Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
49 changes: 47 additions & 2 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,37 @@ 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<pixi.VideoSource> | null;
private sprite: pixi.Sprite | null;
private placeholder: pixi.Graphics | null;
private isPlaying: boolean;

private volumeKeyframeBuilder: KeyframeBuilder;
private animatedSpeed: AnimatedSpeed | null;

private syncTimer: number;
private activeSyncTimer: number;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/core/ui/media-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/media-player-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
7 changes: 7 additions & 0 deletions tests/media-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
56 changes: 55 additions & 1 deletion tests/player-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
// ─────────────────────────────────────────────────────────────────────────────
Expand Down