Skip to content
Merged
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
35 changes: 20 additions & 15 deletions src/components/canvas/players/audio-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -67,29 +65,34 @@ 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();
}

if (this.audioResource.volume() !== this.getVolume()) {
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);
}
}

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
39 changes: 22 additions & 17 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,41 +62,42 @@ 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);
}

if (this.texture.source.resource.volume !== this.getVolume()) {
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;
}
}
}
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.VideoSource>): pixi.Texture<pixi.VideoSource> {
Expand Down
21 changes: 17 additions & 4 deletions src/components/timeline/interaction/interaction-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,19 +487,21 @@ 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));
clipElement.style.setProperty("--clip-length", String(newLength));
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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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];
Expand Down
Loading
Loading