fix(editor): restore inline project save and preview playback stability

This commit is contained in:
webadderall
2026-04-21 11:12:49 +10:00
parent 02880b2a60
commit 1d8b4bc5c0
10 changed files with 425 additions and 240 deletions
+3 -59
View File
@@ -16,7 +16,7 @@ import { useI18n } from "@/contexts/I18nContext";
import { ASPECT_RATIOS, getAspectRatioLabel, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { CropControl } from "./CropControl";
import { EditorToolbar } from "./EditorToolbar";
import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor";
import type { TimelineEditorHandle } from "./timeline/TimelineEditor";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import type { CursorTelemetryPoint } from "./types";
import type { useEditorPreferences } from "./hooks/useEditorPreferences";
@@ -41,8 +41,6 @@ interface EditorContentProps {
isCropped: boolean;
hasSourceAudioFallback: boolean;
effectiveCursorTelemetry: CursorTelemetryPoint[];
normalizedCursorTelemetry: CursorTelemetryPoint[];
autoSuggestZoomsTrigger: number;
videoPlaybackRef: React.RefObject<VideoPlaybackRef | null>;
timelineRef: React.RefObject<TimelineEditorHandle | null>;
setDuration: (v: number) => void;
@@ -56,7 +54,6 @@ interface EditorContentProps {
handleOpenCropEditor: () => void;
handleCloseCropEditor: () => void;
handleCancelCropEditor: () => void;
handleAutoSuggestZoomsConsumed: () => void;
}
export function EditorContent({
@@ -73,8 +70,6 @@ export function EditorContent({
isCropped,
hasSourceAudioFallback,
effectiveCursorTelemetry,
normalizedCursorTelemetry,
autoSuggestZoomsTrigger,
videoPlaybackRef,
timelineRef,
setDuration,
@@ -88,13 +83,11 @@ export function EditorContent({
handleOpenCropEditor,
handleCloseCropEditor,
handleCancelCropEditor,
handleAutoSuggestZoomsConsumed,
}: EditorContentProps) {
const { t } = useI18n();
return (
<>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="flex min-h-0 flex-1 flex-col gap-3">
{/* Preview */}
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden">
@@ -248,55 +241,6 @@ export function EditorContent({
togglePlayPause={togglePlayPause}
handleSeek={handleSeek}
/>
</div>
{/* Timeline */}
<div
className="flex-shrink-0 flex flex-col"
style={{
height: timelineCollapsed ? undefined : "15%",
minHeight: timelineCollapsed ? 0 : 160,
}}
>
<TimelineEditor
ref={timelineRef as React.Ref<TimelineEditorHandle>}
hideToolbar
videoDuration={duration}
currentTime={currentTime}
playheadTime={regions.timelinePlayheadTime}
onSeek={handleSeek}
videoPath={videoPath}
cursorTelemetry={normalizedCursorTelemetry}
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
onAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed}
zoomRegions={regions.zoomRegions}
onZoomAdded={regions.handleZoomAdded}
onZoomSuggested={regions.handleZoomSuggested}
onZoomSpanChange={regions.handleZoomSpanChange}
onZoomDelete={regions.handleZoomDelete}
selectedZoomId={regions.selectedZoomId}
onSelectZoom={regions.handleSelectZoom}
trimRegions={regions.trimRegions}
clipRegions={regions.clipRegions}
onClipSplit={regions.handleClipSplit}
onClipSpanChange={regions.handleClipSpanChange}
onClipDelete={regions.handleClipDelete}
selectedClipId={regions.selectedClipId}
onSelectClip={regions.handleSelectClip}
audioRegions={regions.audioRegions}
onAudioAdded={regions.handleAudioAdded}
onAudioSpanChange={regions.handleAudioSpanChange}
onAudioDelete={regions.handleAudioDelete}
selectedAudioId={regions.selectedAudioId}
onSelectAudio={regions.handleSelectAudio}
annotationRegions={regions.annotationRegions}
onAnnotationAdded={regions.handleAnnotationAdded}
onAnnotationSpanChange={regions.handleAnnotationSpanChange}
onAnnotationDelete={regions.handleAnnotationDelete}
selectedAnnotationId={regions.selectedAnnotationId}
onSelectAnnotation={regions.handleSelectAnnotation}
aspectRatio={prefs.aspectRatio}
/>
</div>
{/* Crop modal */}
{showCropModal ? (
<>
@@ -341,6 +285,6 @@ export function EditorContent({
</div>
</>
) : null}
</>
</div>
);
}
+126 -51
View File
@@ -2,10 +2,9 @@ import {
DownloadSimple as Download,
FolderOpen,
ArrowClockwise as Redo2,
FloppyDisk as Save,
ArrowCounterClockwise as Undo2,
} from "@phosphor-icons/react";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -41,7 +40,6 @@ interface EditorHeaderProps {
headerLeftControlsPaddingClass: string;
mp4OutputDimensions: Record<string, { width: number; height: number }>;
gifOutputDimensions: { width: number; height: number };
openRecordingsFolder: () => Promise<void>;
revealExportedFile: () => Promise<void>;
projectBrowserTriggerRef: React.RefObject<HTMLButtonElement | null>;
}
@@ -55,13 +53,65 @@ export function EditorHeader({
headerLeftControlsPaddingClass,
mp4OutputDimensions,
gifOutputDimensions,
openRecordingsFolder,
revealExportedFile,
projectBrowserTriggerRef,
}: EditorHeaderProps) {
const { t } = useI18n();
const [isEditingProjectName, setIsEditingProjectName] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState(projectDisplayName);
const [isSavingProjectName, setIsSavingProjectName] = useState(false);
const projectNameInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!isEditingProjectName) {
setProjectNameDraft(projectDisplayName);
}
}, [isEditingProjectName, projectDisplayName]);
useEffect(() => {
if (!isEditingProjectName) {
return;
}
const frameId = window.requestAnimationFrame(() => {
projectNameInputRef.current?.focus();
projectNameInputRef.current?.select();
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [isEditingProjectName]);
const closeProjectNameEditor = useCallback(() => {
setProjectNameDraft(projectDisplayName);
setIsEditingProjectName(false);
}, [projectDisplayName]);
const handleProjectNameSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const trimmedProjectName = projectNameDraft.trim();
if (!trimmedProjectName) {
closeProjectNameEditor();
return;
}
setIsSavingProjectName(true);
const saved = await project.saveProjectWithName(trimmedProjectName);
setIsSavingProjectName(false);
if (saved) {
setIsEditingProjectName(false);
return;
}
projectNameInputRef.current?.focus();
projectNameInputRef.current?.select();
},
[closeProjectNameEditor, project, projectNameDraft],
);
// ── Export derived labels ─────────────────────────────────────────
const isLightningExportInProgress =
prefs.exportFormat === "mp4" &&
prefs.exportPipelineModel === "modern" &&
@@ -123,7 +173,7 @@ export function EditorHeader({
return (
<div
className="relative flex h-11 flex-shrink-0 items-center justify-between bg-editor-header/88 px-5 backdrop-blur-md border-b border-foreground/10 z-50"
className="relative z-50 flex h-11 flex-shrink-0 items-center justify-between border-b border-foreground/10 bg-editor-header/88 px-5 backdrop-blur-md"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
@@ -131,13 +181,14 @@ export function EditorHeader({
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Button
ref={projectBrowserTriggerRef as React.Ref<HTMLButtonElement>}
type="button"
variant="ghost"
size="sm"
onClick={() => void openRecordingsFolder()}
onClick={project.handleOpenProjectBrowser}
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("common.app.manageRecordings", "Open recordings folder")}
aria-label={t("common.app.manageRecordings", "Open recordings folder")}
title={t("editor.project.projects", "Open projects")}
aria-label={t("editor.project.projects", "Open projects")}
>
<FolderOpen className="h-4 w-4" />
</Button>
@@ -168,48 +219,66 @@ export function EditorHeader({
</Button>
</div>
<div
className="pointer-events-none absolute left-1/2 flex min-w-0 -translate-x-1/2 items-baseline justify-center gap-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<span className="text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
{isEditingProjectName ? (
<form
onSubmit={(event) => void handleProjectNameSubmit(event)}
className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]"
>
{project.hasUnsavedChanges ? (
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
) : null}
<input
ref={projectNameInputRef}
type="text"
value={projectNameDraft}
onChange={(event) => setProjectNameDraft(event.target.value)}
onBlur={() => {
if (!isSavingProjectName) {
closeProjectNameEditor();
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
closeProjectNameEditor();
}
}}
disabled={isSavingProjectName}
className="min-w-[10ch] max-w-[min(40vw,360px)] bg-transparent text-sm font-semibold tracking-tight text-foreground/95 outline-none placeholder:text-muted-foreground/60 disabled:cursor-wait"
style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }}
aria-label={t("editor.project.renameInput", "Project name")}
/>
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</form>
) : (
<button
type="button"
onClick={() => setIsEditingProjectName(true)}
className="inline-flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] px-2.5 py-1 transition-colors hover:bg-foreground/5"
title={t("editor.project.renameTitle", "Rename project")}
aria-label={t("editor.project.renameTitle", "Rename project")}
>
{project.hasUnsavedChanges ? (
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
) : null}
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</button>
)}
</div>
<div
className="flex items-center gap-2 justify-self-end pr-3"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Button
ref={projectBrowserTriggerRef as React.Ref<HTMLButtonElement>}
type="button"
onClick={project.handleOpenProjectBrowser}
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-[5px] bg-neutral-800 px-4 text-white shadow-[0_14px_32px_rgba(0,0,0,0.18)] transition-colors hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"
>
<FolderOpen className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
{t("editor.project.projects", "Projects")}
</span>
</Button>
<Button
type="button"
onClick={project.handleSaveProject}
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-[5px] bg-neutral-800 px-4 text-white transition-colors hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"
>
<span
className={`${project.hasUnsavedChanges ? "flex" : "hidden"} size-2 relative`}
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2563EB] opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-[#2563EB]" />
</span>
<Save className="h-4 w-4" weight="fill" />
<span className="text-sm font-semibold tracking-tight">
{t("common.actions.save")}
</span>
</Button>
<div className="mx-1 h-5 w-px bg-foreground/10" />
<DropdownMenu
open={exp.showExportDropdown}
onOpenChange={exp.setShowExportDropdown}
@@ -278,14 +347,17 @@ export function EditorHeader({
<div
className="h-full bg-[#2563EB] transition-all duration-300 ease-out"
style={{
width: `${Math.min(exp.isRenderingAudio ? ((exp.exportProgress as ExportProgress).audioProgress ?? 0) * 100 : (exp.exportFinalizingProgress ?? exp.exportProgress?.percentage ?? 8), 100)}%`,
width: `${Math.min(
exp.isRenderingAudio
? ((exp.exportProgress as ExportProgress).audioProgress ?? 0) * 100
: (exp.exportFinalizingProgress ?? exp.exportProgress?.percentage ?? 8),
100,
)}%`,
}}
/>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
{exportPercentLabel}
</p>
<p className="mt-2 text-xs text-muted-foreground">{exportPercentLabel}</p>
{exp.isRenderingAudio ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
Audio requires real-time playback for speed/overlay edits
@@ -340,7 +412,10 @@ export function EditorHeader({
{t("editor.exportStatus.complete", "Export complete")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("editor.exportStatus.savedSuccessfully", "Your file was saved successfully.")}
{t(
"editor.exportStatus.savedSuccessfully",
"Your file was saved successfully.",
)}
</p>
{exportRuntimeLabel ? (
<p className="mt-1 text-[11px] text-muted-foreground/70">
@@ -397,4 +472,4 @@ export function EditorHeader({
</div>
</div>
);
}
}
+49 -16
View File
@@ -23,7 +23,7 @@ import ProjectBrowserDialog from "./ProjectBrowserDialog";
import { fromFileUrl } from "./projectPersistence";
import type { CropRegion } from "./types";
import { VideoPlaybackRef } from "./VideoPlayback";
import type { TimelineEditorHandle } from "./timeline/TimelineEditor";
import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor";
import { getSmokeExportConfig } from "./videoEditorUtils";
export default function VideoEditor() {
@@ -293,16 +293,6 @@ export default function VideoEditor() {
}, [prefs.cropRegion]);
// ── Misc handlers ────────────────────────────────────────────────
const openRecordingsFolder = useCallback(async () => {
try {
const result = await window.electronAPI.openRecordingsFolder();
if (!result.success)
toast.error(result.message || result.error || "Failed to open recordings folder.");
} catch (err) {
toast.error(`Failed to open recordings folder: ${String(err)}`);
}
}, []);
const revealExportedFile = useCallback(async () => {
if (!exp.exportedFilePath) return;
try {
@@ -368,12 +358,11 @@ export default function VideoEditor() {
headerLeftControlsPaddingClass={headerLeftControlsPaddingClass}
mp4OutputDimensions={wiring.mp4OutputDimensions}
gifOutputDimensions={wiring.gifOutputDimensions}
openRecordingsFolder={openRecordingsFolder}
revealExportedFile={revealExportedFile}
projectBrowserTriggerRef={projectBrowserTriggerRef}
/>
<div className="relative flex min-h-0 flex-1 flex-col gap-3 p-4">
<div className="flex min-h-0 flex-1 gap-3">
<div className="relative z-10 flex min-h-0 flex-1 gap-3">
<EditorSidebar
prefs={prefs}
regions={regions}
@@ -396,8 +385,6 @@ export default function VideoEditor() {
isCropped={isCropped}
hasSourceAudioFallback={hasSourceAudioFallback}
effectiveCursorTelemetry={wiring.effectiveCursorTelemetry}
normalizedCursorTelemetry={wiring.normalizedCursorTelemetry}
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
videoPlaybackRef={videoPlaybackRef}
timelineRef={timelineRef}
setDuration={setDuration}
@@ -411,7 +398,53 @@ export default function VideoEditor() {
handleOpenCropEditor={handleOpenCropEditor}
handleCloseCropEditor={handleCloseCropEditor}
handleCancelCropEditor={handleCancelCropEditor}
handleAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed}
/>
</div>
<div
className="relative z-0 isolate flex-shrink-0 flex flex-col"
style={{
height: timelineCollapsed ? undefined : "15%",
minHeight: timelineCollapsed ? 0 : 160,
}}
>
<TimelineEditor
ref={timelineRef}
hideToolbar
videoDuration={duration}
currentTime={currentTime}
playheadTime={regions.timelinePlayheadTime}
onSeek={handleSeek}
videoPath={videoPath}
cursorTelemetry={wiring.normalizedCursorTelemetry}
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
onAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed}
zoomRegions={regions.zoomRegions}
onZoomAdded={regions.handleZoomAdded}
onZoomSuggested={regions.handleZoomSuggested}
onZoomSpanChange={regions.handleZoomSpanChange}
onZoomDelete={regions.handleZoomDelete}
selectedZoomId={regions.selectedZoomId}
onSelectZoom={regions.handleSelectZoom}
trimRegions={regions.trimRegions}
clipRegions={regions.clipRegions}
onClipSplit={regions.handleClipSplit}
onClipSpanChange={regions.handleClipSpanChange}
onClipDelete={regions.handleClipDelete}
selectedClipId={regions.selectedClipId}
onSelectClip={regions.handleSelectClip}
audioRegions={regions.audioRegions}
onAudioAdded={regions.handleAudioAdded}
onAudioSpanChange={regions.handleAudioSpanChange}
onAudioDelete={regions.handleAudioDelete}
selectedAudioId={regions.selectedAudioId}
onSelectAudio={regions.handleSelectAudio}
annotationRegions={regions.annotationRegions}
onAnnotationAdded={regions.handleAnnotationAdded}
onAnnotationSpanChange={regions.handleAnnotationSpanChange}
onAnnotationDelete={regions.handleAnnotationDelete}
selectedAnnotationId={regions.selectedAnnotationId}
onSelectAnnotation={regions.handleSelectAnnotation}
aspectRatio={prefs.aspectRatio}
/>
</div>
</div>
@@ -39,6 +39,14 @@ interface UseEditorProjectParams {
clearHistory: () => void;
}
type SaveProjectResult = {
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
};
export function useEditorProject({
getCurrentPersistedState,
getCurrentSourcePath,
@@ -71,18 +79,50 @@ export function useEditorProject({
return JSON.stringify(current) !== JSON.stringify(lastSavedSnapshot);
}, [getCurrentPersistedState, getCurrentSourcePath, getCurrentProjectPath, lastSavedSnapshot]);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
const sourcePath = getCurrentSourcePath();
if (!sourcePath) {
toast.error("No video loaded");
const prepareProjectSave = useCallback(async () => {
const sourcePath = getCurrentSourcePath();
if (!sourcePath) {
toast.error("No video loaded");
return null;
}
const projectData = createProjectData(sourcePath, getCurrentPersistedState());
const fileNameBase =
sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") ||
`project-${Date.now()}`;
const thumbnailDataUrl = await captureProjectThumbnail();
return {
projectData,
fileNameBase,
thumbnailDataUrl,
};
}, [captureProjectThumbnail, getCurrentPersistedState, getCurrentSourcePath]);
const completeProjectSave = useCallback(
async (result: SaveProjectResult, projectData: EditorProjectData) => {
if (result.canceled) {
toast.info("Project save canceled");
return false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
return false;
}
if (result.path) setCurrentProjectPath(result.path);
setLastSavedSnapshot(globalThis.structuredClone(projectData));
await refreshProjectLibrary();
toast.success(result.path ? `Project saved to ${result.path}` : "Project saved");
return true;
},
[refreshProjectLibrary, setCurrentProjectPath],
);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
const preparedSave = await prepareProjectSave();
if (!preparedSave) return false;
try {
const projectData = createProjectData(sourcePath, getCurrentPersistedState());
const fileNameBase =
sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") ||
`project-${Date.now()}`;
let targetProjectPath = forceSaveAs ? undefined : (getCurrentProjectPath() ?? undefined);
if (!forceSaveAs && !targetProjectPath) {
@@ -93,42 +133,53 @@ export function useEditorProject({
}
}
const thumbnailDataUrl = await captureProjectThumbnail();
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
preparedSave.projectData,
preparedSave.fileNameBase,
targetProjectPath,
thumbnailDataUrl,
preparedSave.thumbnailDataUrl,
);
if (result.canceled) {
toast.info("Project save canceled");
return false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
return false;
}
if (result.path) setCurrentProjectPath(result.path);
setLastSavedSnapshot(globalThis.structuredClone(projectData));
await refreshProjectLibrary();
toast.success(`Project saved to ${result.path}`);
return true;
return await completeProjectSave(result, preparedSave.projectData);
} finally {
remountPreview();
}
},
[
captureProjectThumbnail,
getCurrentPersistedState,
getCurrentSourcePath,
completeProjectSave,
getCurrentProjectPath,
prepareProjectSave,
setCurrentProjectPath,
refreshProjectLibrary,
remountPreview,
],
);
const saveProjectWithName = useCallback(
async (projectName: string) => {
const trimmedProjectName = projectName.trim();
if (!trimmedProjectName) {
toast.error("Project name is required");
return false;
}
const preparedSave = await prepareProjectSave();
if (!preparedSave) return false;
try {
const result = await window.electronAPI.saveProjectFileNamed(
preparedSave.projectData,
trimmedProjectName,
preparedSave.thumbnailDataUrl,
);
return await completeProjectSave(result, preparedSave.projectData);
} finally {
remountPreview();
}
},
[completeProjectSave, prepareProjectSave, remountPreview],
);
/** Load and apply a project from a raw (possibly unknown) candidate value. */
const applyLoadedProject = useCallback(
async (candidate: unknown, path?: string | null) => {
@@ -218,6 +269,7 @@ export function useEditorProject({
setLastSavedSnapshot,
hasUnsavedChanges,
saveProject,
saveProjectWithName,
handleSaveProject,
handleSaveProjectAs,
applyLoadedProject,
@@ -309,7 +309,7 @@ export function useEditorRegions({
}) => {
setZoomRegions(editor.zoomRegions);
setClipRegions(editor.clipRegions);
clipInitializedRef.current = true;
clipInitializedRef.current = editor.clipRegions.length > 0;
resetAnnotationAudioForProject(editor);
setSelectedZoomId(null);
setSelectedClipId(null);
@@ -76,6 +76,7 @@ export function useEditorWiring({
(snapshot: EditorHistorySnapshot) => {
regions.setZoomRegions(snapshot.zoomRegions);
regions.setClipRegions(snapshot.clipRegions);
regions.clipInitializedRef.current = snapshot.clipRegions.length > 0;
regions.setAnnotationRegions(snapshot.annotationRegions);
regions.setAudioRegions(snapshot.audioRegions);
captions.setAutoCaptions(snapshot.autoCaptions);
@@ -221,8 +221,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(function
videoReady,
onTimeUpdate,
onPlayStateChange,
layoutVideoContent: layout.layoutVideoContent,
updateOverlayForRegion: () => layout.updateOverlayForRegion(null),
updateOverlayForRegion: layout.updateOverlayForRegion,
});
usePlaybackTicker({
@@ -10,8 +10,7 @@ interface UsePixiVideoSceneParams {
videoReady: boolean;
onTimeUpdate: (time: number) => void;
onPlayStateChange: (playing: boolean) => void;
layoutVideoContent: () => void;
updateOverlayForRegion: () => void;
updateOverlayForRegion: (region: null) => void;
}
export function usePixiVideoScene({
@@ -20,7 +19,6 @@ export function usePixiVideoScene({
videoReady,
onTimeUpdate,
onPlayStateChange,
layoutVideoContent,
updateOverlayForRegion,
}: UsePixiVideoSceneParams) {
useEffect(() => {
@@ -64,7 +62,7 @@ export function usePixiVideoScene({
refs.blurFilterRef.current = blurFilter;
refs.motionBlurFilterRef.current = motionBlurFilter;
layoutVideoContent();
refs.layoutVideoContentRef.current?.();
video.pause();
const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({
@@ -118,7 +116,7 @@ export function usePixiVideoScene({
}
videoTexture.destroy(false);
refs.videoSpriteRef.current = null;
updateOverlayForRegion();
updateOverlayForRegion(null);
};
}, [layoutVideoContent, onPlayStateChange, onTimeUpdate, pixiReady, refs, updateOverlayForRegion, videoReady]);
}, [onPlayStateChange, onTimeUpdate, pixiReady, refs, updateOverlayForRegion, videoReady]);
}
@@ -83,79 +83,156 @@ export function useVideoPlaybackRefs({
"image" | "video" | "style"
>("image");
const refs: VideoPlaybackRuntimeRefs = {
videoRef: useRef<HTMLVideoElement | null>(null),
containerRef: useRef<HTMLDivElement | null>(null),
appRef: useRef<Application | null>(null),
videoSpriteRef: useRef<Sprite | null>(null),
videoContainerRef: useRef<Container | null>(null),
cursorContainerRef: useRef<Container | null>(null),
cameraContainerRef: useRef<Container | null>(null),
timeUpdateAnimationRef: useRef<number | null>(null),
overlayRef: useRef<HTMLDivElement | null>(null),
focusIndicatorRef: useRef<HTMLDivElement | null>(null),
webcamVideoRef: useRef<HTMLVideoElement | null>(null),
webcamBubbleRef: useRef<HTMLDivElement | null>(null),
webcamBubbleInnerRef: useRef<HTMLDivElement | null>(null),
captionBoxRef: useRef<HTMLDivElement | null>(null),
currentTimeRef: useRef(0),
zoomRegionsRef: useRef(zoomRegions),
selectedZoomIdRef: useRef<string | null>(selectedZoomId),
animationStateRef: useRef(createPlaybackAnimationState()),
blurFilterRef: useRef<BlurFilter | null>(null),
motionBlurFilterRef: useRef<MotionBlurFilter | null>(null),
isDraggingFocusRef: useRef(false),
stageSizeRef: useRef({ width: 0, height: 0 }),
videoSizeRef: useRef({ width: 0, height: 0 }),
baseScaleRef: useRef(1),
baseOffsetRef: useRef({ x: 0, y: 0 }),
baseMaskRef: useRef({ x: 0, y: 0, width: 0, height: 0 }),
cropBoundsRef: useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }),
maskGraphicsRef: useRef<Graphics | null>(null),
frameSpriteRef: useRef<Sprite | null>(null),
frameContainerRef: useRef<Container | null>(null),
frameIdRef: useRef<string | null>(frame),
isPlayingRef: useRef(isPlaying),
isSeekingRef: useRef(false),
allowPlaybackRef: useRef(false),
lockedVideoDimensionsRef: useRef<{ width: number; height: number } | null>(null),
layoutVideoContentRef: useRef<(() => void) | null>(null),
trimRegionsRef: useRef<TrimRegion[]>(trimRegions),
speedRegionsRef: useRef<SpeedRegion[]>(speedRegions),
lastWebcamSyncTimeRef: useRef<number | null>(null),
bgVideoRef: useRef<HTMLVideoElement | null>(null),
zoomMotionBlurRef: useRef(zoomMotionBlur),
connectZoomsRef: useRef(connectZooms),
zoomInDurationMsRef: useRef(zoomInDurationMs),
zoomInOverlapMsRef: useRef(zoomInOverlapMs),
zoomOutDurationMsRef: useRef(zoomOutDurationMs),
connectedZoomGapMsRef: useRef(connectedZoomGapMs),
connectedZoomDurationMsRef: useRef(connectedZoomDurationMs),
zoomInEasingRef: useRef(zoomInEasing),
zoomOutEasingRef: useRef(zoomOutEasing),
connectedZoomEasingRef: useRef(connectedZoomEasing),
videoReadyRafRef: useRef<number | null>(null),
cursorOverlayRef: useRef(null),
cursorEffectsCanvasRef: useRef<HTMLCanvasElement | null>(null),
cursorTelemetryRef: useRef(cursorTelemetry),
showCursorRef: useRef(showCursor),
cursorSizeRef: useRef(cursorSize),
cursorStyleRef: useRef(cursorStyle),
cursorSmoothingRef: useRef(cursorSmoothing),
cursorMotionBlurRef: useRef(cursorMotionBlur),
cursorClickBounceRef: useRef(cursorClickBounce),
cursorClickBounceDurationRef: useRef(cursorClickBounceDuration),
cursorSwayRef: useRef(cursorSway),
lastEmittedClickTimeMsRef: useRef(-1),
springScaleRef: useRef<SpringState>(createSpringState(1)),
springXRef: useRef<SpringState>(createSpringState(0)),
springYRef: useRef<SpringState>(createSpringState(0)),
lastTickTimeRef: useRef<number | null>(null),
zoomSmoothnessRef: useRef(zoomSmoothness),
zoomClassicModeRef: useRef(zoomClassicMode),
cursorFollowCameraRef: useRef<CursorFollowCameraState>(createCursorFollowCameraState()),
motionBlurStateRef: useRef<MotionBlurState>(createMotionBlurState()),
};
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoContainerRef = useRef<Container | null>(null);
const cursorContainerRef = useRef<Container | null>(null);
const cameraContainerRef = useRef<Container | null>(null);
const timeUpdateAnimationRef = useRef<number | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const webcamBubbleRef = useRef<HTMLDivElement | null>(null);
const webcamBubbleInnerRef = useRef<HTMLDivElement | null>(null);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef(zoomRegions);
const selectedZoomIdRef = useRef<string | null>(selectedZoomId);
const animationStateRef = useRef(createPlaybackAnimationState());
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
const baseScaleRef = useRef(1);
const baseOffsetRef = useRef({ x: 0, y: 0 });
const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 });
const maskGraphicsRef = useRef<Graphics | null>(null);
const frameSpriteRef = useRef<Sprite | null>(null);
const frameContainerRef = useRef<Container | null>(null);
const frameIdRef = useRef<string | null>(frame);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>(trimRegions);
const speedRegionsRef = useRef<SpeedRegion[]>(speedRegions);
const lastWebcamSyncTimeRef = useRef<number | null>(null);
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const connectZoomsRef = useRef(connectZooms);
const zoomInDurationMsRef = useRef(zoomInDurationMs);
const zoomInOverlapMsRef = useRef(zoomInOverlapMs);
const zoomOutDurationMsRef = useRef(zoomOutDurationMs);
const connectedZoomGapMsRef = useRef(connectedZoomGapMs);
const connectedZoomDurationMsRef = useRef(connectedZoomDurationMs);
const zoomInEasingRef = useRef(zoomInEasing);
const zoomOutEasingRef = useRef(zoomOutEasing);
const connectedZoomEasingRef = useRef(connectedZoomEasing);
const videoReadyRafRef = useRef<number | null>(null);
const cursorOverlayRef = useRef(null);
const cursorEffectsCanvasRef = useRef<HTMLCanvasElement | null>(null);
const cursorTelemetryRef = useRef(cursorTelemetry);
const showCursorRef = useRef(showCursor);
const cursorSizeRef = useRef(cursorSize);
const cursorStyleRef = useRef(cursorStyle);
const cursorSmoothingRef = useRef(cursorSmoothing);
const cursorMotionBlurRef = useRef(cursorMotionBlur);
const cursorClickBounceRef = useRef(cursorClickBounce);
const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration);
const cursorSwayRef = useRef(cursorSway);
const lastEmittedClickTimeMsRef = useRef(-1);
const springScaleRef = useRef<SpringState>(createSpringState(1));
const springXRef = useRef<SpringState>(createSpringState(0));
const springYRef = useRef<SpringState>(createSpringState(0));
const lastTickTimeRef = useRef<number | null>(null);
const zoomSmoothnessRef = useRef(zoomSmoothness);
const zoomClassicModeRef = useRef(zoomClassicMode);
const cursorFollowCameraRef = useRef<CursorFollowCameraState>(createCursorFollowCameraState());
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const refsRef = useRef<VideoPlaybackRuntimeRefs | null>(null);
if (!refsRef.current) {
refsRef.current = {
videoRef,
containerRef,
appRef,
videoSpriteRef,
videoContainerRef,
cursorContainerRef,
cameraContainerRef,
timeUpdateAnimationRef,
overlayRef,
focusIndicatorRef,
webcamVideoRef,
webcamBubbleRef,
webcamBubbleInnerRef,
captionBoxRef,
currentTimeRef,
zoomRegionsRef,
selectedZoomIdRef,
animationStateRef,
blurFilterRef,
motionBlurFilterRef,
isDraggingFocusRef,
stageSizeRef,
videoSizeRef,
baseScaleRef,
baseOffsetRef,
baseMaskRef,
cropBoundsRef,
maskGraphicsRef,
frameSpriteRef,
frameContainerRef,
frameIdRef,
isPlayingRef,
isSeekingRef,
allowPlaybackRef,
lockedVideoDimensionsRef,
layoutVideoContentRef,
trimRegionsRef,
speedRegionsRef,
lastWebcamSyncTimeRef,
bgVideoRef,
zoomMotionBlurRef,
connectZoomsRef,
zoomInDurationMsRef,
zoomInOverlapMsRef,
zoomOutDurationMsRef,
connectedZoomGapMsRef,
connectedZoomDurationMsRef,
zoomInEasingRef,
zoomOutEasingRef,
connectedZoomEasingRef,
videoReadyRafRef,
cursorOverlayRef,
cursorEffectsCanvasRef,
cursorTelemetryRef,
showCursorRef,
cursorSizeRef,
cursorStyleRef,
cursorSmoothingRef,
cursorMotionBlurRef,
cursorClickBounceRef,
cursorClickBounceDurationRef,
cursorSwayRef,
lastEmittedClickTimeMsRef,
springScaleRef,
springXRef,
springYRef,
lastTickTimeRef,
zoomSmoothnessRef,
zoomClassicModeRef,
cursorFollowCameraRef,
motionBlurStateRef,
};
}
const refs = refsRef.current;
return {
refs,
@@ -296,6 +296,7 @@ export function useVideoPlaybackSync({
const videoStage = refs.videoContainerRef.current;
const sprite = refs.videoSpriteRef.current;
const currentApp = refs.appRef.current;
const layout = refs.layoutVideoContentRef.current;
if (!container || !videoStage || !sprite || !currentApp) return;
container.scale.set(1);
@@ -305,7 +306,7 @@ export function useVideoPlaybackSync({
sprite.scale.set(1);
sprite.position.set(0, 0);
layoutVideoContent();
layout?.();
requestAnimationFrame(() => {
const finalApp = refs.appRef.current;
@@ -317,7 +318,12 @@ export function useVideoPlaybackSync({
}
});
});
}, [layoutVideoContent, pixiReady, refs, videoReady]);
}, [pixiReady, refs, videoPath, videoReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
layoutVideoContent();
}, [layoutVideoContent, pixiReady, videoReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;