From 238e5133c43135d642573068780c999c64cd93d1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 28 Sep 2026 15:08:45 +1000 Subject: [PATCH] feat: preview animated video speed --- src/components/canvas/players/player.ts | 4 +- src/components/canvas/players/video-player.ts | 49 +++++++++++++++- src/core/ui/media-toolbar.ts | 6 +- tests/media-player-fallback.test.ts | 51 +++++++++++++++++ tests/media-toolbar.test.ts | 7 +++ tests/player-sync.test.ts | 56 ++++++++++++++++++- 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index bda30a07..d493e4ca 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -453,8 +453,8 @@ export abstract class Player extends Entity { * Playback speed of the asset (1 = normal). Matches `asset.speed` used by renders. */ public getAssetSpeed(): number { - const { speed = 1 } = this.clipConfiguration.asset as { speed?: number }; - return speed; + const { speed } = this.clipConfiguration.asset as { speed?: unknown }; + return typeof speed === "number" ? speed : 1; } /** diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 848acafb..24dbc7ad 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -8,6 +8,29 @@ import * as pixi from "pixi.js"; import { createPlaceholderGraphic } from "./placeholder-graphic"; import { Player, PlayerType } from "./player"; +// Chrome throws NotSupportedError for playbackRate outside this range. +const MIN_PLAYBACK_RATE = 0.0625; +const MAX_PLAYBACK_RATE = 16; +const SPEED_STEP_SECONDS = 0.01; + +type AnimatedSpeed = { curve: KeyframeBuilder; sourceOffsets: Float64Array }; + +function createAnimatedSpeed(asset: VideoAsset, length: number): AnimatedSpeed | null { + const tweens = asset.speed; + if (!Array.isArray(tweens) || tweens.length === 0) return null; + const [first] = [...tweens].sort((a, b) => (a.start ?? 0) - (b.start ?? 0)); + // The render holds the first tween's speed before it starts; KeyframeBuilder would otherwise ramp from 0. + const curve = new KeyframeBuilder(tweens, length, typeof first.from === "number" ? first.from : 1); + // Trapezoid rule, matching the render, so linear ramps land exactly. + const sourceOffsets = new Float64Array(Math.ceil(length / SPEED_STEP_SECONDS) + 1); + for (let i = 1; i < sourceOffsets.length; i += 1) { + const from = curve.getValue((i - 1) * SPEED_STEP_SECONDS); + const to = curve.getValue(i * SPEED_STEP_SECONDS); + sourceOffsets[i] = sourceOffsets[i - 1] + ((from + to) / 2) * SPEED_STEP_SECONDS; + } + return { curve, sourceOffsets }; +} + export class VideoPlayer extends Player { private texture: pixi.Texture | null; private sprite: pixi.Sprite | null; @@ -15,6 +38,7 @@ export class VideoPlayer extends Player { private isPlaying: boolean; private volumeKeyframeBuilder: KeyframeBuilder; + private animatedSpeed: AnimatedSpeed | null; private syncTimer: number; private activeSyncTimer: number; @@ -31,6 +55,7 @@ export class VideoPlayer extends Player { const videoAsset = this.clipConfiguration.asset as VideoAsset; this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength()); + this.animatedSpeed = createAnimatedSpeed(videoAsset, this.getLength()); this.syncTimer = 0; this.activeSyncTimer = 0; this.skipVideoUpdate = false; @@ -75,7 +100,8 @@ export class VideoPlayer extends Player { const speed = this.getAssetSpeed(); const sourceTime = this.getSourceTime(); - const shouldClipPlay = this.edit.isPlaying && this.isActive() && speed > 0; + const nativeRate = speed >= MIN_PLAYBACK_RATE && speed <= MAX_PLAYBACK_RATE; + const shouldClipPlay = this.edit.isPlaying && this.isActive() && nativeRate; const desyncThreshold = 0.3; if (shouldClipPlay) { @@ -118,7 +144,7 @@ export class VideoPlayer extends Player { // Prepare future clips after a backward seek, before they become visible. // Active paused/frozen clips still sync every 100ms for scrubbing. const shouldSync = this.syncTimer > 100; - if (this.edit.playbackTime < this.getStart() || ((!this.edit.isPlaying || speed === 0) && this.isActive() && shouldSync)) { + if (this.edit.playbackTime < this.getStart() || ((!this.edit.isPlaying || !nativeRate) && this.isActive() && shouldSync)) { this.syncTimer = 0; if (Math.abs(this.texture.source.resource.currentTime - sourceTime) > 0.01) { this.texture.source.resource.currentTime = sourceTime; @@ -177,6 +203,7 @@ export class VideoPlayer extends Player { const videoAsset = this.clipConfiguration.asset as VideoAsset; this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength()); + this.animatedSpeed = createAnimatedSpeed(videoAsset, this.getLength()); } private async loadVideo(): Promise { @@ -284,6 +311,24 @@ export class VideoPlayer extends Player { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } + public override getAssetSpeed(): number { + return this.animatedSpeed ? this.animatedSpeed.curve.getValue(this.getPlaybackTime()) : super.getAssetSpeed(); + } + + public override getSourceTime(): number { + if (!this.animatedSpeed) return super.getSourceTime(); + const { trim = 0 } = this.clipConfiguration.asset as VideoAsset; + const { sourceOffsets } = this.animatedSpeed; + const position = Math.min(this.getPlaybackTime() / SPEED_STEP_SECONDS, sourceOffsets.length - 1); + const index = Math.floor(position); + const next = Math.min(index + 1, sourceOffsets.length - 1); + return trim + sourceOffsets[index] + (sourceOffsets[next] - sourceOffsets[index]) * (position - index); + } + + public override getMaxLength(): number | null { + return this.animatedSpeed ? null : super.getMaxLength(); + } + public override getSourceDuration(): number | null { const duration = this.texture?.source?.resource?.duration; return typeof duration === "number" && Number.isFinite(duration) && duration > 0 ? duration : null; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index a70c077c..32245cd2 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -164,6 +164,7 @@ export class MediaToolbar extends BaseToolbar { private speedValue: HTMLSpanElement | null = null; private speedDisplayInput: HTMLInputElement | null = null; private currentSpeed: number = 1; + private speedAnimated = false; // ─── Advanced Menu ─────────────────────────────────────────────────────────── private dynamicToggle: HTMLInputElement | null = null; @@ -835,7 +836,8 @@ export class MediaToolbar extends BaseToolbar { // Playback speed if (SPEED_ASSET_TYPES.has(this.assetType)) { - const asset = clip.asset as { speed?: number }; + const asset = clip.asset as { speed?: unknown }; + this.speedAnimated = Array.isArray(asset.speed); this.currentSpeed = typeof asset.speed === "number" ? asset.speed : 1; } } @@ -868,7 +870,7 @@ export class MediaToolbar extends BaseToolbar { // Show/hide speed section (only for types whose preview honours speed) if (this.speedSection) { - this.speedSection.classList.toggle("hidden", !SPEED_ASSET_TYPES.has(this.assetType)); + this.speedSection.classList.toggle("hidden", !SPEED_ASSET_TYPES.has(this.assetType) || this.speedAnimated); } // Hide the advanced/dynamic source divider and button for AI types diff --git a/tests/media-player-fallback.test.ts b/tests/media-player-fallback.test.ts index f3b90d64..01c5907c 100644 --- a/tests/media-player-fallback.test.ts +++ b/tests/media-player-fallback.test.ts @@ -368,6 +368,57 @@ describe("media player fallbacks", () => { expect(player.getSize()).toEqual({ width: 1280, height: 720 }); expect(Number.isFinite(player.getScale())).toBe(true); }); + + it("seeks animated-speed clips instead of setting playback rates the browser rejects", async () => { + const edit = createEdit(); + const video = document.createElement("video"); + let currentTime = 0; + const rates: number[] = []; + Object.defineProperties(video, { + currentTime: { + get: () => currentTime, + set: (value: number) => { + currentTime = value; + } + }, + seeking: { get: () => false }, + readyState: { value: 4 }, + playbackRate: { + get: () => rates.at(-1) ?? 1, + set: (value: number) => { + rates.push(value); + } + }, + play: { value: jest.fn().mockResolvedValue(undefined) }, + pause: { value: jest.fn() } + }); + const texture = new pixi.Texture({ source: new pixi.VideoSource({ resource: video }), width: 1280, height: 720 } as ConstructorParameters< + typeof pixi.Texture + >[0]); + edit.assetLoader.loadVideoUnique.mockResolvedValueOnce(texture); + const clip = { + ...createVideoClip(), + asset: { + type: "video", + src: "https://example.com/video.mp4", + trim: 3, + speed: [ + { from: 0, to: 0, start: 0, length: 1 }, + { from: -2, to: -2, start: 1, length: 1 } + ] + } + } as ResolvedClip; + const player = new VideoPlayer(edit as never, clip); + await player.load(); + + edit.isPlaying = true; + [0.5, 1.5].forEach(time => { + edit.playbackTime = time; + player.update(0, 101); + expect(currentTime).toBeCloseTo(player.getSourceTime(), 5); + }); + expect(rates.every(rate => rate >= 0.0625 && rate <= 16)).toBe(true); + }); }); describe("player disposal during load", () => { diff --git a/tests/media-toolbar.test.ts b/tests/media-toolbar.test.ts index 4a4ce819..272bf0ba 100644 --- a/tests/media-toolbar.test.ts +++ b/tests/media-toolbar.test.ts @@ -868,6 +868,13 @@ describe("MediaToolbar", () => { toolbar.dispose(); }); + it("hides the speed section when speed is animated", () => { + const { toolbar, parent } = mountWithVideoClip({ speed: [{ from: 1, to: 3, start: 0, length: 1 }] }); + const speedSection = parent.querySelector("[data-speed-section]") as HTMLElement; + expect(speedSection.classList.contains("hidden")).toBe(true); + toolbar.dispose(); + }); + it("hides the speed section for image assets", () => { const mockEdit = createMockEditSession(); mockEdit.getResolvedClip.mockReturnValue(createImageClip()); diff --git a/tests/player-sync.test.ts b/tests/player-sync.test.ts index 3424db08..bb5a1b65 100644 --- a/tests/player-sync.test.ts +++ b/tests/player-sync.test.ts @@ -16,7 +16,7 @@ import { LumaPlayer } from "@canvas/players/luma-player"; import { CaptionPlayer } from "@canvas/players/caption-player"; import type { Edit } from "@core/edit-session"; import { sec, type Seconds } from "@core/timing/types"; -import type { ResolvedClip, VideoAsset, AudioAsset, LumaAsset, CaptionAsset } from "@schemas"; +import type { ResolvedClip, VideoAsset, AudioAsset, LumaAsset, CaptionAsset, Tween } from "@schemas"; import * as pixi from "pixi.js"; // Mock pixi-filters (must be before pixi.js) @@ -414,6 +414,60 @@ describe("getMaxLength", () => { }); }); +function createAnimatedSpeedClipConfig(speed: Tween[], trim = 0): ResolvedClip { + return { + asset: { type: "video", src: "test.mp4", trim, speed } as VideoAsset, + start: 0, + length: 2 + } as ResolvedClip; +} + +describe("animated speed", () => { + it("starts the source at trim and advances by the integrated speed", () => { + // 1× → 3× over the first second covers 2 s of source + const player = new VideoPlayer(createMockEdit(1), createAnimatedSpeedClipConfig([{ from: 1, to: 3, start: 0, length: 1 }], 1)); + expect(player.getSourceTime()).toBeCloseTo(3, 3); + }); + + it("holds the last speed after the final tween", () => { + const player = new VideoPlayer(createMockEdit(1.5), createAnimatedSpeedClipConfig([{ from: 1, to: 3, start: 0, length: 1 }])); + expect(player.getSourceTime()).toBeCloseTo(3.5, 3); + expect(player.getAssetSpeed()).toBeCloseTo(3, 5); + }); + + it("holds the first speed before the first tween", () => { + const player = new VideoPlayer(createMockEdit(0.5), createAnimatedSpeedClipConfig([{ from: 2, to: 2, start: 1, length: 1 }])); + expect(player.getSourceTime()).toBeCloseTo(1, 3); + }); + + it("freezes at trim when the speed is 0", () => { + const player = new VideoPlayer(createMockEdit(1), createAnimatedSpeedClipConfig([{ from: 0, to: 0, start: 0, length: 2 }], 4)); + expect(player.getSourceTime()).toBeCloseTo(4, 5); + }); + + it("leaves the resize limit unbounded once media is loaded", () => { + const player = new VideoPlayer(createMockEdit(0), createAnimatedSpeedClipConfig([{ from: 1, to: 2, start: 0, length: 1 }])); + // @ts-expect-error - accessing private property for testing + player.texture = { source: new pixi.VideoSource({ resource: createMockVideoElement() }), width: 1920, height: 1080 }; + expect(player.getMaxLength()).toBeNull(); + }); + + it("plays an empty speed array at 1× from trim", () => { + const player = new VideoPlayer(createMockEdit(1), createAnimatedSpeedClipConfig([], 2)); + expect(player.getAssetSpeed()).toBe(1); + expect(player.getSourceTime()).toBeCloseTo(3, 5); + }); + + it("keeps adjacent tweens separate: freeze then reverse", () => { + const speed = [ + { from: 0, to: 0, start: 0, length: 1 }, + { from: -2, to: -2, start: 1, length: 1 } + ]; + expect(new VideoPlayer(createMockEdit(0.5), createAnimatedSpeedClipConfig(speed, 3)).getSourceTime()).toBeCloseTo(3, 3); + expect(new VideoPlayer(createMockEdit(1.5), createAnimatedSpeedClipConfig(speed, 3)).getSourceTime()).toBeCloseTo(2, 1); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // AudioPlayer Tests // ─────────────────────────────────────────────────────────────────────────────