From 7ecf9a23b69cfda75438b19be221d8fe819d43bf Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 22 Jul 2026 14:24:49 +1000 Subject: [PATCH 1/2] feat: honour asset.speed in preview playback with toolbar speed control and speed-aware clip resizing --- src/components/canvas/players/audio-player.ts | 35 +-- src/components/canvas/players/player.ts | 37 ++++ src/components/canvas/players/video-player.ts | 39 ++-- .../interaction/interaction-controller.ts | 21 +- src/core/ui/media-toolbar.ts | 203 +++++++++++++++++- src/styles/ui/media-toolbar.css | 15 ++ src/templates/speed.json | 98 +++++++++ tests/interaction-controller.test.ts | 2 +- tests/media-toolbar.test.ts | 113 ++++++++++ tests/player-sync.test.ts | 94 +++++++- 10 files changed, 613 insertions(+), 44 deletions(-) create mode 100644 src/templates/speed.json diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index 56ebeefa..cd049e37 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -57,8 +57,6 @@ export class AudioPlayer extends Player { public override update(deltaTime: number, elapsed: number): void { super.update(deltaTime, elapsed); - const { trim = 0 } = this.clipConfiguration.asset as AudioAsset; - this.syncTimer += elapsed; this.getContainer().alpha = 0; @@ -67,15 +65,16 @@ export class AudioPlayer extends Player { return; } - const shouldClipPlay = this.edit.isPlaying && this.isActive(); - // getPlaybackTime() returns seconds - const playbackTime = this.getPlaybackTime(); + const speed = this.getAssetSpeed(); + const sourceTime = this.getSourceTime(); + const shouldClipPlay = this.edit.isPlaying && this.isActive() && speed > 0; if (shouldClipPlay) { if (!this.isPlaying) { this.isPlaying = true; this.audioResource.volume(this.getVolume()); - this.audioResource.seek(playbackTime + trim); + this.audioResource.rate(speed); + this.audioResource.seek(sourceTime); this.audioResource.play(); } @@ -83,13 +82,17 @@ export class AudioPlayer extends Player { this.audioResource.volume(this.getVolume()); } + if (this.audioResource.rate() !== speed) { + this.audioResource.rate(speed); + } + // Desync threshold: 0.1 seconds (100ms) const desyncThreshold = 0.1; - // Both audioResource.seek() and playbackTime are in seconds - const shouldSync = Math.abs(this.audioResource.seek() - trim - playbackTime) > desyncThreshold; + // Both audioResource.seek() and sourceTime are in source-media seconds + const shouldSync = Math.abs((this.audioResource.seek() as number) - sourceTime) > desyncThreshold; if (shouldSync) { - this.audioResource.seek(playbackTime + trim); + this.audioResource.seek(sourceTime); } } @@ -102,7 +105,7 @@ export class AudioPlayer extends Player { const shouldSync = this.syncTimer > 100; if (!this.edit.isPlaying && this.isActive() && shouldSync) { this.syncTimer = 0; - this.audioResource.seek(playbackTime + trim); + this.audioResource.seek(sourceTime); } } @@ -159,13 +162,15 @@ export class AudioPlayer extends Player { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } + public override getSourceDuration(): number | null { + const duration = this.audioResource?.duration(); + return typeof duration === "number" && duration > 0 ? duration : null; + } + public getCurrentDrift(): number { if (!this.audioResource) return 0; - const { trim = 0 } = this.clipConfiguration.asset as AudioAsset; - const audioTime = this.audioResource.seek() as number; - // getPlaybackTime() returns seconds, audioTime is also seconds - const playbackTime = this.getPlaybackTime(); - return Math.abs(audioTime - trim - playbackTime); + // Both seek() and getSourceTime() are in source-media seconds + return Math.abs((this.audioResource.seek() as number) - this.getSourceTime()); } private createVolumeKeyframes(asset: AudioAsset, baseVolume: number): Keyframe[] | number { diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index f9032722..b6a0e048 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -437,6 +437,43 @@ export abstract class Player extends Entity { return clipTime; } + /** + * 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; + } + + /** + * Map clip playback time to source media time. Matches render output: + * sourceTime = speed × (trim + playbackTime), i.e. trim is also scaled by speed. + */ + public getSourceTime(): number { + const { trim = 0 } = this.clipConfiguration.asset as { trim?: number }; + return this.getAssetSpeed() * (trim + this.getPlaybackTime()); + } + + /** Duration of the source media in seconds, or null when unknown or not applicable. */ + public getSourceDuration(): number | null { + return null; + } + + /** + * Longest clip length the source media can fill at the current trim and speed + * (duration / speed − trim), or null when unbounded or the media isn't loaded yet. + */ + public getMaxLength(): number | null { + const duration = this.getSourceDuration(); + if (duration === null) return null; + + const speed = this.getAssetSpeed(); + if (speed <= 0) return null; + + const { trim = 0 } = this.clipConfiguration.asset as { trim?: number }; + return Math.max(0.1, duration / speed - trim); + } + public abstract getSize(): Size; /** diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 1c29b873..5399aa63 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -62,24 +62,22 @@ export class VideoPlayer extends Player { return; } - const { trim = 0 } = this.clipConfiguration.asset as VideoAsset; - this.syncTimer += elapsed; if (!this.texture) { return; } - // getPlaybackTime() returns seconds - const playbackTime = this.getPlaybackTime(); - const shouldClipPlay = this.edit.isPlaying && this.isActive(); + const speed = this.getAssetSpeed(); + const sourceTime = this.getSourceTime(); + const shouldClipPlay = this.edit.isPlaying && this.isActive() && speed > 0; if (shouldClipPlay) { if (!this.isPlaying) { this.isPlaying = true; this.activeSyncTimer = 0; this.texture.source.resource.volume = this.getVolume(); - this.texture.source.resource.currentTime = playbackTime + trim; + this.texture.source.resource.currentTime = sourceTime; this.texture.source.resource.play().catch(console.error); } @@ -87,16 +85,19 @@ export class VideoPlayer extends Player { this.texture.source.resource.volume = this.getVolume(); } + if (this.texture.source.resource.playbackRate !== speed) { + this.texture.source.resource.playbackRate = speed; + } + // Rate-limit sync checks to once per second to prevent audio stuttering this.activeSyncTimer += elapsed; if (this.activeSyncTimer > 1000) { this.activeSyncTimer = 0; // Desync threshold: 0.3 seconds (300ms) const desyncThreshold = 0.3; - // Both currentTime and playbackTime are in seconds - const drift = Math.abs(this.texture.source.resource.currentTime - trim - playbackTime); + const drift = Math.abs(this.texture.source.resource.currentTime - sourceTime); if (drift > desyncThreshold) { - this.texture.source.resource.currentTime = playbackTime + trim; + this.texture.source.resource.currentTime = sourceTime; } } } @@ -106,11 +107,11 @@ export class VideoPlayer extends Player { this.texture.source.resource.pause(); } - // When paused, sync every 100ms for scrubbing + // When paused (or frozen at speed 0), sync every 100ms for scrubbing const shouldSync = this.syncTimer > 100; - if (!this.edit.isPlaying && this.isActive() && shouldSync) { + if ((!this.edit.isPlaying || speed === 0) && this.isActive() && shouldSync) { this.syncTimer = 0; - this.texture.source.resource.currentTime = playbackTime + trim; + this.texture.source.resource.currentTime = sourceTime; } } @@ -214,6 +215,8 @@ export class VideoPlayer extends Player { // Set initial volume immediately so the element never sits at the browser default of 1.0 this.texture.source.resource.volume = this.getVolume(); + + this.texture.source.resource.preservesPitch = false; } private disposeVideo(): void { @@ -249,13 +252,15 @@ export class VideoPlayer extends Player { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } + public override getSourceDuration(): number | null { + const duration = this.texture?.source?.resource?.duration; + return typeof duration === "number" && Number.isFinite(duration) && duration > 0 ? duration : null; + } + public getCurrentDrift(): number { if (!this.texture?.source?.resource) return 0; - const { trim = 0 } = this.clipConfiguration.asset as VideoAsset; - const videoTime = this.texture.source.resource.currentTime; - // getPlaybackTime() returns seconds, videoTime is also seconds - const playbackTime = this.getPlaybackTime(); - return Math.abs(videoTime - trim - playbackTime); + // Both currentTime and getSourceTime() are in source-media seconds + return Math.abs(this.texture.source.resource.currentTime - this.getSourceTime()); } private createCroppedTexture(texture: pixi.Texture): pixi.Texture { diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts index 0ff70b4a..b87c50a0 100644 --- a/src/components/timeline/interaction/interaction-controller.ts +++ b/src/components/timeline/interaction/interaction-controller.ts @@ -487,11 +487,13 @@ export class InteractionController implements TimelineInteractionRegistration { // Calculate new dimensions based on edge const { edge, originalStart, originalLength, clipElement } = state; + const maxLength = this.getResizeMaxLength(state.clipRef, originalLength); if (edge === "left") { // Resize from left edge (keep end fixed, change start and length) const originalEnd = originalStart + originalLength; - const newStart = sec(Math.max(0, Math.min(time, originalEnd - 0.1))); + const minStart = maxLength !== null ? Math.max(0, originalEnd - maxLength) : 0; + const newStart = sec(Math.max(minStart, Math.min(time, originalEnd - 0.1))); const newLength = sec(originalEnd - newStart); clipElement.style.setProperty("--clip-start", String(newStart)); @@ -499,7 +501,7 @@ export class InteractionController implements TimelineInteractionRegistration { this.feedbackElements.dragTimeTooltip = showDragTimeTooltip(this.feedbackElements, newStart, e.clientX - rect.left, e.clientY - rect.top); } else { // Resize from right edge - const newLength = sec(Math.max(0.1, time - originalStart)); + const newLength = sec(Math.max(0.1, Math.min(time - originalStart, maxLength ?? Infinity))); clipElement.style.setProperty("--clip-length", String(newLength)); this.feedbackElements.dragTimeTooltip = showDragTimeTooltip( @@ -774,10 +776,13 @@ export class InteractionController implements TimelineInteractionRegistration { // Get attached luma Player reference BEFORE changes (stable across index changes) const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); + const maxLength = this.getResizeMaxLength(clipRef, originalLength); + if (edge === "left") { // Resize from left edge (keep end fixed, change start and length) const originalEnd = originalStart + originalLength; - const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); + const minStart = maxLength !== null ? Math.max(0, originalEnd - maxLength) : 0; + const newStart = Math.max(minStart, Math.min(time, originalEnd - 0.1)); const newLength = originalEnd - newStart; if (newStart !== originalStart || newLength !== originalLength) { @@ -810,7 +815,7 @@ export class InteractionController implements TimelineInteractionRegistration { } } else { // Resize from right edge (keep start fixed, change length) - const newLength = Math.max(0.1, time - originalStart); + const newLength = Math.max(0.1, Math.min(time - originalStart, maxLength ?? Infinity)); if (newLength !== originalLength) { const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, sec(newLength)); @@ -841,6 +846,14 @@ export class InteractionController implements TimelineInteractionRegistration { this.edit.executeEditCommand(cmd); } + /** + * Longest length a resize may grow the clip to, or null when unbounded. + */ + private getResizeMaxLength(clipRef: ClipRef, originalLength: number): number | null { + const maxLength = this.edit.getPlayerClip(clipRef.trackIndex, clipRef.clipIndex)?.getMaxLength() ?? null; + return maxLength === null ? null : Math.max(maxLength, originalLength); + } + /** Resolve clip collision based on clip boundaries (delegates to pure function) */ private resolveClipCollisionOnTrack(trackIndex: number, desiredStart: Seconds, clipLength: Seconds, excludeClip: ClipRef): CollisionResult { const track = this.stateManager.getTracks()[trackIndex]; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index 8111e044..5180eaa5 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -37,6 +37,7 @@ const ICONS = { chevron: ``, check: ``, moreVertical: ``, + speed: ``, effect: ``, fadeIn: ``, fadeOut: ``, @@ -55,6 +56,16 @@ const VOLUME_ASSET_TYPES: ReadonlySet = new Set(["video", "audio /** Asset types that have audio fade controls (audio-only types) */ const AUDIO_FADE_ASSET_TYPES: ReadonlySet = new Set(["audio", "text-to-speech"]); +/** Asset types that support playback speed (previewed live and honoured by renders) */ +const SPEED_ASSET_TYPES: ReadonlySet = new Set(["video", "audio"]); + +const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4]; + +const SPEED_MIN = 0.1; +const SPEED_MAX = 10; +/** Slider midpoint: log-scaled so 1× sits centred and 0.5–2× gets half the travel */ +const SPEED_SLIDER_HALF = 300; + export interface MediaToolbarOptions { mergeFields?: boolean; } @@ -104,6 +115,7 @@ export class MediaToolbar extends BaseToolbar { private effectBtn: HTMLButtonElement | null = null; private advancedBtn: HTMLButtonElement | null = null; private audioFadeBtn: HTMLButtonElement | null = null; + private speedBtn: HTMLButtonElement | null = null; // ─── Popup Elements ────────────────────────────────────────────────────────── private fitPopup: HTMLDivElement | null = null; @@ -114,6 +126,7 @@ export class MediaToolbar extends BaseToolbar { private effectPopup: HTMLDivElement | null = null; private advancedPopup: HTMLDivElement | null = null; private audioFadePopup: HTMLDivElement | null = null; + private speedPopup: HTMLDivElement | null = null; // ─── Other Elements ────────────────────────────────────────────────────────── private fitLabel: HTMLSpanElement | null = null; @@ -123,6 +136,11 @@ export class MediaToolbar extends BaseToolbar { private volumeSection: HTMLDivElement | null = null; private visualSection: HTMLDivElement | null = null; private audioSection: HTMLDivElement | null = null; + private speedSection: HTMLDivElement | null = null; + private speedSlider: HTMLInputElement | null = null; + private speedValue: HTMLSpanElement | null = null; + private speedDisplayInput: HTMLInputElement | null = null; + private currentSpeed: number = 1; // ─── Advanced Menu ─────────────────────────────────────────────────────────── private dynamicToggle: HTMLInputElement | null = null; @@ -259,6 +277,27 @@ export class MediaToolbar extends BaseToolbar { + +
+
+
+ +
+
Speed
+
+ + +
+
+ ${SPEED_PRESETS.map(p => ``).join("")} +
+
+
+
+
@@ -333,6 +372,7 @@ export class MediaToolbar extends BaseToolbar { this.effectBtn = this.container.querySelector('[data-action="effect"]'); this.advancedBtn = this.container.querySelector('[data-action="advanced"]'); this.audioFadeBtn = this.container.querySelector('[data-action="audio-fade"]'); + this.speedBtn = this.container.querySelector('[data-action="speed"]'); this.fitPopup = this.container.querySelector('[data-popup="fit"]'); this.opacityPopup = this.container.querySelector('[data-popup="opacity"]'); @@ -342,6 +382,7 @@ export class MediaToolbar extends BaseToolbar { this.effectPopup = this.container.querySelector('[data-popup="effect"]'); this.advancedPopup = this.container.querySelector('[data-popup="advanced"]'); this.audioFadePopup = this.container.querySelector('[data-popup="audio-fade"]'); + this.speedPopup = this.container.querySelector('[data-popup="speed"]'); this.fitLabel = this.container.querySelector("[data-fit-label]"); this.volumeValue = this.container.querySelector("[data-volume-value]"); @@ -350,6 +391,10 @@ export class MediaToolbar extends BaseToolbar { this.volumeSection = this.container.querySelector("[data-volume-section]"); this.visualSection = this.container.querySelector("[data-visual-section]"); this.audioSection = this.container.querySelector("[data-audio-section]"); + this.speedSection = this.container.querySelector("[data-speed-section]"); + this.speedSlider = this.container.querySelector("[data-speed-slider]"); + this.speedValue = this.container.querySelector("[data-speed-value]"); + this.speedDisplayInput = this.container.querySelector("[data-speed-display]"); this.dynamicToggle = this.container.querySelector("[data-dynamic-toggle]"); this.dynamicPanel = this.container.querySelector("[data-dynamic-panel]"); @@ -503,6 +548,64 @@ export class MediaToolbar extends BaseToolbar { }, { signal } ); + this.speedBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("speed"); + }, + { signal } + ); + + // Speed slider: readout-only during drag, single commit on release. + this.speedSlider?.addEventListener( + "input", + () => { + this.currentSpeed = MediaToolbar.sliderToSpeed(parseInt(this.speedSlider!.value, 10)); + this.updateSpeedDisplay(); + }, + { signal } + ); + this.speedSlider?.addEventListener("change", () => this.handleSpeedChange(MediaToolbar.sliderToSpeed(parseInt(this.speedSlider!.value, 10))), { + signal + }); + + // Speed display input: commit on blur or Enter, revert on Escape + this.speedDisplayInput?.addEventListener("blur", () => this.commitSpeedInputValue(), { signal }); + this.speedDisplayInput?.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + this.commitSpeedInputValue(); + this.speedDisplayInput?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.updateSpeedDisplay(); + this.speedDisplayInput?.blur(); + } + }, + { signal } + ); + this.speedDisplayInput?.addEventListener( + "focus", + () => { + this.speedDisplayInput?.select(); + }, + { signal } + ); + + // Speed presets + this.speedPopup?.querySelectorAll("[data-speed-preset]").forEach(btn => { + btn.addEventListener( + "click", + e => { + const el = e.currentTarget as HTMLElement; + this.handleSpeedChange(parseFloat(el.dataset["speedPreset"] || "1")); + }, + { signal } + ); + }); // Dynamic source handlers this.setupDynamicSourceHandlers(signal); @@ -571,7 +674,7 @@ export class MediaToolbar extends BaseToolbar { }); } - private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced" | "audio-fade"): void { + private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced" | "audio-fade" | "speed"): void { const popupMap = { fit: { popup: this.fitPopup, btn: this.fitBtn }, opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, @@ -580,7 +683,8 @@ export class MediaToolbar extends BaseToolbar { transition: { popup: this.transitionPopup, btn: this.transitionBtn }, effect: { popup: this.effectPopup, btn: this.effectBtn }, advanced: { popup: this.advancedPopup, btn: this.advancedBtn }, - "audio-fade": { popup: this.audioFadePopup, btn: this.audioFadeBtn } + "audio-fade": { popup: this.audioFadePopup, btn: this.audioFadeBtn }, + speed: { popup: this.speedPopup, btn: this.speedBtn } }; const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); @@ -604,6 +708,7 @@ export class MediaToolbar extends BaseToolbar { this.effectBtn?.classList.remove("active"); this.advancedBtn?.classList.remove("active"); this.audioFadeBtn?.classList.remove("active"); + this.speedBtn?.classList.remove("active"); } protected override getPopupList(): (HTMLElement | null)[] { @@ -615,7 +720,8 @@ export class MediaToolbar extends BaseToolbar { this.transitionPopup, this.effectPopup, this.advancedPopup, - this.audioFadePopup + this.audioFadePopup, + this.speedPopup ]; } @@ -651,6 +757,12 @@ export class MediaToolbar extends BaseToolbar { const asset = clip.asset as { effect?: string }; this.audioFadeEffect = (asset.effect as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut") || ""; } + + // Playback speed + if (SPEED_ASSET_TYPES.has(this.assetType)) { + const asset = clip.asset as { speed?: number }; + this.currentSpeed = typeof asset.speed === "number" ? asset.speed : 1; + } } // Update displays @@ -658,6 +770,7 @@ export class MediaToolbar extends BaseToolbar { this.updateOpacityDisplay(); this.updateScaleDisplay(); this.updateVolumeDisplay(); + this.updateSpeedDisplay(); // Update active states this.updateFitActiveState(); @@ -679,6 +792,11 @@ export class MediaToolbar extends BaseToolbar { this.audioSection.classList.toggle("hidden", !AUDIO_FADE_ASSET_TYPES.has(this.assetType)); } + // 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)); + } + // Hide the advanced/dynamic source divider and button for AI types const advancedDivider = this.container?.querySelector("[data-divider-before-advanced]") as HTMLElement | null; if (advancedDivider) { @@ -803,6 +921,79 @@ export class MediaToolbar extends BaseToolbar { } } + // ─── Speed Handlers ────────────────────────────────────────────────────────── + + /** Map slider position (0 to 2×half) to speed via log scale: half-way = 1× */ + private static sliderToSpeed(t: number): number { + const speed = 10 ** ((t - SPEED_SLIDER_HALF) / SPEED_SLIDER_HALF); + return Math.round(speed * 100) / 100; + } + + private static speedToSlider(speed: number): number { + const clamped = Math.max(SPEED_MIN, Math.min(SPEED_MAX, speed)); + return Math.round(Math.log10(clamped) * SPEED_SLIDER_HALF + SPEED_SLIDER_HALF); + } + + /** Format for display: trims trailing zeros, e.g. 1×, 0.5×, 1.25× */ + private static formatSpeed(speed: number): string { + return `${parseFloat(speed.toFixed(2))}×`; + } + + private handleSpeedChange(value: number): void { + this.currentSpeed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, value)); + this.updateSpeedDisplay(); + + if (!SPEED_ASSET_TYPES.has(this.assetType)) return; + + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || !clipId) return; + + const asset = clip.asset as Record; + const newAsset: Record = { ...asset, speed: this.currentSpeed === 1 ? undefined : this.currentSpeed }; + const updates: Record = { asset: newAsset }; + + // Rescale length and trim by oldSpeed/newSpeed so the clip keeps playing the same + // source window at the new pace (trim is speed-scaled at render time, so it moves too) + const oldSpeed = typeof asset["speed"] === "number" ? (asset["speed"] as number) : 1; + if (oldSpeed > 0 && oldSpeed !== this.currentSpeed) { + const ratio = oldSpeed / this.currentSpeed; + const docClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); + if (typeof docClip?.length === "number") { + updates["length"] = Math.max(0.1, docClip.length * ratio); + } + if (typeof asset["trim"] === "number" && asset["trim"] > 0) { + newAsset["trim"] = asset["trim"] * ratio; + } + } + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + + private commitSpeedInputValue(): void { + if (!this.speedDisplayInput) return; + + const stripped = this.speedDisplayInput.value.replace(/[^0-9.]/g, ""); + const num = parseFloat(stripped); + if (Number.isNaN(num)) { + this.updateSpeedDisplay(); + return; + } + this.handleSpeedChange(num); + } + + private updateSpeedDisplay(): void { + const text = MediaToolbar.formatSpeed(this.currentSpeed); + if (this.speedValue) this.speedValue.textContent = text; + if (this.speedSlider) this.speedSlider.value = String(MediaToolbar.speedToSlider(this.currentSpeed)); + if (this.speedDisplayInput) this.speedDisplayInput.value = text; + + this.speedPopup?.querySelectorAll("[data-speed-preset]").forEach(btn => { + const el = btn as HTMLElement; + el.classList.toggle("active", parseFloat(el.dataset["speedPreset"] || "1") === this.currentSpeed); + }); + } + /** * Parse and commit the value from the volume text input. */ @@ -1117,6 +1308,7 @@ export class MediaToolbar extends BaseToolbar { this.effectBtn = null; this.advancedBtn = null; this.audioFadeBtn = null; + this.speedBtn = null; this.fitPopup = null; this.opacityPopup = null; @@ -1126,6 +1318,7 @@ export class MediaToolbar extends BaseToolbar { this.effectPopup = null; this.advancedPopup = null; this.audioFadePopup = null; + this.speedPopup = null; this.fitLabel = null; this.volumeSlider = null; @@ -1134,6 +1327,10 @@ export class MediaToolbar extends BaseToolbar { this.volumeSection = null; this.visualSection = null; this.audioSection = null; + this.speedSection = null; + this.speedSlider = null; + this.speedValue = null; + this.speedDisplayInput = null; this.dynamicToggle = null; this.dynamicPanel = null; diff --git a/src/styles/ui/media-toolbar.css b/src/styles/ui/media-toolbar.css index 3918e541..0cdcdca9 100644 --- a/src/styles/ui/media-toolbar.css +++ b/src/styles/ui/media-toolbar.css @@ -324,6 +324,21 @@ color: #fff; } +/* Speed section (video and audio assets) */ +.ss-media-toolbar-speed { + display: flex; + align-items: center; +} + +.ss-media-toolbar-speed.hidden { + display: none; +} + +/* Speed popup needs width for six preset chips */ +.ss-media-toolbar-popup--speed { + min-width: 260px; +} + /* Volume icon wrapper - needs display for SVG visibility */ [data-volume-icon] { display: flex; diff --git a/src/templates/speed.json b/src/templates/speed.json new file mode 100644 index 00000000..c786fa05 --- /dev/null +++ b/src/templates/speed.json @@ -0,0 +1,98 @@ +{ + "timeline": { + "background": "#000000", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "text", + "text": "0x (freeze)", + "font": { "color": "#ffffff", "family": "Montserrat SemiBold", "size": "40" }, + "width": 300, + "height": 60 + }, + "start": 0, + "length": 4, + "position": "top" + }, + { + "asset": { + "type": "text", + "text": "2x", + "font": { "color": "#ffffff", "family": "Montserrat SemiBold", "size": "40" }, + "width": 300, + "height": 60 + }, + "start": 4, + "length": 4, + "position": "top" + }, + { + "asset": { + "type": "text", + "text": "0.5x", + "font": { "color": "#ffffff", "family": "Montserrat SemiBold", "size": "40" }, + "width": 300, + "height": 60 + }, + "start": 8, + "length": 4, + "position": "top" + }, + { + "asset": { + "type": "text", + "text": "2x + trim 2", + "font": { "color": "#ffffff", "family": "Montserrat SemiBold", "size": "40" }, + "width": 300, + "height": 60 + }, + "start": 12, + "length": 3, + "position": "top" + } + ] + }, + { + "clips": [ + { + "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 1 }, + "start": 0, + "length": 4 + }, + { + "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 2 }, + "start": 4, + "length": 4 + }, + { + "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 0.5 }, + "start": 8, + "length": 4 + }, + { + "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 2, "trim": 2 }, + "start": 12, + "length": 3 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "audio", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/5f6210e5-73f0-4bcc-b8e4-292a94585f55/source.mp3", + "volume": 0.2, + "speed": 1.5 + }, + "start": 0, + "length": 10 + } + ] + } + ] + }, + "output": { "format": "mp4", "size": { "width": 1280, "height": 720 } } +} diff --git a/tests/interaction-controller.test.ts b/tests/interaction-controller.test.ts index e0efb2cc..4d92c948 100644 --- a/tests/interaction-controller.test.ts +++ b/tests/interaction-controller.test.ts @@ -140,7 +140,7 @@ function createMockEdit() { executeEditCommand: jest.fn((cmd: unknown) => { executedCommands.push(cmd); }), - getPlayerClip: jest.fn(() => ({ id: "player-1" })), + getPlayerClip: jest.fn(() => ({ id: "player-1", getMaxLength: () => null })), findClipIndices: jest.fn((player: unknown) => { if (!player) return null; return { trackIndex: 0, clipIndex: 0 }; diff --git a/tests/media-toolbar.test.ts b/tests/media-toolbar.test.ts index f8572dc8..d7c703ac 100644 --- a/tests/media-toolbar.test.ts +++ b/tests/media-toolbar.test.ts @@ -56,6 +56,7 @@ function createMockEditSession() { return { getClipId: jest.fn().mockReturnValue("clip-1"), getResolvedClip: jest.fn(), + getDocumentClip: jest.fn(), updateClip: jest.fn(), updateClipInDocument: jest.fn(), resolveClip: jest.fn(), @@ -367,6 +368,118 @@ describe("MediaToolbar", () => { }); }); + describe("speed control", () => { + function mountWithVideoClip(assetOverrides: Record = {}, docLength: number | string = 10) { + const mockEdit = createMockEditSession(); + const clip = createVideoClip(); + Object.assign(clip.asset, assetOverrides); + mockEdit.getResolvedClip.mockReturnValue(clip); + mockEdit.getDocumentClip.mockReturnValue({ ...clip, length: docLength }); + + const mounted = mountToolbar(mockEdit); + mounted.toolbar.show(0, 0); + return { mockEdit, ...mounted }; + } + + it("rescales length by oldSpeed/newSpeed when a preset is clicked", () => { + const { mockEdit, toolbar, parent } = mountWithVideoClip(); + + (parent.querySelector('[data-speed-preset="2"]') as HTMLButtonElement).click(); + + // 10s at 1× → 5s at 2×, same source window + expect(mockEdit.updateClip).toHaveBeenCalledWith( + 0, + 0, + expect.objectContaining({ + length: 5, + asset: expect.objectContaining({ speed: 2 }) + }) + ); + toolbar.dispose(); + }); + + it("rescales trim with length so the source start frame is unchanged", () => { + const { mockEdit, toolbar, parent } = mountWithVideoClip({ trim: 2 }); + + (parent.querySelector('[data-speed-preset="2"]') as HTMLButtonElement).click(); + + // Renders scale trim by speed: trim 2 at 1× and trim 1 at 2× both start at source 2s + expect(mockEdit.updateClip).toHaveBeenCalledWith( + 0, + 0, + expect.objectContaining({ + length: 5, + asset: expect.objectContaining({ speed: 2, trim: 1 }) + }) + ); + toolbar.dispose(); + }); + + it("preserves auto/end length intent strings (no numeric rewrite)", () => { + const { mockEdit, toolbar, parent } = mountWithVideoClip({}, "auto"); + + (parent.querySelector('[data-speed-preset="2"]') as HTMLButtonElement).click(); + + const updates = mockEdit.updateClip.mock.calls[0][2]; + expect(updates).not.toHaveProperty("length"); + toolbar.dispose(); + }); + + it("strips speed from the asset when set back to exactly 1×", () => { + const { mockEdit, toolbar, parent } = mountWithVideoClip({ speed: 2 }, 5); + + (parent.querySelector('[data-speed-preset="1"]') as HTMLButtonElement).click(); + + expect(mockEdit.updateClip).toHaveBeenCalledWith( + 0, + 0, + expect.objectContaining({ + length: 10, + asset: expect.objectContaining({ speed: undefined }) + }) + ); + toolbar.dispose(); + }); + + it("slider drag updates the readout only, committing once on release", () => { + const { mockEdit, toolbar } = mountWithVideoClip(); + + const { speedSlider } = toolbar as any; // eslint-disable-line @typescript-eslint/no-explicit-any + expect(speedSlider).toBeTruthy(); + + // Slide to the max position (10×) - no document writes while dragging + speedSlider.value = "600"; + speedSlider.dispatchEvent(new Event("input", { bubbles: true })); + expect(mockEdit.updateClip).not.toHaveBeenCalled(); + expect(mockEdit.updateClipInDocument).not.toHaveBeenCalled(); + + // Release commits a single update through the command path (emits ClipUpdated) + speedSlider.dispatchEvent(new Event("change", { bubbles: true })); + expect(mockEdit.updateClip).toHaveBeenCalledTimes(1); + expect(mockEdit.updateClip).toHaveBeenCalledWith( + 0, + 0, + expect.objectContaining({ + length: 1, + asset: expect.objectContaining({ speed: 10 }) + }) + ); + toolbar.dispose(); + }); + + it("hides the speed section for image assets", () => { + const mockEdit = createMockEditSession(); + mockEdit.getResolvedClip.mockReturnValue(createImageClip()); + + const { toolbar, parent } = mountToolbar(mockEdit); + toolbar.show(0, 0); + + const speedSection = parent.querySelector("[data-speed-section]") as HTMLElement; + expect(speedSection.classList.contains("hidden")).toBe(true); + toolbar.dispose(); + }); + }); + describe("section visibility by asset type", () => { it("shows visual section for image assets", () => { const mockEdit = createMockEditSession(); diff --git a/tests/player-sync.test.ts b/tests/player-sync.test.ts index 868c43c5..3424db08 100644 --- a/tests/player-sync.test.ts +++ b/tests/player-sync.test.ts @@ -100,6 +100,7 @@ jest.mock("howler", () => ({ stop: jest.fn(), seek: jest.fn().mockReturnValue(0), volume: jest.fn().mockReturnValue(1), + rate: jest.fn().mockReturnValue(1), duration: jest.fn().mockReturnValue(10), unload: jest.fn() })) @@ -195,17 +196,17 @@ function createMockEdit(playbackTimeSec: number): Edit { } as unknown as Edit; } -function createVideoClipConfig(trim = 0, start = 0): ResolvedClip { +function createVideoClipConfig(trim = 0, start = 0, speed?: number): ResolvedClip { return { - asset: { type: "video", src: "test.mp4", trim } as VideoAsset, + asset: { type: "video", src: "test.mp4", trim, speed } as VideoAsset, start, length: 10 } as ResolvedClip; } -function createAudioClipConfig(trim = 0, start = 0): ResolvedClip { +function createAudioClipConfig(trim = 0, start = 0, speed?: number): ResolvedClip { return { - asset: { type: "audio", src: "test.mp3", trim } as AudioAsset, + asset: { type: "audio", src: "test.mp3", trim, speed } as AudioAsset, start, length: 10 } as ResolvedClip; @@ -305,6 +306,28 @@ describe("VideoPlayer", () => { expect(drift).toBeLessThan(10); }); + it("accounts for speed when computing drift", () => { + const mockEdit = createMockEdit(3); // 3 seconds + const mockVideoElement = createMockVideoElement(); + + // speed 2, trim 0.5: expected source time = 2 × (0.5 + 3) = 7.0 + const player = new VideoPlayer(mockEdit, createVideoClipConfig(0.5, 0, 2)); + + const mockTexture = { + source: new pixi.VideoSource({ resource: mockVideoElement }), + width: 1920, + height: 1080 + }; + // @ts-expect-error - accessing private property for testing + player.texture = mockTexture; + + mockVideoElement.currentTime = 7.0; + expect(player.getCurrentDrift()).toBe(0); + + mockVideoElement.currentTime = 7.4; + expect(player.getCurrentDrift()).toBeCloseTo(0.4, 5); + }); + it("handles zero drift correctly", () => { const mockEdit = createMockEdit(2); // 2 seconds const mockVideoElement = createMockVideoElement(); @@ -328,6 +351,69 @@ describe("VideoPlayer", () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Speed Mapping Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe("getSourceTime", () => { + it("defaults to trim + playbackTime at speed 1", () => { + const player = new VideoPlayer(createMockEdit(3), createVideoClipConfig(0.5, 0)); + expect(player.getSourceTime()).toBe(3.5); + }); + + it("maps sourceTime = speed × (trim + playbackTime), matching render output", () => { + // playbackTime 4, trim 1, speed 0.5 → source time 0.5 × (1 + 4) = 2.5 + const slow = new VideoPlayer(createMockEdit(4), createVideoClipConfig(1, 0, 0.5)); + expect(slow.getSourceTime()).toBe(2.5); + + // playbackTime 2, trim 1, speed 2 → source time 2 × (1 + 2) = 6 + const fast = new AudioPlayer(createMockEdit(2), createAudioClipConfig(1, 0, 2)); + expect(fast.getSourceTime()).toBe(6); + }); + + it("freezes at source time 0 when speed is 0", () => { + const player = new VideoPlayer(createMockEdit(5), createVideoClipConfig(2, 0, 0)); + expect(player.getSourceTime()).toBe(0); + }); + + it("exposes asset speed with default 1", () => { + expect(new VideoPlayer(createMockEdit(0), createVideoClipConfig()).getAssetSpeed()).toBe(1); + expect(new AudioPlayer(createMockEdit(0), createAudioClipConfig(0, 0, 1.5)).getAssetSpeed()).toBe(1.5); + }); +}); + +describe("getMaxLength", () => { + function createVideoPlayerWithDuration(trim: number, speed?: number): VideoPlayer { + const player = new VideoPlayer(createMockEdit(0), createVideoClipConfig(trim, 0, speed)); + const mockTexture = { + source: new pixi.VideoSource({ resource: createMockVideoElement() }), + width: 1920, + height: 1080 + }; + // @ts-expect-error - accessing private property for testing + player.texture = mockTexture; + return player; + } + + it("returns source duration minus trim at speed 1", () => { + // mock video duration is 10s + expect(createVideoPlayerWithDuration(0).getMaxLength()).toBe(10); + expect(createVideoPlayerWithDuration(2).getMaxLength()).toBe(8); + }); + + it("scales max length by speed: duration/speed − trim", () => { + expect(createVideoPlayerWithDuration(0, 2).getMaxLength()).toBe(5); + expect(createVideoPlayerWithDuration(0, 0.5).getMaxLength()).toBe(20); + expect(createVideoPlayerWithDuration(1, 2).getMaxLength()).toBe(4); + }); + + it("returns null when unbounded: media not loaded or speed 0", () => { + const noMedia = new VideoPlayer(createMockEdit(0), createVideoClipConfig()); + expect(noMedia.getMaxLength()).toBeNull(); + expect(createVideoPlayerWithDuration(0, 0).getMaxLength()).toBeNull(); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // AudioPlayer Tests // ───────────────────────────────────────────────────────────────────────────── From 8aee9f455b002398bef6829c1a84078d3cee54ce Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 22 Jul 2026 14:30:50 +1000 Subject: [PATCH 2/2] chore: cover speed 0 freeze-frame and 1x baseline in speed test template --- src/templates/speed.json | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/templates/speed.json b/src/templates/speed.json index c786fa05..e50629ee 100644 --- a/src/templates/speed.json +++ b/src/templates/speed.json @@ -13,6 +13,18 @@ "height": 60 }, "start": 0, + "length": 3, + "position": "top" + }, + { + "asset": { + "type": "text", + "text": "1x", + "font": { "color": "#ffffff", "family": "Montserrat SemiBold", "size": "40" }, + "width": 300, + "height": 60 + }, + "start": 3, "length": 4, "position": "top" }, @@ -24,7 +36,7 @@ "width": 300, "height": 60 }, - "start": 4, + "start": 7, "length": 4, "position": "top" }, @@ -36,7 +48,7 @@ "width": 300, "height": 60 }, - "start": 8, + "start": 11, "length": 4, "position": "top" }, @@ -48,7 +60,7 @@ "width": 300, "height": 60 }, - "start": 12, + "start": 15, "length": 3, "position": "top" } @@ -57,23 +69,28 @@ { "clips": [ { - "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 1 }, + "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 0 }, "start": 0, + "length": 3 + }, + { + "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 1 }, + "start": 3, "length": 4 }, { "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 2 }, - "start": 4, + "start": 7, "length": 4 }, { "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 0.5 }, - "start": 8, + "start": 11, "length": 4 }, { "asset": { "type": "video", "src": "https://shotstack-assets.s3.amazonaws.com/footage/scott-ko.mp4", "volume": 1, "speed": 2, "trim": 2 }, - "start": 12, + "start": 15, "length": 3 } ]