feat(editor): persist clip sequences and synchronize playback

This commit is contained in:
webadderall
2026-09-20 18:52:26 +10:00
parent e82272fc6c
commit a40da338ec
23 changed files with 773 additions and 137 deletions
+25
View File
@@ -0,0 +1,25 @@
# Clip sequence timing
Primary footage is an ordered, contiguous sequence. A visual clip separator has no duration. Both sides of a cut at 2.8 seconds represent 2.8 seconds, even if the second clip starts at a different point in the source recording.
- `ClipRegion.startMs/endMs`: edited timeline coordinates.
- `sourceStartMs`: the retained recording in-point. Packing or reordering never derives this from the new timeline position.
- `speed`: source milliseconds consumed per timeline millisecond.
- `clipSequence.ts`: packs trims/deletes/speed changes, handles explicit insertion indices, and maps connected effects and audio anchors.
- `timeline/core/clipPresentation.ts`: maps media time into inset clip rectangles and back. Scrubbing a separator returns its cut timestamp. The playhead does not wait or animate through a separator.
Clip resizing is constrained by available source footage and minimum clip duration, rather than collision with the next clip. Dragging a clip chooses an insertion index rather than looking for empty space. Source-based captions are reprojected from their original cues. Imported audio retains its duration as its anchor moves. Annotation selection is independent of whether the annotation is visible at the current playhead time.
Legacy projects with primary-track gaps are normalized before establishing the loaded undo baseline. Editing and export use the same contiguous clip positions.
## Verification
135 focused unit tests passed for sequence packing/rippling, persistence, drag and resize resolution, visual seam mapping, playback, annotation visibility and deletion shortcuts. All 13 renderer regressions passed for block deletion, dragged annotation selection, background transitions, gap-free trims, clip reordering, and connected annotation/audio undo. These browser checks use fixture media and a mocked Electron bridge.
## Boundary snapping and preview motion
Timeline effects use the same media-to-display mapping as clips and the playhead. Dragging or resizing a zoom, annotation, caption, or audio block snaps within 8 screen pixels of other block boundaries, including clip cuts, at every timeline zoom level. Dragging preserves the block duration. Moving beyond that distance releases the snap. Zoom blocks can span visual separators continuously.
Hover previews and caption placement use the same inverse mapping. Keyframe positions and playhead snapping use media time consistently. Source-video seeks at contiguous cuts preserve camera springs; explicit timeline seeks still snap to the requested frame.
Caption visibility was checked in the real renderer with fixture footage at 1×, 2× and 4×, including source-frame matching, cue visibility boundaries and moving playback. The caption test captures the preview at 2×. This verifies rendering and timing, not speech-recognition accuracy or a packaged export.
@@ -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}
+37 -37
View File
@@ -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 },
]);
});
+115
View File
@@ -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
+5
View File
@@ -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);
}
+2 -2
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
+159
View File
@@ -0,0 +1,159 @@
import { expect, type Locator, type Page, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
const clipsFor = (page: Page) => page.locator('[data-variant="clip"]');
const sourceTime = (page: Page) =>
page
.locator('video[aria-hidden="true"]')
.evaluate((video: HTMLVideoElement) => video.currentTime);
const spanFor = async (item: Locator) => ({
start: Number(await item.getAttribute("data-start-ms")),
end: Number(await item.getAttribute("data-end-ms")),
});
async function seekInsideClip(page: Page, clip: Locator, fraction: number) {
const box = (await clip.locator(".timeline-block").boundingBox())!;
const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
await page.mouse.click(box.x + box.width * fraction, row.y - 8);
}
async function splitInsideClip(page: Page, clip: Locator, fraction: number) {
const count = await clipsFor(page).count();
await seekInsideClip(page, clip, fraction);
await page.getByRole("button", { name: "Split Clip (C)", exact: true }).click();
await expect(clipsFor(page)).toHaveCount(count + 1);
}
async function dragLeftEdge(page: Page, clip: Locator, deltaPx: number) {
const box = (await clip.boundingBox())!;
await page.mouse.move(box.x + 2, box.y + 20);
await page.mouse.down();
await page.mouse.move(box.x + 2 + deltaPx, box.y + 20, { steps: 12 });
await page.mouse.up();
}
async function makeThreeClips(page: Page) {
const clips = clipsFor(page);
await splitInsideClip(page, clips.first(), 1 / 3);
await splitInsideClip(page, clips.nth(1), 1 / 2);
}
test.beforeEach(async ({ page }) => {
await installDesktopBridge(page, "filmstrip.mp4");
await page.goto("/?windowType=editor");
await expect(clipsFor(page)).toHaveAttribute("data-end-ms", "6000", { timeout: 20000 });
});
test("left trimming keeps both gutter edges at one timestamp and can reveal footage again", async ({
page,
}) => {
const clips = clipsFor(page);
await splitInsideClip(page, clips.first(), 0.5);
const first = clips.nth(0);
const second = clips.nth(1);
const cut = (await spanFor(first)).end;
await dragLeftEdge(page, second, 60);
await expect.poll(async () => (await spanFor(second)).end).toBeLessThan(5900);
const trimmed = await spanFor(second);
expect(trimmed.start).toBe(cut);
await expect(first).toHaveAttribute("data-end-ms", String(cut));
const removedMs = 6000 - trimmed.end;
const row = (await page.locator('[data-timeline-row="row-clip"]').boundingBox())!;
const left = (await first.locator(".timeline-block").boundingBox())!;
const right = (await second.locator(".timeline-block").boundingBox())!;
for (const x of [left.x + left.width + 2, right.x - 2]) {
await page.mouse.click(x, row.y - 8);
await expect(page.getByTestId("playhead-cap")).toHaveAttribute(
"aria-label",
`Playhead ${(cut / 1000).toFixed(1)}s`,
);
await expect.poll(() => sourceTime(page)).toBeCloseTo((cut + removedMs) / 1000, 2);
}
// Clip resizing maps its visible body to its full media span, excluding the gutter.
const restorePixels = (removedMs / (trimmed.end - trimmed.start)) * right.width;
await dragLeftEdge(page, second, -restorePixels);
await expect.poll(async () => Math.abs((await spanFor(second)).end - 6000)).toBeLessThan(12);
await expect(second).toHaveAttribute("data-start-ms", String(cut));
const restored = (await second.locator(".timeline-block").boundingBox())!;
await page.mouse.click(restored.x - 2, row.y - 8);
await expect.poll(() => sourceTime(page)).toBeCloseTo(cut / 1000, 1);
});
test("dragging the first clip past the middle clip inserts between its neighbors", async ({
page,
}) => {
await makeThreeClips(page);
const clips = clipsFor(page);
const before = await Promise.all([
spanFor(clips.nth(0)),
spanFor(clips.nth(1)),
spanFor(clips.nth(2)),
]);
const first = (await clips.nth(0).boundingBox())!;
const second = (await clips.nth(1).boundingBox())!;
await page.mouse.move(first.x + first.width / 2, first.y + 20);
await page.mouse.down();
await page.mouse.move(second.x + second.width / 2 + 15, second.y + 20, { steps: 16 });
await page.mouse.up();
const expectedOrder = [before[1], before[0], before[2]];
for (let index = 0; index < 3; index += 1) {
await seekInsideClip(page, clips.nth(index), 0.5);
await expect
.poll(() => sourceTime(page))
.toBeCloseTo((expectedOrder[index].start + expectedOrder[index].end) / 2000, 1);
}
const after = await Promise.all([
spanFor(clips.nth(0)),
spanFor(clips.nth(1)),
spanFor(clips.nth(2)),
]);
expect(after[0].start).toBe(0);
expect(after[0].end).toBe(after[1].start);
expect(after[1].end).toBe(after[2].start);
expect(after[2].end).toBe(6000);
});
test("clip deletion and undo ripple connected annotations and imported audio together", async ({
page,
}) => {
await makeThreeClips(page);
const clips = clipsFor(page);
const removedDuration = (await spanFor(clips.first())).end;
await seekInsideClip(page, clips.nth(2), 0.25);
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
await page.getByRole("menuitem", { name: "Annotation", exact: true }).click();
const annotation = page.locator('[data-variant="annotation"]');
await expect(annotation).toHaveCount(1);
await page.evaluate(() => {
window.electronAPI.openAudioFilePicker = async () => ({
success: true,
path: `${location.origin}/tests/ui/fixtures/filmstrip.mp4`,
});
});
await page.getByRole("button", { name: "Add Layer", exact: true }).click();
await page.getByRole("menuitem", { name: "Audio", exact: true }).click();
const audio = page.locator('[data-variant="audio"]');
await expect(audio).toHaveCount(1);
const annotationBefore = await spanFor(annotation);
const audioBefore = await spanFor(audio);
await clips.first().click({ position: { x: 50, y: 20 } });
await page.keyboard.press("Delete");
await expect(clips).toHaveCount(2);
await expect(annotation).toHaveAttribute(
"data-start-ms",
String(annotationBefore.start - removedDuration),
);
await expect(audio).toHaveAttribute(
"data-start-ms",
String(audioBefore.start - removedDuration),
);
await expect(audio).toHaveAttribute("data-end-ms", String(audioBefore.end - removedDuration));
await expect(clips.last()).toHaveAttribute("data-end-ms", String(6000 - removedDuration));
await page.keyboard.press("Meta+z");
await expect(clips).toHaveCount(3);
await expect(annotation).toHaveAttribute("data-start-ms", String(annotationBefore.start));
await expect(annotation).toHaveAttribute("data-end-ms", String(annotationBefore.end));
await expect(audio).toHaveAttribute("data-start-ms", String(audioBefore.start));
await expect(audio).toHaveAttribute("data-end-ms", String(audioBefore.end));
await expect(clips.last()).toHaveAttribute("data-end-ms", "6000");
});