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
@@ -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;
}