mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
feat(editor): persist clip sequences and synchronize playback
This commit is contained in:
@@ -32,12 +32,12 @@ interface AnnotationOverlayProps {
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
}
|
||||
|
||||
function clampPercent(value: number) {
|
||||
function positivePercent(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, value));
|
||||
return Math.max(1, value);
|
||||
}
|
||||
|
||||
/** Render an annotation in preview space with editor drag and resize controls. */
|
||||
@@ -80,18 +80,18 @@ export function AnnotationOverlay({
|
||||
|
||||
return {
|
||||
position: {
|
||||
x: clampPercent(
|
||||
x:
|
||||
((nextSceneX - safeRecordingRect.x) / Math.max(1, safeRecordingRect.width)) *
|
||||
100,
|
||||
),
|
||||
y: clampPercent(
|
||||
100,
|
||||
y:
|
||||
((nextSceneY - safeRecordingRect.y) / Math.max(1, safeRecordingRect.height)) *
|
||||
100,
|
||||
),
|
||||
100,
|
||||
},
|
||||
size: {
|
||||
width: clampPercent((nextSceneWidth / Math.max(1, safeRecordingRect.width)) * 100),
|
||||
height: clampPercent(
|
||||
width: positivePercent(
|
||||
(nextSceneWidth / Math.max(1, safeRecordingRect.width)) * 100,
|
||||
),
|
||||
height: positivePercent(
|
||||
(nextSceneHeight / Math.max(1, safeRecordingRect.height)) * 100,
|
||||
),
|
||||
},
|
||||
@@ -208,6 +208,7 @@ export function AnnotationOverlay({
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
data-annotation-id={annotation.id}
|
||||
position={{ x, y }}
|
||||
size={{ width, height }}
|
||||
scale={interactionScale}
|
||||
|
||||
@@ -87,10 +87,7 @@ import {
|
||||
type ZoomRegion,
|
||||
type ZoomTransitionEasing,
|
||||
} from "./types";
|
||||
import {
|
||||
isAnnotationActiveAtTime,
|
||||
shouldClearSelectedAnnotation,
|
||||
} from "./videoPlayback/annotationVisibility";
|
||||
import { isAnnotationActiveAtTime } from "./videoPlayback/annotationVisibility";
|
||||
import { createClipPlayback, findPreviewClipAtTimelineTime } from "./videoPlayback/clipPlayback";
|
||||
import { DEFAULT_FOCUS } from "./videoPlayback/constants";
|
||||
import {
|
||||
@@ -124,6 +121,7 @@ import {
|
||||
} from "./videoPlayback/sceneMotion";
|
||||
import {
|
||||
getWebcamMediaTargetTimeSeconds,
|
||||
isWebcamVisibleAtSourceTime,
|
||||
isWebcamMediaSynchronized,
|
||||
shouldSeekWebcamMedia,
|
||||
} from "./videoPlayback/webcamSync";
|
||||
@@ -833,7 +831,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const bubble = webcamBubbleRef.current;
|
||||
const bubbleInner = webcamBubbleInnerRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
if (!bubble || !bubbleInner || !overlay || !webcamEnabled || !webcamVideoPath) {
|
||||
if (
|
||||
!bubble ||
|
||||
!bubbleInner ||
|
||||
!overlay ||
|
||||
!webcamEnabled ||
|
||||
!webcamVideoPath ||
|
||||
!isWebcamVisibleAtSourceTime(webcam, currentTimeRef.current / 1000)
|
||||
) {
|
||||
if (bubble) {
|
||||
bubble.style.display = "none";
|
||||
}
|
||||
@@ -894,6 +899,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
[
|
||||
webcamCorner,
|
||||
webcamRoundness,
|
||||
webcam,
|
||||
webcamEnabled,
|
||||
webcamMargin,
|
||||
webcamPositionPreset,
|
||||
@@ -1224,22 +1230,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
selectedZoomIdRef.current = selectedZoomId;
|
||||
}, [selectedZoomId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAnnotationId || !onSelectAnnotation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldClearSelectedAnnotation(
|
||||
annotationRegions ?? [],
|
||||
selectedAnnotationId,
|
||||
Math.round(timelineTime * 1000),
|
||||
)
|
||||
) {
|
||||
onSelectAnnotation(null);
|
||||
}
|
||||
}, [annotationRegions, timelineTime, onSelectAnnotation, selectedAnnotationId]);
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
const bgVideo = bgVideoRef.current;
|
||||
@@ -1887,9 +1877,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
layoutVideoContentRef.current?.();
|
||||
video.pause();
|
||||
|
||||
let preserveCameraAcrossCut = false;
|
||||
const transport = createClipPlayback({
|
||||
video,
|
||||
getClips: () => clipRegionsRef.current,
|
||||
onSourceSeek: (reason) => {
|
||||
preserveCameraAcrossCut = reason === "cut";
|
||||
},
|
||||
onTime: (time, source) => {
|
||||
timelineTimeRef.current = time;
|
||||
if (source !== null) currentTimeRef.current = source * 1000;
|
||||
@@ -1908,11 +1902,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
transport.seek(timelineTimeRef.current);
|
||||
const handleSeeked = () => {
|
||||
isSeekingRef.current = false;
|
||||
shouldSnapPausedFrameRef.current = true;
|
||||
// A source seek at a contiguous cut must not reset the camera springs.
|
||||
if (!preserveCameraAcrossCut || !isPlayingRef.current)
|
||||
shouldSnapPausedFrameRef.current = true;
|
||||
preserveCameraAcrossCut = false;
|
||||
};
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
shouldSnapPausedFrameRef.current = true;
|
||||
if (!preserveCameraAcrossCut) shouldSnapPausedFrameRef.current = true;
|
||||
};
|
||||
video.addEventListener("seeked", handleSeeked);
|
||||
video.addEventListener("seeking", handleSeeking);
|
||||
@@ -2386,6 +2383,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
data-preview-overlay
|
||||
className="absolute inset-0 select-none"
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
@@ -2404,9 +2402,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{webcam && webcamVideoPath ? (
|
||||
<div
|
||||
ref={webcamBubbleRef}
|
||||
data-webcam-overlay
|
||||
className="absolute"
|
||||
style={{
|
||||
display: webcam.enabled && !isGap ? "block" : "none",
|
||||
display:
|
||||
webcam.enabled &&
|
||||
!isGap &&
|
||||
isWebcamVisibleAtSourceTime(webcam, currentTime)
|
||||
? "block"
|
||||
: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
@@ -2464,6 +2468,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
>
|
||||
<div
|
||||
ref={captionBoxRef}
|
||||
className="focus-visible:outline-2 focus-visible:outline-accent"
|
||||
role={
|
||||
onEditAutoCaption && !isCaptionEditing
|
||||
? "button"
|
||||
@@ -2477,7 +2482,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
? "Edit current caption"
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!isCaptionEditing) {
|
||||
beginCaptionEdit();
|
||||
@@ -2681,16 +2687,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
className="absolute"
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
left: annotationRecordingRect.x || 0,
|
||||
top: annotationRecordingRect.y || 0,
|
||||
width:
|
||||
annotationRecordingRect.width ||
|
||||
overlayRef.current?.clientWidth ||
|
||||
800,
|
||||
height:
|
||||
annotationRecordingRect.height ||
|
||||
overlayRef.current?.clientHeight ||
|
||||
600,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: overlayRef.current?.clientWidth || 800,
|
||||
height: overlayRef.current?.clientHeight || 600,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
@@ -2737,8 +2737,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
600
|
||||
}
|
||||
recordingRect={{
|
||||
x: 0,
|
||||
y: 0,
|
||||
x: annotationRecordingRect.x,
|
||||
y: annotationRecordingRect.y,
|
||||
width:
|
||||
annotationRecordingRect.width ||
|
||||
overlayRef.current?.clientWidth ||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeClipGaps,
|
||||
reorderClipSequence,
|
||||
mapClipSequenceTime,
|
||||
packClipSequence,
|
||||
rippleRegionAnchors,
|
||||
rippleRegions,
|
||||
} from "./clipSequence";
|
||||
import { changeClipSpan } from "./clipSpanChange";
|
||||
|
||||
const clips = [
|
||||
{ id: "a", startMs: 0, endMs: 2800, speed: 1 },
|
||||
{ id: "b", startMs: 2800, endMs: 6000, speed: 1 },
|
||||
];
|
||||
describe("contiguous clip sequence", () => {
|
||||
it("a trim keeps both sides of the cut at 2.8s while changing the source in-point", () => {
|
||||
const next = closeClipGaps([clips[0], changeClipSpan(clips[1], 3500, 6000, 6000)]);
|
||||
expect(next[0].endMs).toBe(2800);
|
||||
expect(next[1]).toMatchObject({ startMs: 2800, endMs: 5300, sourceStartMs: 3500 });
|
||||
expect(rippleRegions([{ startMs: 4000, endMs: 5000 }], clips, next)).toEqual([
|
||||
{ startMs: 3300, endMs: 4300 },
|
||||
]);
|
||||
});
|
||||
it("deleting footage ripples the remaining footage and connected effects", () => {
|
||||
const next = closeClipGaps([clips[1]]);
|
||||
expect(next[0]).toMatchObject({ startMs: 0, endMs: 3200, sourceStartMs: 2800 });
|
||||
expect(
|
||||
rippleRegions(
|
||||
[
|
||||
{ startMs: 500, endMs: 2000 },
|
||||
{ startMs: 3000, endMs: 4000 },
|
||||
],
|
||||
clips,
|
||||
next,
|
||||
),
|
||||
).toEqual([{ startMs: 200, endMs: 1200 }]);
|
||||
});
|
||||
it("slowing a clip pushes the next clip without overwriting its source", () => {
|
||||
const next = closeClipGaps([{ ...clips[0], speed: 0.5, endMs: 5600 }, clips[1]]);
|
||||
expect(next[1]).toMatchObject({ startMs: 5600, endMs: 8800, sourceStartMs: 2800 });
|
||||
});
|
||||
it("reordering preserves source in-points and closes every gap", () => {
|
||||
const next = closeClipGaps([
|
||||
clips[0],
|
||||
{ ...clips[1], sourceStartMs: 2800, startMs: -3200, endMs: 0 },
|
||||
]);
|
||||
expect(next.map((c) => [c.id, c.startMs, c.endMs, c.sourceStartMs])).toEqual([
|
||||
["b", 0, 3200, 2800],
|
||||
["a", 3200, 6000, 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connected sequence content", () => {
|
||||
it.each([2, 4, 8, 16])("retains imported audio at fractional %sx anchors", (speed) => {
|
||||
const before = [{ id: "a", startMs: 0, endMs: 6000, speed: 1 }];
|
||||
const after = [{ ...before[0], speed, endMs: 6000 / speed }];
|
||||
const audio = [{ id: "music", startMs: 1001, endMs: 5501, volume: 0.7 }];
|
||||
expect(rippleRegionAnchors(audio, before, after)).toEqual([
|
||||
{
|
||||
...audio[0],
|
||||
startMs: Math.round(1001 / speed),
|
||||
endMs: Math.round(1001 / speed) + 4500,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("preserves independently timed audio duration across a legacy gap", () => {
|
||||
const before = [clips[0], { ...clips[1], startMs: 3500, endMs: 6700 }];
|
||||
const after = closeClipGaps(before);
|
||||
expect(rippleRegionAnchors([{ startMs: 2000, endMs: 7000 }], before, after)).toEqual([
|
||||
{ startMs: 2000, endMs: 7000 },
|
||||
]);
|
||||
expect(mapClipSequenceTime(7200, before, after)).toBe(6500);
|
||||
});
|
||||
it("retains music anchored to deleted footage at the surviving cut", () => {
|
||||
const after = closeClipGaps([clips[1]]);
|
||||
expect(rippleRegionAnchors([{ startMs: 1000, endMs: 5000 }], clips, after)).toEqual([
|
||||
{ startMs: 0, endMs: 4000 },
|
||||
]);
|
||||
});
|
||||
it("preserves an effect spanning clips whose order is reversed", () => {
|
||||
const after = closeClipGaps([
|
||||
{ ...clips[1], sourceStartMs: 2800, startMs: -3200, endMs: 0 },
|
||||
clips[0],
|
||||
]);
|
||||
expect(
|
||||
rippleRegions([{ id: "annotation", startMs: 2000, endMs: 4000 }], clips, after),
|
||||
).toEqual([{ id: "annotation", startMs: 0, endMs: 6000 }]);
|
||||
});
|
||||
it("removes effects supported solely by deleted footage", () => {
|
||||
expect(
|
||||
rippleRegions([{ startMs: 0, endMs: 2000 }], clips, closeClipGaps([clips[1]])),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sequence insertion", () => {
|
||||
const threeClips = [
|
||||
{ id: "a", startMs: 0, endMs: 1000, speed: 1 },
|
||||
{ id: "b", startMs: 1000, endMs: 3000, speed: 1 },
|
||||
{ id: "c", startMs: 3000, endMs: 6000, speed: 1 },
|
||||
];
|
||||
it("moves the first clip after the middle one without skipping to the end", () => {
|
||||
expect(reorderClipSequence(threeClips, "a", 1)).toEqual([
|
||||
{ ...threeClips[1], startMs: 0, endMs: 2000, sourceStartMs: 1000 },
|
||||
{ ...threeClips[0], startMs: 2000, endMs: 3000, sourceStartMs: 0 },
|
||||
threeClips[2],
|
||||
]);
|
||||
});
|
||||
it("moves a middle clip to the start while preserving source footage", () => {
|
||||
const reordered = reorderClipSequence(threeClips, "b", 0);
|
||||
expect(reordered.map((clip) => clip.id)).toEqual(["b", "a", "c"]);
|
||||
expect(reordered[0]).toMatchObject({ startMs: 0, endMs: 2000, sourceStartMs: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals footage on a clip's left edge without changing sequence order", () => {
|
||||
const before = [
|
||||
{ id: "a", startMs: 0, endMs: 2000, sourceStartMs: 0, speed: 1 },
|
||||
{ id: "b", startMs: 2000, endMs: 3000, sourceStartMs: 4000, speed: 1 },
|
||||
];
|
||||
const edited = [before[0], changeClipSpan(before[1], -1000, 3000, 6000)];
|
||||
const after = packClipSequence(edited);
|
||||
expect(after).toEqual([
|
||||
before[0],
|
||||
{ ...before[1], startMs: 2000, endMs: 6000, sourceStartMs: 1000 },
|
||||
]);
|
||||
expect(rippleRegions([{ startMs: 2200, endMs: 2600 }], before, after)).toEqual([
|
||||
{ startMs: 5200, endMs: 5600 },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { type ClipRegion, getClipSourceStartMs, sortClipRegions } from "./types";
|
||||
|
||||
/** Primary footage is a sequence. Source in-points survive every ripple edit. */
|
||||
export function closeClipGaps(clips: ClipRegion[]): ClipRegion[] {
|
||||
return packClipSequence(sortClipRegions(clips));
|
||||
}
|
||||
|
||||
/** Repack authored sequence order; trim positions must never determine clip order. */
|
||||
export function packClipSequence(clips: ClipRegion[]): ClipRegion[] {
|
||||
let cursor = 0;
|
||||
return clips.map((clip) => {
|
||||
const duration = clip.endMs - clip.startMs;
|
||||
const next =
|
||||
clip.startMs === cursor
|
||||
? clip
|
||||
: {
|
||||
...clip,
|
||||
sourceStartMs: getClipSourceStartMs(clip),
|
||||
startMs: cursor,
|
||||
endMs: cursor + duration,
|
||||
};
|
||||
cursor += duration;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert by sequence index, independent of temporarily overlapping drag positions. */
|
||||
export function reorderClipSequence(
|
||||
clips: ClipRegion[],
|
||||
id: string,
|
||||
sequenceIndex: number,
|
||||
): ClipRegion[] {
|
||||
const ordered = sortClipRegions(clips);
|
||||
const activeIndex = ordered.findIndex((clip) => clip.id === id);
|
||||
if (activeIndex < 0 || !Number.isFinite(sequenceIndex)) return closeClipGaps(clips);
|
||||
const [active] = ordered.splice(activeIndex, 1);
|
||||
ordered.splice(Math.max(0, Math.min(ordered.length, Math.round(sequenceIndex))), 0, active);
|
||||
return packClipSequence(ordered);
|
||||
}
|
||||
|
||||
function safeSpeed(clip: ClipRegion): number {
|
||||
return Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
|
||||
}
|
||||
|
||||
function mapWithinClip(time: number, before: ClipRegion, after: ClipRegion): number {
|
||||
const source = getClipSourceStartMs(before) + (time - before.startMs) * safeSpeed(before);
|
||||
return Math.round(
|
||||
Math.max(
|
||||
after.startMs,
|
||||
Math.min(
|
||||
after.endMs,
|
||||
after.startMs + (source - getClipSourceStartMs(after)) / safeSpeed(after),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Map an anchor directly: rounding a tiny synthetic span can erase valid anchors. */
|
||||
export function mapClipSequenceTime(
|
||||
time: number,
|
||||
before: ClipRegion[],
|
||||
after: ClipRegion[],
|
||||
): number {
|
||||
if (before.length === 0) return time;
|
||||
const sorted = sortClipRegions(before);
|
||||
const owner = sorted.find((clip) => time >= clip.startMs && time < clip.endMs);
|
||||
const next = owner && after.find((clip) => clip.id === owner.id);
|
||||
if (owner && next) return mapWithinClip(time, owner, next);
|
||||
|
||||
const beforeEnd = Math.max(...before.map((clip) => clip.endMs));
|
||||
const afterEnd = Math.max(0, ...after.map((clip) => clip.endMs));
|
||||
// Independently timed content beyond footage keeps its distance from the end.
|
||||
if (time >= beforeEnd) return Math.round(afterEnd + time - beforeEnd);
|
||||
const following = sorted.find(
|
||||
(clip) => clip.startMs >= time && after.some((nextClip) => nextClip.id === clip.id),
|
||||
);
|
||||
return following ? after.find((clip) => clip.id === following.id)!.startMs : afterEnd;
|
||||
}
|
||||
|
||||
/** Imported audio keeps its duration while its timeline anchor follows the edit. */
|
||||
export function rippleRegionAnchors<T extends { startMs: number; endMs: number }>(
|
||||
regions: T[],
|
||||
before: ClipRegion[],
|
||||
after: ClipRegion[],
|
||||
): T[] {
|
||||
return regions.map((region) => {
|
||||
const startMs = mapClipSequenceTime(region.startMs, before, after);
|
||||
return { ...region, startMs, endMs: startMs + region.endMs - region.startMs };
|
||||
});
|
||||
}
|
||||
|
||||
/** Map the retained footage covered by each connected effect through a sequence edit. */
|
||||
export function rippleRegions<T extends { startMs: number; endMs: number }>(
|
||||
regions: T[],
|
||||
before: ClipRegion[],
|
||||
after: ClipRegion[],
|
||||
): T[] {
|
||||
return regions.flatMap((region) => {
|
||||
const retainedSpans = before.flatMap((clip) => {
|
||||
const next = after.find((candidate) => candidate.id === clip.id);
|
||||
const start = Math.max(region.startMs, clip.startMs);
|
||||
const end = Math.min(region.endMs, clip.endMs);
|
||||
if (!next || end <= start) return [];
|
||||
const startMs = mapWithinClip(start, clip, next);
|
||||
const endMs = mapWithinClip(end, clip, next);
|
||||
return endMs > startMs ? [{ startMs, endMs }] : [];
|
||||
});
|
||||
if (retainedSpans.length === 0) return [];
|
||||
// Reordering can reverse the original endpoints. Include every retained
|
||||
// segment instead of deleting the effect because its endpoints inverted.
|
||||
const startMs = Math.min(...retainedSpans.map((span) => span.startMs));
|
||||
const endMs = Math.max(...retainedSpans.map((span) => span.endMs));
|
||||
return [{ ...region, startMs, endMs }];
|
||||
});
|
||||
}
|
||||
@@ -26,3 +26,17 @@ describe("clip span changes", () => {
|
||||
expect(changeClipSpan(moved, 1000, 6000, 12000)).toEqual(moved);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reveal a neighboring recording when extending an imported clip", () => {
|
||||
const clip = {
|
||||
id: "imported",
|
||||
startMs: 0,
|
||||
endMs: 2000,
|
||||
sourceStartMs: 10000,
|
||||
sourceMinMs: 10000,
|
||||
sourceMaxMs: 12000,
|
||||
speed: 1,
|
||||
};
|
||||
expect(changeClipSpan(clip, -500, 2000, 20000)).toEqual(clip);
|
||||
expect(changeClipSpan(clip, 0, 2500, 20000)).toEqual(clip);
|
||||
});
|
||||
|
||||
@@ -11,11 +11,18 @@ export function changeClipSpan(
|
||||
if (isMove) return { ...clip, startMs, endMs, sourceStartMs: sourceStart };
|
||||
|
||||
// Resizing reveals/hides footage; it cannot manufacture source before 0 or after EOF.
|
||||
const start = Math.max(startMs, Math.ceil(clip.startMs - sourceStart / clip.speed));
|
||||
const start = Math.max(
|
||||
startMs,
|
||||
Math.ceil(clip.startMs - (sourceStart - (clip.sourceMinMs ?? 0)) / clip.speed),
|
||||
);
|
||||
const sourceStartMs = Math.round(sourceStart + (start - clip.startMs) * clip.speed);
|
||||
const end = Math.min(
|
||||
endMs,
|
||||
Math.floor(start + (sourceDurationMs - sourceStartMs) / clip.speed),
|
||||
Math.floor(
|
||||
start +
|
||||
(Math.min(sourceDurationMs, clip.sourceMaxMs ?? sourceDurationMs) - sourceStartMs) /
|
||||
clip.speed,
|
||||
),
|
||||
);
|
||||
return { ...clip, startMs: start, endMs: end, sourceStartMs };
|
||||
}
|
||||
|
||||
@@ -76,7 +76,8 @@ export function useVideoEditorPresets({
|
||||
borderRadiusUnit: "percent",
|
||||
padding: { ...appearance.padding },
|
||||
cropRegion: { ...appearance.cropRegion },
|
||||
webcam: (({ sourcePath: _sourcePath, ...settings }) => settings)(appearance.webcam),
|
||||
webcam: (({ sourcePath: _sourcePath, visibleRanges: _visibleRanges, ...settings }) =>
|
||||
settings)(appearance.webcam),
|
||||
aspectRatio,
|
||||
exportEncodingMode: exportSettings.exportEncodingMode,
|
||||
exportBackendPreference: exportSettings.exportBackendPreference,
|
||||
@@ -143,6 +144,7 @@ export function useVideoEditorPresets({
|
||||
appearance.setWebcam((current) => ({
|
||||
...snapshot.webcam,
|
||||
sourcePath: current.sourcePath,
|
||||
visibleRanges: current.visibleRanges,
|
||||
}));
|
||||
setAspectRatio(snapshot.aspectRatio);
|
||||
exportSettings.setExportEncodingMode(snapshot.exportEncodingMode);
|
||||
|
||||
@@ -98,6 +98,7 @@ export function useInitialEditorSource({
|
||||
appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null;
|
||||
appearance.setWebcam((previous) => ({
|
||||
...previous,
|
||||
visibleRanges: undefined,
|
||||
enabled: Boolean(webcamPath),
|
||||
sourcePath: webcamPath,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
@@ -133,6 +134,7 @@ export function useInitialEditorSource({
|
||||
pendingFreshRecordingAutoZoomPathRef.current = null;
|
||||
appearance.setWebcam((previous) => ({
|
||||
...previous,
|
||||
visibleRanges: undefined,
|
||||
enabled: Boolean(webcamPath),
|
||||
sourcePath: webcamPath,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
@@ -168,6 +170,7 @@ export function useInitialEditorSource({
|
||||
applySessionPresentation(sessionResult.session);
|
||||
appearance.setWebcam((previous) => ({
|
||||
...previous,
|
||||
visibleRanges: undefined,
|
||||
enabled: Boolean(sessionResult.session?.webcamPath),
|
||||
sourcePath: sessionResult.session?.webcamPath ?? null,
|
||||
timeOffsetMs:
|
||||
@@ -191,6 +194,7 @@ export function useInitialEditorSource({
|
||||
applySessionPresentation(null);
|
||||
appearance.setWebcam((previous) => ({
|
||||
...previous,
|
||||
visibleRanges: undefined,
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
@@ -218,6 +222,7 @@ export function useInitialEditorSource({
|
||||
if (!session || sessionSourcePath !== videoSourcePath) return;
|
||||
appearance.setWebcam((previous) => ({
|
||||
...previous,
|
||||
visibleRanges: undefined,
|
||||
enabled: Boolean(webcamPath),
|
||||
sourcePath: webcamPath,
|
||||
timeOffsetMs: webcamPath
|
||||
|
||||
@@ -364,6 +364,7 @@ export function useProjectLifecycle(input: Input) {
|
||||
...previous,
|
||||
enabled: true,
|
||||
sourcePath: result.path ?? null,
|
||||
visibleRanges: undefined,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS);
|
||||
@@ -374,6 +375,7 @@ export function useProjectLifecycle(input: Input) {
|
||||
...previous,
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
visibleRanges: undefined,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
await syncRecordingSessionWebcam(null);
|
||||
|
||||
@@ -132,6 +132,7 @@ export function useProjectOpenActions({
|
||||
: null;
|
||||
appearance.setWebcam((previous) => ({
|
||||
...previous,
|
||||
visibleRanges: undefined,
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
normalizeProjectEditor,
|
||||
resolveVideoUrl,
|
||||
} from "./projectPersistence";
|
||||
import {
|
||||
createEditorHistoryStack,
|
||||
recordEditorHistorySnapshot,
|
||||
undoEditorHistoryStack,
|
||||
type EditorHistorySnapshot,
|
||||
} from "./editorHistory";
|
||||
import { ADVANCED_VERTICAL_PADDING_MAX } from "./types";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
@@ -43,7 +49,11 @@ describe("normalizeProjectEditor", () => {
|
||||
};
|
||||
const normalized = normalizeProjectEditor(savedEditor);
|
||||
expect(normalized.zoomMotionBlur).toBe(0.6);
|
||||
for (const field of ["zoomTemporalMotionBlur", "zoomMotionBlurSampleCount", "zoomMotionBlurShutterFraction"]) {
|
||||
for (const field of [
|
||||
"zoomTemporalMotionBlur",
|
||||
"zoomMotionBlurSampleCount",
|
||||
"zoomMotionBlurShutterFraction",
|
||||
]) {
|
||||
expect(normalized).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
@@ -127,3 +137,103 @@ describe("normalizeProjectEditor", () => {
|
||||
expect(editor.webcam.roundness).toBeCloseTo(4.34, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loaded clip sequence migration", () => {
|
||||
const saved = {
|
||||
clipRegions: [
|
||||
{ id: "a", startMs: 0, endMs: 2000, speed: 1 },
|
||||
{ id: "b", startMs: 3000, endMs: 6000, sourceStartMs: 5000, speed: 1 },
|
||||
],
|
||||
zoomRegions: [{ id: "zoom", startMs: 3500, endMs: 4500, depth: 2 }] as never,
|
||||
annotationRegions: [
|
||||
{ id: "annotation", startMs: 3500, endMs: 4500, content: "Keep", type: "text" },
|
||||
] as never,
|
||||
audioRegions: [
|
||||
{ id: "music", startMs: 3500, endMs: 7500, audioPath: "/music.wav", volume: 0.7 },
|
||||
],
|
||||
autoCaptions: [{ id: "cue", startMs: 5500, endMs: 6000, text: "Source timed" }],
|
||||
sourceAudioTrackSettingsByClip: { b: { system: { volume: 0.4, muted: false } } } as never,
|
||||
};
|
||||
it("migrates clips and connected tracks before they become editor state", () => {
|
||||
const editor = normalizeProjectEditor(saved);
|
||||
expect(editor.clipRegions[1]).toMatchObject({
|
||||
id: "b",
|
||||
startMs: 2000,
|
||||
endMs: 5000,
|
||||
sourceStartMs: 5000,
|
||||
});
|
||||
expect(editor.zoomRegions[0]).toMatchObject({ startMs: 2500, endMs: 3500 });
|
||||
expect(editor.annotationRegions[0]).toMatchObject({ startMs: 2500, endMs: 3500 });
|
||||
expect(editor.audioRegions[0]).toMatchObject({ startMs: 2500, endMs: 6500 });
|
||||
expect(editor.autoCaptions).toEqual(saved.autoCaptions);
|
||||
expect(editor.sourceAudioTrackSettingsByClip).toEqual(saved.sourceAudioTrackSettingsByClip);
|
||||
expect(normalizeProjectEditor(editor)).toMatchObject({
|
||||
clipRegions: editor.clipRegions,
|
||||
zoomRegions: editor.zoomRegions,
|
||||
annotationRegions: editor.annotationRegions,
|
||||
audioRegions: editor.audioRegions,
|
||||
autoCaptions: editor.autoCaptions,
|
||||
});
|
||||
});
|
||||
it("starts history at migrated footage, with only real edits to undo", () => {
|
||||
const editor = normalizeProjectEditor(saved);
|
||||
const snapshot = (
|
||||
value: ReturnType<typeof normalizeProjectEditor>,
|
||||
): EditorHistorySnapshot => ({
|
||||
clipRegions: value.clipRegions,
|
||||
zoomRegions: value.zoomRegions,
|
||||
annotationRegions: value.annotationRegions,
|
||||
audioRegions: value.audioRegions,
|
||||
speedRegions: value.speedRegions,
|
||||
autoCaptions: value.autoCaptions,
|
||||
selectedZoomId: null,
|
||||
selectedClipId: null,
|
||||
selectedAnnotationId: null,
|
||||
selectedAudioId: null,
|
||||
});
|
||||
const initial = snapshot(editor);
|
||||
const history = createEditorHistoryStack();
|
||||
expect(recordEditorHistorySnapshot(history, initial)).toBe("initialized");
|
||||
expect(recordEditorHistorySnapshot(history, snapshot(normalizeProjectEditor(editor)))).toBe(
|
||||
"unchanged",
|
||||
);
|
||||
expect(history.past).toEqual([]);
|
||||
const edited = { ...initial, clipRegions: [editor.clipRegions[0]] };
|
||||
expect(recordEditorHistorySnapshot(history, edited)).toBe("recorded");
|
||||
expect(undoEditorHistoryStack(history, edited)?.clipRegions).toEqual(editor.clipRegions);
|
||||
expect(history.past).toEqual([]);
|
||||
});
|
||||
it("preserves an explicit empty timeline and independent audio", () => {
|
||||
const editor = normalizeProjectEditor({ ...saved, clipRegions: [] });
|
||||
expect(editor.clipRegions).toEqual([]);
|
||||
expect(editor.audioRegions[0]).toMatchObject({ startMs: 3500, endMs: 7500 });
|
||||
});
|
||||
});
|
||||
|
||||
it("reopens persisted local media URLs using the current server port", async () => {
|
||||
const getLocalMediaUrl = vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
url: "http://127.0.0.1:9999/video?path=%2Ftmp%2Fclip.mp4",
|
||||
});
|
||||
vi.stubGlobal("window", { electronAPI: { getLocalMediaUrl } });
|
||||
await expect(
|
||||
resolveVideoUrl("http://127.0.0.1:1234/video?path=%2Ftmp%2Fclip.mp4"),
|
||||
).resolves.toContain(":9999/");
|
||||
expect(getLocalMediaUrl).toHaveBeenCalledWith("/tmp/clip.mp4");
|
||||
});
|
||||
|
||||
|
||||
describe("annotations across the canvas and imported webcam ranges", () => {
|
||||
it("preserves annotations outside the recording rectangle and webcam visibility when a project is reopened", () => {
|
||||
const editor = normalizeProjectEditor({
|
||||
annotationRegions: [{ id: "outside", startMs: 0, endMs: 1000, type: "text", position: { x: -12, y: 110 }, size: { width: 140, height: 20 } }] as never,
|
||||
webcam: { sourcePath: "/sequence-webcam.mp4", visibleRanges: [{ startMs: 1200, endMs: 2000 }] } as never,
|
||||
});
|
||||
expect(editor.annotationRegions[0].position).toEqual({ x: -12, y: 110 });
|
||||
expect(editor.annotationRegions[0].size.width).toBe(140);
|
||||
expect(normalizeProjectEditor(editor).annotationRegions).toEqual(editor.annotationRegions);
|
||||
expect(normalizeProjectEditor(editor).webcam.visibleRanges).toEqual([{ startMs: 1200, endMs: 2000 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getLocalMediaServerPath } from "@/lib/localMediaUrl";
|
||||
import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes";
|
||||
import type {
|
||||
ExportBackendPreference,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
import { isValidMp4FrameRate } from "@/lib/exporter/types";
|
||||
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { closeClipGaps, rippleRegionAnchors, rippleRegions } from "./clipSequence";
|
||||
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
|
||||
import {
|
||||
ADVANCED_VERTICAL_PADDING_MAX,
|
||||
@@ -268,6 +270,8 @@ export function toFileUrl(filePath: string): string {
|
||||
|
||||
export function fromFileUrl(fileUrl: string): string {
|
||||
const value = fileUrl.trim();
|
||||
const serverPath = getLocalMediaServerPath(value);
|
||||
if (serverPath) return serverPath;
|
||||
if (!isFileUrl(value)) {
|
||||
return fileUrl;
|
||||
}
|
||||
@@ -316,7 +320,7 @@ export function deriveNextId(prefix: string, ids: string[]): number {
|
||||
* media server is unavailable.
|
||||
*/
|
||||
export async function resolveVideoUrl(sourcePath: string): Promise<string> {
|
||||
const trimmedSourcePath = sourcePath.trim();
|
||||
const trimmedSourcePath = fromFileUrl(sourcePath.trim());
|
||||
if (/^(?:https?:|blob:|data:)/i.test(trimmedSourcePath)) {
|
||||
return trimmedSourcePath;
|
||||
}
|
||||
@@ -491,6 +495,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
...(isFiniteNumber(region.sourceStartMs)
|
||||
? { sourceStartMs: Math.max(0, Math.round(region.sourceStartMs)) }
|
||||
: {}),
|
||||
...(isFiniteNumber(region.sourceMinMs)
|
||||
? { sourceMinMs: Math.max(0, Math.round(region.sourceMinMs)) }
|
||||
: {}),
|
||||
...(isFiniteNumber(region.sourceMaxMs)
|
||||
? { sourceMaxMs: Math.max(0, Math.round(region.sourceMaxMs)) }
|
||||
: {}),
|
||||
speed: isFiniteNumber(region.speed) ? region.speed : 1,
|
||||
muted: typeof region.muted === "boolean" ? region.muted : false,
|
||||
showSourceAudio:
|
||||
@@ -501,6 +511,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
})
|
||||
: [];
|
||||
|
||||
// Migrate before applying editor state, so loaded history and the saved baseline
|
||||
// both begin with the same canonical sequence rather than recording a repair edit.
|
||||
const sequenceClips = closeClipGaps(normalizedClipRegions);
|
||||
const sequenceChanged = sequenceClips.some(
|
||||
(clip, index) => clip !== normalizedClipRegions[index],
|
||||
);
|
||||
|
||||
const normalizedAutoFullTrackClipId =
|
||||
typeof editor.autoFullTrackClipId === "string" ? editor.autoFullTrackClipId : null;
|
||||
const normalizedAutoFullTrackClipEndMs = isFiniteNumber(editor.autoFullTrackClipEndMs)
|
||||
@@ -575,20 +592,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
? region.imageContent
|
||||
: undefined,
|
||||
position: {
|
||||
x: clamp(
|
||||
isFiniteNumber(region.position?.x)
|
||||
? region.position.x
|
||||
: DEFAULT_ANNOTATION_POSITION.x,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
y: clamp(
|
||||
isFiniteNumber(region.position?.y)
|
||||
? region.position.y
|
||||
: DEFAULT_ANNOTATION_POSITION.y,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
x: isFiniteNumber(region.position?.x)
|
||||
? region.position.x
|
||||
: DEFAULT_ANNOTATION_POSITION.x,
|
||||
y: isFiniteNumber(region.position?.y)
|
||||
? region.position.y
|
||||
: DEFAULT_ANNOTATION_POSITION.y,
|
||||
},
|
||||
size: {
|
||||
width: clamp(
|
||||
@@ -596,14 +605,14 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
? region.size.width
|
||||
: DEFAULT_ANNOTATION_SIZE.width,
|
||||
1,
|
||||
200,
|
||||
10000,
|
||||
),
|
||||
height: clamp(
|
||||
isFiniteNumber(region.size?.height)
|
||||
? region.size.height
|
||||
: DEFAULT_ANNOTATION_SIZE.height,
|
||||
1,
|
||||
200,
|
||||
10000,
|
||||
),
|
||||
},
|
||||
style: {
|
||||
@@ -948,17 +957,34 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
},
|
||||
zoomRegions: normalizedZoomRegions,
|
||||
zoomRegions: sequenceChanged
|
||||
? rippleRegions(normalizedZoomRegions, normalizedClipRegions, sequenceClips)
|
||||
: normalizedZoomRegions,
|
||||
trimRegions: normalizedTrimRegions,
|
||||
clipRegions: normalizedClipRegions,
|
||||
clipRegions: sequenceClips,
|
||||
autoFullTrackClipId: normalizedAutoFullTrackClipId,
|
||||
autoFullTrackClipEndMs: normalizedAutoFullTrackClipEndMs,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
audioRegions: normalizedAudioRegions,
|
||||
annotationRegions: sequenceChanged
|
||||
? rippleRegions(normalizedAnnotationRegions, normalizedClipRegions, sequenceClips)
|
||||
: normalizedAnnotationRegions,
|
||||
audioRegions: sequenceChanged
|
||||
? rippleRegionAnchors(normalizedAudioRegions, normalizedClipRegions, sequenceClips)
|
||||
: normalizedAudioRegions,
|
||||
autoCaptions: normalizedAutoCaptions,
|
||||
autoCaptionSettings: normalizedAutoCaptionSettings,
|
||||
webcam: {
|
||||
visibleRanges: Array.isArray(webcam.visibleRanges)
|
||||
? webcam.visibleRanges
|
||||
.filter(
|
||||
(range) =>
|
||||
isFiniteNumber(range?.startMs) &&
|
||||
isFiniteNumber(range?.endMs) &&
|
||||
range.startMs >= 0 &&
|
||||
range.endMs > range.startMs,
|
||||
)
|
||||
.map(({ startMs, endMs }) => ({ startMs, endMs }))
|
||||
: undefined,
|
||||
enabled:
|
||||
typeof webcam.enabled === "boolean"
|
||||
? webcam.enabled
|
||||
|
||||
@@ -134,6 +134,8 @@ export type WebcamPositionPreset =
|
||||
| "custom";
|
||||
|
||||
export interface WebcamOverlaySettings {
|
||||
/** Source-time intervals containing webcam footage in an imported sequence. */
|
||||
visibleRanges?: { startMs: number; endMs: number }[];
|
||||
enabled: boolean;
|
||||
sourcePath: string | null;
|
||||
timeOffsetMs: number;
|
||||
@@ -236,6 +238,9 @@ export interface ClipRegion {
|
||||
* left-trimming a sped-up clip moves the source in without moving the clip.
|
||||
*/
|
||||
sourceStartMs?: number;
|
||||
/** Bounds of this recording within the shared media source. */
|
||||
sourceMinMs?: number;
|
||||
sourceMaxMs?: number;
|
||||
speed: number;
|
||||
muted?: boolean;
|
||||
showSourceAudio?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAnnotationActiveAtTime, shouldClearSelectedAnnotation } from "./annotationVisibility";
|
||||
import { isAnnotationActiveAtTime } from "./annotationVisibility";
|
||||
|
||||
describe("isAnnotationActiveAtTime", () => {
|
||||
it("includes both annotation range boundaries", () => {
|
||||
@@ -20,19 +20,3 @@ describe("isAnnotationActiveAtTime", () => {
|
||||
expect(isAnnotationActiveAtTime({ startMs: Number.NaN, endMs: 2_000 }, 1_500)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearSelectedAnnotation", () => {
|
||||
const annotation = {
|
||||
id: "annotation-1",
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
} as never;
|
||||
|
||||
it("clears selection after the playhead leaves its active range", () => {
|
||||
expect(shouldClearSelectedAnnotation([annotation], annotation.id, 2_001)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps selection while its annotation is active", () => {
|
||||
expect(shouldClearSelectedAnnotation([annotation], annotation.id, 1_500)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,19 +12,3 @@ export function isAnnotationActiveAtTime(
|
||||
timeMs <= annotation.endMs
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether the current playhead has left the selected annotation's range. */
|
||||
export function shouldClearSelectedAnnotation(
|
||||
annotations: AnnotationRegion[],
|
||||
selectedAnnotationId: string | null | undefined,
|
||||
timeMs: number,
|
||||
): boolean {
|
||||
if (!selectedAnnotationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedAnnotation = annotations.find(
|
||||
(annotation) => annotation.id === selectedAnnotationId,
|
||||
);
|
||||
return Boolean(selectedAnnotation && !isAnnotationActiveAtTime(selectedAnnotation, timeMs));
|
||||
}
|
||||
|
||||
@@ -44,26 +44,53 @@ describe("clip timeline playback", () => {
|
||||
const onTime = vi.fn();
|
||||
const onPlaying = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onSourceSeek = vi.fn();
|
||||
const playback = createClipPlayback({
|
||||
video,
|
||||
getClips: () => clips,
|
||||
onTime,
|
||||
onPlaying,
|
||||
onError,
|
||||
onSourceSeek,
|
||||
});
|
||||
return { video, playback, onTime, onPlaying, onError };
|
||||
return { video, playback, onTime, onPlaying, onError, onSourceSeek };
|
||||
}
|
||||
it("takes real time through a deleted middle at 3x, then resumes at the retained source in-point", async () => {
|
||||
it("crosses a source cut at the same 2.8s timeline timestamp without playing the removed footage", async () => {
|
||||
const { video, playback, onTime, onSourceSeek } = setup([
|
||||
{ id: "a", startMs: 0, endMs: 2800, sourceStartMs: 0, speed: 1 },
|
||||
{ id: "b", startMs: 2800, endMs: 5300, sourceStartMs: 3500, speed: 1 },
|
||||
]);
|
||||
playback.seek(2.799);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2.799, 2.799);
|
||||
expect(onSourceSeek).toHaveBeenLastCalledWith("seek");
|
||||
await playback.play();
|
||||
video.currentTime = 2.8;
|
||||
advance(1);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2.8, 3.5);
|
||||
expect(video.currentTime).toBe(3.5);
|
||||
expect(onSourceSeek).toHaveBeenLastCalledWith("cut");
|
||||
playback.seek(2.8005);
|
||||
expect(onSourceSeek).toHaveBeenLastCalledWith("seek");
|
||||
video.currentTime = 3.501;
|
||||
advance(1);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2.801, 3.501);
|
||||
});
|
||||
it("cannot simulate playback after the final clip is deleted", async () => {
|
||||
const { video, playback, onTime } = setup([]);
|
||||
await playback.play();
|
||||
advance(1000);
|
||||
expect(playback.isPlaying).toBe(false);
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
expect(onTime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a deleted middle at 3x and resumes at the retained source in-point", async () => {
|
||||
const { video, playback, onTime } = setup();
|
||||
await playback.play();
|
||||
expect(video.playbackRate).toBe(3);
|
||||
video.currentTime = 3;
|
||||
advance(1000);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1, null);
|
||||
expect(playback.isPlaying).toBe(true);
|
||||
advance(500);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1.5, null);
|
||||
advance(500);
|
||||
expect(video.currentTime).toBe(6);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2, 6);
|
||||
video.currentTime = 9;
|
||||
@@ -73,21 +100,24 @@ describe("clip timeline playback", () => {
|
||||
advance(1000);
|
||||
expect(playback.isPlaying).toBe(false);
|
||||
});
|
||||
it("seeks, pauses and resumes inside a gap without revealing source footage or restarting the gap", async () => {
|
||||
it("keeps paused gap seeks editable and skips to the next clip when play resumes", async () => {
|
||||
const { video, playback, onTime } = setup();
|
||||
playback.seek(1.25);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1.25, null);
|
||||
await playback.play();
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
advance(250);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2, 6);
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
playback.pause();
|
||||
advance(5000);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1.5, null);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2, 6);
|
||||
await playback.play();
|
||||
video.currentTime = 6.75;
|
||||
advance(250);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1.75, null);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2.25, 6.75);
|
||||
playback.seek(1.5);
|
||||
expect(onTime).toHaveBeenLastCalledWith(2, 6);
|
||||
});
|
||||
it("does not skip a short gap when a media tick overshoots the cut", async () => {
|
||||
it("skips a short gap without skipping the next clip in-point when a tick overshoots", async () => {
|
||||
const { video, playback, onTime } = setup([
|
||||
{ id: "a", startMs: 0, endMs: 1000, sourceStartMs: 0, speed: 3 },
|
||||
{ id: "b", startMs: 1010, endMs: 2000, sourceStartMs: 6000, speed: 3 },
|
||||
@@ -95,8 +125,6 @@ describe("clip timeline playback", () => {
|
||||
await playback.play();
|
||||
video.currentTime = 3.15;
|
||||
advance(1050);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1, null);
|
||||
advance(10);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1.01, 6);
|
||||
});
|
||||
it("leaves a clip at source EOF even when metadata rounding extends its timeline end", async () => {
|
||||
@@ -119,14 +147,12 @@ describe("clip timeline playback", () => {
|
||||
advance(2000);
|
||||
expect(onTime).toHaveBeenLastCalledWith(3, 9);
|
||||
});
|
||||
it("plays leading gaps and clips placed earlier than their source positions", async () => {
|
||||
it("skips leading gaps and plays clips from their source positions", async () => {
|
||||
const { video, playback, onTime } = setup([
|
||||
{ id: "moved", startMs: 1000, endMs: 2000, sourceStartMs: 9000, speed: 1 },
|
||||
]);
|
||||
await playback.play();
|
||||
advance(500);
|
||||
expect(onTime).toHaveBeenLastCalledWith(0.5, null);
|
||||
advance(500);
|
||||
expect(onTime).toHaveBeenLastCalledWith(1, 9);
|
||||
expect(video.currentTime).toBe(9);
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
@@ -187,7 +213,9 @@ describe("clip timeline playback", () => {
|
||||
it("does not restart decoder seeks when repeatedly selecting the start", () => {
|
||||
const { playback, video } = setup();
|
||||
let currentTime = 0;
|
||||
const setTime = vi.fn((value: number) => { currentTime = value; });
|
||||
const setTime = vi.fn((value: number) => {
|
||||
currentTime = value;
|
||||
});
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
get: () => currentTime,
|
||||
set: setTime,
|
||||
|
||||
@@ -25,12 +25,14 @@ export function createClipPlayback({
|
||||
onTime,
|
||||
onPlaying,
|
||||
onError,
|
||||
onSourceSeek,
|
||||
}: {
|
||||
video: HTMLVideoElement;
|
||||
getClips: () => ClipRegion[];
|
||||
onTime: (timelineSeconds: number, sourceSeconds: number | null) => void;
|
||||
onPlaying: (playing: boolean) => void;
|
||||
onError: (error: unknown) => void;
|
||||
onSourceSeek?: (reason: "cut" | "seek") => void;
|
||||
}) {
|
||||
let timeMs = 0;
|
||||
let playing = false;
|
||||
@@ -38,7 +40,7 @@ export function createClipPlayback({
|
||||
let lastTick = 0;
|
||||
let activeClip: ClipRegion | null = null;
|
||||
let playRequest = 0;
|
||||
const duration = () => getTimelineDurationMs(getClips(), video.duration * 1000);
|
||||
const duration = () => getTimelineDurationMs(getClips(), 0);
|
||||
|
||||
const pause = () => {
|
||||
playRequest++;
|
||||
@@ -58,7 +60,14 @@ export function createClipPlayback({
|
||||
});
|
||||
};
|
||||
const sync = (seek = false) => {
|
||||
const clip = findPreviewClipAtTimelineTime(timeMs, getClips());
|
||||
const clips = getClips();
|
||||
// Empty timeline space is skipped during playback. Clip positions and
|
||||
// paused seeks stay intact so editing a gap never moves source footage.
|
||||
if (playing && !findPreviewClipAtTimelineTime(timeMs, clips)) {
|
||||
const next = sortClipRegions(clips).find((clip) => clip.startMs > timeMs);
|
||||
if (next) timeMs = next.startMs;
|
||||
}
|
||||
const clip = findPreviewClipAtTimelineTime(timeMs, clips);
|
||||
const sourceMs = clip
|
||||
? getClipSourceStartMs(clip) + (timeMs - clip.startMs) * clip.speed
|
||||
: null;
|
||||
@@ -78,13 +87,21 @@ export function createClipPlayback({
|
||||
const targetMs = atEnd
|
||||
? Math.max(getClipSourceStartMs(clip), sourceMs - 0.001)
|
||||
: sourceMs;
|
||||
const target = Math.max(0, Math.min(
|
||||
Number.isFinite(video.duration) ? Math.max(0, video.duration - 0.000001) : Infinity,
|
||||
targetMs / 1000,
|
||||
));
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Number.isFinite(video.duration)
|
||||
? Math.max(0, video.duration - 0.000001)
|
||||
: Infinity,
|
||||
targetMs / 1000,
|
||||
),
|
||||
);
|
||||
// Assigning currentTime even to its current value starts another
|
||||
// asynchronous seek in Chromium (especially disruptive at zero).
|
||||
if (Math.abs(video.currentTime - target) > 1e-8) video.currentTime = target;
|
||||
if (Math.abs(video.currentTime - target) > 1e-8) {
|
||||
onSourceSeek?.(playing && !seek ? "cut" : "seek");
|
||||
video.currentTime = target;
|
||||
}
|
||||
}
|
||||
if (playing && (seek || clip !== activeClip)) playSource();
|
||||
} else {
|
||||
@@ -98,7 +115,7 @@ export function createClipPlayback({
|
||||
request = null;
|
||||
if (!playing) return;
|
||||
// Follow the media inside footage (buffering must not skip content).
|
||||
// A gap has no source clock, so advance it with elapsed real time.
|
||||
// With no clips loaded, elapsed real time is the fallback clock.
|
||||
if (!activeClip) timeMs += now - lastTick;
|
||||
else if (!video.seeking) {
|
||||
timeMs = video.ended
|
||||
@@ -125,7 +142,7 @@ export function createClipPlayback({
|
||||
return playing;
|
||||
},
|
||||
play: async () => {
|
||||
if (playing) return;
|
||||
if (playing || getClips().length === 0) return;
|
||||
if (timeMs >= duration()) timeMs = 0;
|
||||
playing = true;
|
||||
onPlaying(true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getWebcamMediaTargetTimeSeconds,
|
||||
getWebcamPreviewTargetTimeSeconds,
|
||||
isWebcamMediaSynchronized,
|
||||
isWebcamVisibleAtSourceTime,
|
||||
shouldSeekWebcamMedia,
|
||||
} from "./webcamSync";
|
||||
|
||||
@@ -140,3 +141,16 @@ describe("isWebcamMediaSynchronized", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("imported webcam visibility", () => {
|
||||
it("hides screen-only spans and uses source-time boundaries", () => {
|
||||
const webcam = { visibleRanges: [{ startMs: 1200, endMs: 2000 }, { startMs: 4000, endMs: 5000 }] };
|
||||
expect(isWebcamVisibleAtSourceTime(webcam, 1)).toBe(false);
|
||||
expect(isWebcamVisibleAtSourceTime(webcam, 1.2)).toBe(true);
|
||||
expect(isWebcamVisibleAtSourceTime(webcam, 2)).toBe(false);
|
||||
expect(isWebcamVisibleAtSourceTime(webcam, 4.5)).toBe(true);
|
||||
expect(isWebcamVisibleAtSourceTime({ visibleRanges: [] }, 0)).toBe(false);
|
||||
expect(isWebcamVisibleAtSourceTime({}, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,3 +71,8 @@ export function shouldSeekWebcamMedia({
|
||||
|
||||
return timelineJumped || Math.abs(webcamCurrentTime - desiredTime) > driftThreshold;
|
||||
}
|
||||
|
||||
/** Missing ranges means an original recording; an empty list means no webcam frames. */
|
||||
export function isWebcamVisibleAtSourceTime(webcam: { visibleRanges?: { startMs: number; endMs: number }[] } | undefined, sourceTimeSeconds: number): boolean {
|
||||
return !webcam?.visibleRanges || webcam.visibleRanges.some(({ startMs, endMs }) => sourceTimeSeconds * 1000 >= startMs && sourceTimeSeconds * 1000 < endMs);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "@/components/video-editor/videoPlayback/motionSmoothing";
|
||||
import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects";
|
||||
import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion";
|
||||
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
|
||||
import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
computeZoomTransform,
|
||||
@@ -1739,7 +1739,7 @@ export class FrameRenderer {
|
||||
const webcam = this.config.webcam;
|
||||
const webcamDecodedFrame = this.webcamDecodedFrame;
|
||||
const webcamVideo = this.webcamVideoElement;
|
||||
if (!webcam?.enabled || (!webcamDecodedFrame && !webcamVideo)) {
|
||||
if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, this.currentVideoTime) || (!webcamDecodedFrame && !webcamVideo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
} from "@/components/video-editor/videoPlayback/motionSmoothing";
|
||||
import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects";
|
||||
import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion";
|
||||
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
|
||||
import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
computeZoomTransform,
|
||||
@@ -2725,7 +2725,7 @@ export class FrameRenderer {
|
||||
|
||||
private updateWebcamOverlay(referenceTimeSeconds = this.currentVideoTime): void {
|
||||
const webcam = this.config.webcam;
|
||||
if (!webcam?.enabled || !this.webcamRootContainer || !this.webcamMaskGraphics) {
|
||||
if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) || !this.webcamRootContainer || !this.webcamMaskGraphics) {
|
||||
if (this.webcamRootContainer) {
|
||||
this.webcamRootContainer.visible = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user