diff --git a/clients/desktop/src-tauri/crates/lookout-core/src/api.rs b/clients/desktop/src-tauri/crates/lookout-core/src/api.rs index eb2604b2..88981b2e 100644 --- a/clients/desktop/src-tauri/crates/lookout-core/src/api.rs +++ b/clients/desktop/src-tauri/crates/lookout-core/src/api.rs @@ -192,13 +192,21 @@ impl Core { self.api_get(session_url(s, "/units")).await } - /// `PUT /api/sessions/:token/cuts` — replace the cut list. `cuts` is the - /// JSON array of `CutInterval`s; `[]` clears all edits. - pub async fn session_set_cuts(&self, s: &SessionConfig, cuts: Value) -> ApiResult { + /// `PUT /api/sessions/:token/cuts` — replace the cut and mask lists. `cuts` is the + /// JSON array of `CutInterval`s; `masks` is the JSON array of `MaskRegion`s. + pub async fn session_set_cuts( + &self, + s: &SessionConfig, + cuts: Value, + masks: Option, + ) -> ApiResult { self.api_json( reqwest::Method::PUT, session_url(s, "/cuts"), - Some(serde_json::json!({ "cuts": cuts })), + Some(serde_json::json!({ + "cuts": cuts, + "masks": masks.unwrap_or_else(|| serde_json::json!([])), + })), API_TIMEOUT, ) .await @@ -632,12 +640,31 @@ mod tests { assert_eq!(r["target"], "/api/sessions/tok/name"); assert_eq!(r["body"], r#"{"name":"My cut"}"#); let c = core - .session_set_cuts(&cfg(&base), serde_json::json!([{ "start": 1, "end": 2 }])) + .session_set_cuts( + &cfg(&base), + serde_json::json!([{ "start": 1, "end": 2 }]), + None, + ) .await .unwrap(); assert_eq!(c["method"], "PUT"); assert_eq!(c["target"], "/api/sessions/tok/cuts"); - assert_eq!(c["body"], r#"{"cuts":[{"end":2,"start":1}]}"#); + assert_eq!(c["body"], r#"{"cuts":[{"end":2,"start":1}],"masks":[]}"#); + + let c_masks = core + .session_set_cuts( + &cfg(&base), + serde_json::json!([{ "start": 1, "end": 2 }]), + Some(serde_json::json!([{ "x": 0.1, "y": 0.2 }])), + ) + .await + .unwrap(); + assert_eq!(c_masks["method"], "PUT"); + assert_eq!(c_masks["target"], "/api/sessions/tok/cuts"); + assert_eq!( + c_masks["body"], + r#"{"cuts":[{"end":2,"start":1}],"masks":[{"x":0.1,"y":0.2}]}"# + ); } #[tokio::test] diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 782d1422..69383c5f 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -640,9 +640,13 @@ async fn api_session_set_cuts( token: String, api_base_url: String, cuts: Value, + masks: Option, state: State<'_, AppState>, ) -> Result { - state.core.session_set_cuts(&session_ref(token, api_base_url), cuts).await + state + .core + .session_set_cuts(&session_ref(token, api_base_url), cuts, masks) + .await } #[tauri::command] diff --git a/clients/desktop/src/api/tauriClient.ts b/clients/desktop/src/api/tauriClient.ts index 532864ce..1a5194c1 100644 --- a/clients/desktop/src/api/tauriClient.ts +++ b/clients/desktop/src/api/tauriClient.ts @@ -21,6 +21,7 @@ import type { ConfirmScreenshotResponse, CutInterval, EditHeartbeatResponse, + MaskRegion, PauseResponse, RenameSessionResponse, ResumeResponse, @@ -178,10 +179,11 @@ export function createTauriLookoutClient({ return call("api_session_units", await session()); }, - async setCuts(cuts: CutInterval[]) { + async setCuts(cuts: CutInterval[], masks?: MaskRegion[]) { return call("api_session_set_cuts", { ...(await session()), cuts, + masks: masks ?? [], }); }, diff --git a/clients/desktop/src/components/EditorWindow.test.ts b/clients/desktop/src/components/EditorWindow.test.ts new file mode 100644 index 00000000..be3d4e8f --- /dev/null +++ b/clients/desktop/src/components/EditorWindow.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { buildClosePromptMessage } from "./EditorWindow.js"; + +describe("buildClosePromptMessage", () => { + it("reports single cut when dirty", () => { + expect(buildClosePromptMessage(1, 0, true)).toBe( + "Closing publishes this timelapse with 1 cut applied. This can't be undone.", + ); + }); + + it("reports multiple cuts when dirty", () => { + expect(buildClosePromptMessage(3, 0, true)).toBe( + "Closing publishes this timelapse with 3 cuts applied. This can't be undone.", + ); + }); + + it("reports single mask when dirty", () => { + expect(buildClosePromptMessage(0, 1, true)).toBe( + "Closing publishes this timelapse with 1 mask applied. This can't be undone.", + ); + }); + + it("reports multiple masks when dirty", () => { + expect(buildClosePromptMessage(0, 2, true)).toBe( + "Closing publishes this timelapse with 2 masks applied. This can't be undone.", + ); + }); + + it("reports both cuts and masks when dirty", () => { + expect(buildClosePromptMessage(2, 3, true)).toBe( + "Closing publishes this timelapse with 2 cuts and 3 masks applied. This can't be undone.", + ); + expect(buildClosePromptMessage(1, 1, true)).toBe( + "Closing publishes this timelapse with 1 cut and 1 mask applied. This can't be undone.", + ); + }); + + it("reports published as recorded when cuts and masks are zero", () => { + expect(buildClosePromptMessage(0, 0, true)).toBe( + "Closing publishes this timelapse as recorded. This can't be undone.", + ); + }); + + it("reports published as recorded when not dirty", () => { + expect(buildClosePromptMessage(2, 2, false)).toBe( + "Closing publishes this timelapse as recorded. This can't be undone.", + ); + expect(buildClosePromptMessage(0, 0, false)).toBe( + "Closing publishes this timelapse as recorded. This can't be undone.", + ); + }); +}); diff --git a/clients/desktop/src/components/EditorWindow.tsx b/clients/desktop/src/components/EditorWindow.tsx index 00934ff5..6636297a 100644 --- a/clients/desktop/src/components/EditorWindow.tsx +++ b/clients/desktop/src/components/EditorWindow.tsx @@ -3,7 +3,7 @@ import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { confirm } from "@tauri-apps/plugin-dialog"; -import type { CutInterval } from "@lookout/react"; +import type { CutInterval, MaskRegion } from "@lookout/react"; import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react"; import { invoke } from "../logger.js"; import { getApiBase } from "../serverConfig.js"; @@ -243,6 +243,21 @@ export function useEditorWindowOpen(): string | null { * be pixel-matched against a position that varies by OS version, and it * was visibly off. */ +export function buildClosePromptMessage( + cutsCount: number, + masksCount: number, + isDirty: boolean, +): string { + const parts: string[] = []; + if (cutsCount > 0) + parts.push(`${cutsCount} cut${cutsCount === 1 ? "" : "s"}`); + if (masksCount > 0) + parts.push(`${masksCount} mask${masksCount === 1 ? "" : "s"}`); + if (isDirty && parts.length > 0) + return `Closing publishes this timelapse with ${parts.join(" and ")} applied. This can't be undone.`; + return "Closing publishes this timelapse as recorded. This can't be undone."; +} + export function EditorWindow({ token }: { token: string }) { const isMacOS = navigator.userAgent.includes("Mac"); // Undecorated on Linux, same as the main window, so it owns its corners and @@ -311,63 +326,54 @@ export function EditorWindow({ token }: { token: string }) { // the session is unpublished until someone decides, so an editor that // could be dismissed without deciding would just strand it until the // lease lapsed. Hence: confirm, publish, then close. - const cutsRef = useRef([]); - const dirtyRef = useRef(false); - const finishedRef = useRef(false); + const cutsRef = useRef([]); + const masksRef = useRef([]); + const cutsDirtyRef = useRef(false); + const masksDirtyRef = useRef(false); + const finishedRef = useRef(false); - const finishAndClose = useCallback(async () => { - finishedRef.current = true; - let published: Awaited> | null = null; - try { - await client.setCuts(cutsRef.current); - published = await client.applyCuts(); - } catch (e) { - console.error("[editor] publish on close failed:", e); - // Don't trap the user in a window they asked to close: the hold - // lapses on its own and publishes as recorded shortly after. - } - // Fire-and-forget: the close must not wait on the notification. Carry - // the publish result so the main window can fire the redirect the - // instant it's done (`complete`) or watch the compile to completion. - emit(EDITED_EVENT, { - token, - status: published?.status ?? null, - redirectUrl: published?.redirectUrl ?? null, - // Forwarded so the main window's edit-published handler can pick between - // panel and redirect the same way `handleCompleted` does. Without these, - // a session that has BOTH ends up with the redirect firing (guard sees - // null panelUrl) *and* the panel opening from the session page's own - // status poll — the reporter's "browser and sheet both opened" symptom. - panelUrl: published?.panelUrl ?? null, - panelResolved: published?.panelResolved ?? false, - }).catch((e) => console.error("[editor] emit failed:", e)); - await closeEditorWindow(); - }, [client, token]); + const finishAndClose = useCallback(async () => { + finishedRef.current = true; + let published: Awaited> | null = null; + try { + await client.setCuts(cutsRef.current, masksRef.current); + published = await client.applyCuts(); + } + catch (e) { + console.error("[editor] publish on close failed:", e); + } + emit(EDITED_EVENT, { + token, + status: published?.status ?? null, + redirectUrl: published?.redirectUrl ?? null, + panelUrl: published?.panelUrl ?? null, + panelResolved: published?.panelResolved ?? false, + }).catch((e) => console.error("[editor] emit failed:", e)); + await closeEditorWindow(); + }, [client, token]); - useEffect(() => { - let unlisten: (() => void) | undefined; - void getCurrentWindow() - .onCloseRequested(async (event) => { - if (finishedRef.current) return; - event.preventDefault(); - const removed = cutsRef.current.length; - const ok = await confirm( - dirtyRef.current && removed > 0 - ? `Closing publishes this timelapse with ${removed} cut${ - removed === 1 ? "" : "s" - } applied. This can't be undone.` - : "Closing publishes this timelapse as recorded. This can't be undone.", - { title: "Finish timelapse?", kind: "warning" }, - ); - if (ok) void finishAndClose(); - }) - .then((fn) => { - unlisten = fn; - }); - return () => { - if (unlisten) unlisten(); - }; - }, [finishAndClose]); + useEffect(() => { + let unlisten: (() => void) | undefined; + void getCurrentWindow() + .onCloseRequested(async (event) => { + if (finishedRef.current) return; + event.preventDefault(); + const cutsCount = cutsRef.current.length; + const masksCount = masksRef.current.length; + const isDirty = cutsDirtyRef.current || masksDirtyRef.current; + const ok = await confirm( + buildClosePromptMessage(cutsCount, masksCount, isDirty), + { title: "Finish timelapse?", kind: "warning" }, + ); + if (ok) void finishAndClose(); + }) + .then((fn) => { + unlisten = fn; + }); + return () => { + if (unlisten) unlisten(); + }; + }, [finishAndClose]); return (
{ - cutsRef.current = cuts; - dirtyRef.current = dirty; - }} - onApplied={(result) => { - // Saved from inside the editor. Flag it first so the close - // handler doesn't prompt to publish what's already published. - finishedRef.current = true; - emit(EDITED_EVENT, { - token, - status: result.status, - redirectUrl: result.redirectUrl, - panelUrl: result.panelUrl ?? null, - panelResolved: result.panelResolved ?? false, - }).catch((e) => console.error("[editor] emit failed:", e)); - void closeEditorWindow(); - }} + token={token} + apiBaseUrl={getApiBase()} + client={client} + onCancel={() => void closeEditorWindow()} + onCutsChange={(cuts, dirty) => { + cutsRef.current = cuts; + cutsDirtyRef.current = dirty; + }} + onMasksChange={(masks, dirty) => { + masksRef.current = masks; + masksDirtyRef.current = dirty; + }} + onApplied={(result) => { + finishedRef.current = true; + emit(EDITED_EVENT, { + token, + status: result.status, + redirectUrl: result.redirectUrl, + panelUrl: result.panelUrl ?? null, + panelResolved: result.panelResolved ?? false, + }).catch((e) => console.error("[editor] emit failed:", e)); + void closeEditorWindow(); + }} />
diff --git a/clients/desktop/src/testSetup.ts b/clients/desktop/src/testSetup.ts new file mode 100644 index 00000000..2f80da1a --- /dev/null +++ b/clients/desktop/src/testSetup.ts @@ -0,0 +1,17 @@ +class MemoryStorage implements Storage { + private store = new Map(); + get length(): number { return this.store.size; } + clear(): void { this.store.clear(); } + getItem(key: string): string | null { return this.store.get(key) ?? null; } + key(index: number): string | null { return Array.from(this.store.keys())[index] ?? null; } + removeItem(key: string): void { this.store.delete(key); } + setItem(key: string, value: string): void { this.store.set(key, String(value)); } +} + +if (!globalThis.localStorage || typeof globalThis.localStorage.clear !== "function") { + Object.defineProperty(globalThis, "localStorage", { + value: new MemoryStorage(), + writable: true, + configurable: true, + }); +} diff --git a/clients/desktop/vitest.config.ts b/clients/desktop/vitest.config.ts index 64b2d4ef..4651e28f 100644 --- a/clients/desktop/vitest.config.ts +++ b/clients/desktop/vitest.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ // resolves the interop. Nothing here is stubbed out — the real modules // load, so a test importing more of the shared package still gets it. server: { deps: { inline: [/@squircle-js/, /@lookout\/react/] } }, + setupFiles: ["./src/testSetup.ts"], }, }); diff --git a/clients/playground/src/App.tsx b/clients/playground/src/App.tsx index a75a1275..58e61992 100644 --- a/clients/playground/src/App.tsx +++ b/clients/playground/src/App.tsx @@ -12,6 +12,7 @@ import { radii, spacing, type CutInterval, + type MaskRegion, } from "@lookout/react"; /** @@ -37,7 +38,10 @@ interface Settings { function loadSettings(): Settings { try { const raw = localStorage.getItem(LS_KEY); - if (raw) return JSON.parse(raw) as Settings; + if (raw) { + const parsed = JSON.parse(raw) as Settings; + if (parsed.token) return parsed; + } } catch { // Fall through to defaults. } @@ -56,6 +60,7 @@ export function App() { }); const [tab, setTab] = useState("editor"); const [cuts, setCuts] = useState([]); + const [masks, setMasks] = useState([]); // Mirrors what does, so the editor and // both dialogs can be checked against a brand colour without wiring a @@ -95,6 +100,7 @@ export function App() { key={applied.token} settings={applied} onCuts={setCuts} + onMasks={setMasks} /> )} {tab === "detail" && ( @@ -105,7 +111,7 @@ export function App() { /> )} - + )} @@ -255,9 +261,11 @@ function Empty() { function ResizableEditor({ settings, onCuts, + onMasks, }: { settings: Settings; onCuts: (cuts: CutInterval[]) => void; + onMasks: (masks: MaskRegion[]) => void; }) { const [size, setSize] = useState({ w: 900, h: 620 }); const presets: Array<[string, number, number]> = [ @@ -315,6 +323,10 @@ function ResizableEditor({ onCuts(cuts); console.log("[playground] cuts", { dirty, cuts }); }} + onMasksChange={(masks, dirty) => { + onMasks(masks); + console.log("[playground] masks", { dirty, masks }); + }} /> @@ -332,9 +344,11 @@ function ResizableEditor({ function ServerTruth({ settings, cuts, + masks, }: { settings: Settings; cuts: CutInterval[]; + masks: MaskRegion[]; }) { const [status, setStatus] = useState | null>(null); const [units, setUnits] = useState | null>(null); @@ -405,11 +419,11 @@ function ServerTruth({ }; }, [settings.apiBaseUrl, settings.token, loadUnits]); - // Dry-run the cut list the editor most recently reported, so the + // Dry-run the cut and mask list the editor most recently reported, so the // server's own arithmetic sits next to the editor's footer. - const dryRun = useCallback(async (cuts: CutInterval[]) => { + const dryRun = useCallback(async (cuts: CutInterval[], masks: MaskRegion[]) => { try { - setPreview({ ...(await clientRef.current.setCuts(cuts)) }); + setPreview({ ...(await clientRef.current.setCuts(cuts, masks)) }); } catch (e) { setPreview({ error: e instanceof Error ? e.message : String(e) }); } @@ -478,7 +492,7 @@ function ServerTruth({
Sends the editor's current list and shows what the server counts. diff --git a/clients/react/src/api/client.test.ts b/clients/react/src/api/client.test.ts new file mode 100644 index 00000000..ab40810a --- /dev/null +++ b/clients/react/src/api/client.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLookoutClient } from "./client.js"; + +describe("uploadToR2 URL validation", () => { + const client = createLookoutClient({ + baseUrl: "http://localhost:3000", + token: "tok", + }); + + const dummyBlob = new Blob(["test"], { type: "image/jpeg" }); + + it("rejects attacker domains disguised as localhost", async () => { + await expect( + client.uploadToR2("http://localhost.attacker.com/upload", dummyBlob), + ).rejects.toThrow("Invalid upload URL: must be HTTPS or a relative path."); + }); + + it("rejects attacker domains disguised as 127.0.0.1", async () => { + await expect( + client.uploadToR2("http://127.0.0.1.attacker.com/upload", dummyBlob), + ).rejects.toThrow("Invalid upload URL: must be HTTPS or a relative path."); + }); + + it("rejects protocol-relative URLs pointing to external hosts", async () => { + await expect( + client.uploadToR2("//localhost.attacker.com/upload", dummyBlob), + ).rejects.toThrow("Invalid upload URL: must be HTTPS or a relative path."); + }); + + it("rejects plain HTTP non-local URLs", async () => { + await expect( + client.uploadToR2("http://example.com/upload", dummyBlob), + ).rejects.toThrow("Invalid upload URL: must be HTTPS or a relative path."); + }); + + it("rejects non-HTTP schemes", async () => { + await expect( + client.uploadToR2("ftp://localhost/upload", dummyBlob), + ).rejects.toThrow("Invalid upload URL: must be HTTPS or a relative path."); + }); + + it("allows localhost, 127.0.0.1, https, and relative URLs", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + + await expect(client.uploadToR2("http://localhost:3000/upload", dummyBlob)).resolves.toBeUndefined(); + await expect(client.uploadToR2("http://127.0.0.1:3000/upload", dummyBlob)).resolves.toBeUndefined(); + await expect(client.uploadToR2("https://r2.example.com/upload", dummyBlob)).resolves.toBeUndefined(); + await expect(client.uploadToR2("/api/upload", dummyBlob)).resolves.toBeUndefined(); + + fetchSpy.mockRestore(); + }); +}); diff --git a/clients/react/src/api/client.ts b/clients/react/src/api/client.ts index fec6d634..4827a625 100644 --- a/clients/react/src/api/client.ts +++ b/clients/react/src/api/client.ts @@ -16,6 +16,7 @@ import type { ApplyCutsResponse, EditHeartbeatResponse, CutInterval, + MaskRegion, } from "@lookout/shared"; import type { TokenProvider } from "../types.js"; @@ -49,9 +50,9 @@ export interface LookoutClient { * wall clock), current cut list, and a token-gated presigned URL for the * UNCUT original video. */ getUnits(): Promise; - /** Replace the session's cut list (full replace; [] clears all edits). + /** Replace the session's cut and mask lists. * Returns the normalized list plus a server-authoritative preview. */ - setCuts(cuts: CutInterval[]): Promise; + setCuts(cuts: CutInterval[], masks?: MaskRegion[]): Promise; /** Apply the current cut list to the published video (a cut-compile — * usually a lossless stream copy, seconds not minutes). */ applyCuts(): Promise; @@ -180,9 +181,22 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient }, async uploadToR2(uploadUrl, blob, contentType = "image/jpeg") { - if (!uploadUrl.startsWith("https://") && !uploadUrl.startsWith("/")) { - throw new Error("Invalid upload URL: must be HTTPS or a relative path."); - } + let isAllowed = false; + if (uploadUrl.startsWith("/") && !uploadUrl.startsWith("//")) { + isAllowed = true; + } + else { + try { + const parsed = new URL(uploadUrl); + const isLocal = (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") && (parsed.protocol === "http:" || parsed.protocol === "https:"); + isAllowed = parsed.protocol === "https:" || isLocal; + } + catch { + isAllowed = false; + } + } + if (!isAllowed) + throw new Error("Invalid upload URL: must be HTTPS or a relative path."); let res: Response; try { res = await fetch(uploadUrl, { @@ -281,10 +295,10 @@ export function createLookoutClient(options: CreateClientOptions): LookoutClient return fetchJson(await sessionUrl("/units")); }, - async setCuts(cuts) { + async setCuts(cuts, masks) { return fetchJson(await sessionUrl("/cuts"), { method: "PUT", - body: JSON.stringify({ cuts }), + body: JSON.stringify({ cuts, ...(masks !== undefined ? { masks } : {}) }), }); }, diff --git a/clients/react/src/components/ResultView.test.tsx b/clients/react/src/components/ResultView.test.tsx new file mode 100644 index 00000000..e3581749 --- /dev/null +++ b/clients/react/src/components/ResultView.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { ResultView } from "./ResultView.js"; +import { LookoutProvider } from "../LookoutProvider.js"; +import type { LookoutClient } from "../api/client.js"; + +vi.mock("./VideoPlayer.js", () => ({ + VideoPlayer: ({ src }: { src: string }) =>
{src}
, +})); + +vi.mock("@squircle-js/react", () => ({ + Squircle: ({ children }: { children: React.ReactNode }) => children, +})); + +afterEach(cleanup); + +function createMockClient(videoUrl: string): LookoutClient { + return { + resolveToken: async () => "token", + getSession: vi.fn(), + getUploadUrl: vi.fn(), + confirmScreenshot: vi.fn(), + uploadToR2: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + stop: vi.fn(), + rename: vi.fn(), + getStatus: vi.fn(), + getVideo: vi.fn().mockResolvedValue({ videoUrl }), + getUnits: vi.fn(), + setCuts: vi.fn(), + applyCuts: vi.fn(), + heartbeatEditing: vi.fn(), + }; +} + +describe(" URL validation", () => { + it("rejects attacker domain disguised as localhost", async () => { + const onComplete = vi.fn(); + const client = createMockClient("http://localhost.attacker.com/video.mp4"); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("No video available")).toBeTruthy(); + }); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it("rejects attacker domain disguised as 127.0.0.1", async () => { + const onComplete = vi.fn(); + const client = createMockClient("http://127.0.0.1.attacker.com/video.mp4"); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("No video available")).toBeTruthy(); + }); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it("accepts valid https URL", async () => { + const onComplete = vi.fn(); + const client = createMockClient("https://example.com/video.mp4"); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("video-player")).toBeTruthy(); + }); + expect(onComplete).toHaveBeenCalledWith({ videoUrl: "https://example.com/video.mp4" }); + }); + + it("accepts valid localhost URL", async () => { + const onComplete = vi.fn(); + const client = createMockClient("http://localhost:3000/video.mp4"); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("video-player")).toBeTruthy(); + }); + expect(onComplete).toHaveBeenCalledWith({ videoUrl: "http://localhost:3000/video.mp4" }); + }); +}); diff --git a/clients/react/src/components/ResultView.tsx b/clients/react/src/components/ResultView.tsx index 99cbe6ae..776d7edc 100644 --- a/clients/react/src/components/ResultView.tsx +++ b/clients/react/src/components/ResultView.tsx @@ -18,10 +18,20 @@ export function ResultView({ status, trackedSeconds }: ResultViewProps) { client .getVideo() .then((data) => { - if (data.videoUrl && !data.videoUrl.startsWith("https://")) { - throw new Error("Invalid video URL: must be HTTPS."); - } - setVideoUrl(data.videoUrl); + if (data.videoUrl) { + let isAllowed = false; + try { + const parsed = new URL(data.videoUrl); + const isLocal = (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") && (parsed.protocol === "http:" || parsed.protocol === "https:"); + isAllowed = parsed.protocol === "https:" || isLocal; + } + catch { + isAllowed = false; + } + if (!isAllowed) + throw new Error("Invalid video URL: must be HTTPS."); + } + setVideoUrl(data.videoUrl); config.callbacks.onComplete?.({ videoUrl: data.videoUrl }); }) .catch((err) => diff --git a/clients/react/src/components/TimelapseEditor.tsx b/clients/react/src/components/TimelapseEditor.tsx index ecd6ff84..1258c302 100644 --- a/clients/react/src/components/TimelapseEditor.tsx +++ b/clients/react/src/components/TimelapseEditor.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -9,8 +10,10 @@ import { AnimatePresence, motion } from "motion/react"; import { countCutUnits, type ApplyCutsResponse, + type MaskRegion, type CutInterval, type UnitsResponse, + type VideoShot, } from "@lookout/shared"; import { createLookoutClient, type LookoutClient } from "../api/client.js"; import { @@ -20,11 +23,24 @@ import { regionAtTime, regionsToCuts, elapsedLabel, + shotRulerLabel, rulerStep, rulerTicks, unitAtTime, unitClockLabel, + unitMasksToMasks, + masksToUnitMasks, + findShotAtTime, + snapToNearestShotBoundary, + assignMaskTracks, + canAddMaskAtTime, + activeMasksAtUnit, + isMaskActiveAtTime, + computeSafeCursorTime, + TRACK_PRESETS, + MAX_MASK_TRACKS, type UnitRegion, + type UnitMaskRegion, } from "../hooks/editorMath.js"; import { openDecoderFrames, @@ -42,6 +58,7 @@ import { import { injectEditorStyles } from "./editorStyles.js"; import { Button } from "../ui/Button.js"; import { MinutesFlow } from "../ui/MinutesFlow.js"; +import NumberFlow from "@number-flow/react"; import { Spinner } from "../ui/Spinner.js"; import { ProgressRing } from "../ui/ProgressRing.js"; import { ErrorDisplay } from "../ui/ErrorDisplay.js"; @@ -66,6 +83,10 @@ export interface TimelapseEditorProps { * whether it differs from what's saved. Lets a host (the desktop * window) publish the current edit when the user closes it. */ onCutsChange?: (cuts: CutInterval[], dirty: boolean) => void; + /** Fired whenever the mask list changes. */ + onMasksChange?: (masks: MaskRegion[], dirty: boolean) => void; + /** Alias for onMasksChange. */ + onBlursChange?: (masks: MaskRegion[], dirty: boolean) => void; } const STRIP_HEIGHT = 56; @@ -111,6 +132,13 @@ type DragState = grabOffset: number; anchorUnit: number; } + | { + kind: "mask"; + id: string; + mode: "move" | "start" | "end"; + grabOffset: number; + initialWidth: number; + } | null; /** @@ -132,6 +160,8 @@ export function TimelapseEditor({ onApplied, onCancel, onCutsChange, + onMasksChange, + onBlursChange, }: TimelapseEditorProps) { const client = useMemo( () => clientProp ?? createLookoutClient({ baseUrl: apiBaseUrl, token }), @@ -159,19 +189,108 @@ export function TimelapseEditor({ /** Anchored once per preparing spell, so even a genuine change in the * unit count can't restart the estimate. */ const prepareStartRef = useRef(null); + const videoRef = useRef(null); + const videoBoxRef = useRef(null); + const overlayRef = useRef(null); + const maskTrackRef = useRef(null); + const timelineRef = useRef(null); + const timelineScrollRef = useRef(null); + const timelineAreaRef = useRef(null); + const maskMenuRef = useRef(null); + const containerRef = useRef(null); + const activeScanTokenRef = useRef(0); + const probeVideoRef = useRef(null); + + const [mode, setMode] = useState<"cut" | "mask">("cut"); const [regions, setRegions] = useState([]); const [selected, setSelected] = useState(null); + const [masks, setMasks] = useState([]); + const [selectedMaskId, setSelectedMaskId] = useState(null); + const [drawingMask, setDrawingMask] = useState<{ + startX: number; + startY: number; + currentX: number; + currentY: number; + } | null>(null); + const [maskDrag, setMaskDrag] = useState<{ + id: string; + handle: "move" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; + startX: number; + startY: number; + initial: UnitMaskRegion; + } | null>(null); + const [maskMenuOpen, setMaskMenuOpen] = useState(false); const [time, setTime] = useState(0); const [playing, setPlaying] = useState(false); const [filmstrip, setFilmstrip] = useState([]); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + const [expandingMaskId, setExpandingMaskId] = useState(null); + const [dynamicShots, setDynamicShots] = useState(null); + const dynamicShotsCacheRef = useRef>(new Map()); + const [maskLimitNotice, setMaskLimitNotice] = useState(null); + const [videoAspect, setVideoAspect] = useState(null); + const [zoom, setZoom] = useState(1); + const zoomAnchorRef = useRef<{ trackFrac: number; anchorXInViewport: number } | null>(null); + const [isSmoothSeek, setIsSmoothSeek] = useState(false); + const smoothSeekTimerRef = useRef | null>(null); + const [isTimelineHovered, setIsTimelineHovered] = useState(false); + + const activeShots = useMemo( + () => (dynamicShots && dynamicShots.length > 0 ? dynamicShots : (data?.shots ?? [])), + [dynamicShots, data?.shots], + ); - const videoRef = useRef(null); - const timelineRef = useRef(null); + const trackAllocation = useMemo(() => assignMaskTracks(masks), [masks]); + + const drawingTrackIdx = useMemo(() => { + if (!drawingMask) return 0; + const curTime = videoRef.current?.currentTime ?? time; + const active = activeMasksAtUnit(curTime, masks); + const usedTracks = new Set(active.map((b) => trackAllocation.assignments[b.id])); + for (let t = 0; t < MAX_MASK_TRACKS; t++) { + if (!usedTracks.has(t)) return t; + } + return 0; + }, [drawingMask, masks, trackAllocation, time]); + + const [containerSize, setContainerSize] = useState({ width: 900, height: 620 }); + + useEffect(() => { + const el = containerRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(([entry]) => { + if (!entry) return; + const { width, height } = entry.contentRect; + setContainerSize({ width, height }); + }); + ro.observe(el); + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setContainerSize({ width: rect.width, height: rect.height }); + } + return () => ro.disconnect(); + }, []); + + const isVeryShort = containerSize.height <= 400; + const isShort = containerSize.height <= 500; + const isNarrow = containerSize.width <= 540; + const isTinyWidth = containerSize.width <= 440; + + const stripHeight = isVeryShort ? 32 : isShort ? 40 : STRIP_HEIGHT; + const rulerHeight = isShort ? 18 : RULER_HEIGHT; + const maskTrackHeight = isVeryShort ? 20 : isShort ? 22 : 26; + const rootGap = isShort ? 6 : spacing.md; + const dockGap = isShort ? 6 : spacing.sm; + const playOverlaySize = isVeryShort ? 36 : isShort ? 44 : 56; + const zoomRef = useRef(zoom); + zoomRef.current = zoom; const dragRef = useRef(null); const regionsRef = useRef(regions); regionsRef.current = regions; + const masksRef = useRef(masks); + masksRef.current = masks; const rafRef = useRef(0); const units = data?.units ?? []; @@ -210,6 +329,7 @@ export function TimelapseEditor({ setPreparingUnits(null); setData(res); setRegions(cutsToRegions(res.cuts, res.units)); + setMasks(masksToUnitMasks(res.masks ?? (res as any).blurs ?? [], res.units)); return; } if (res.editableReason === "preparing" || res.editableReason === "no_original") { @@ -319,13 +439,22 @@ export function TimelapseEditor({ // it renews the lease while mounted. No countdown, no deadline to race: // the session waits as long as the window is up, and publishes on its // own shortly after it isn't. Stops once the session is no longer held. - const leaseHeld = useEditLease(client, !saving); + const leaseHeld = useEditLease(client, !saving && !saveSuccess); useEffect(() => { - if (leaseHeld || saving) return; + if (leaseHeld || saving || saveSuccess) return; setLoadError( "This timelapse was already published, so it can no longer be edited.", ); - }, [leaseHeld, saving]); + }, [leaseHeld, saving, saveSuccess]); + + const prevEditSig = useRef(""); + useEffect(() => { + const sig = JSON.stringify({ regions, masks }); + if (prevEditSig.current && prevEditSig.current !== sig) { + setSaveSuccess(false); + } + prevEditSig.current = sig; + }, [regions, masks]); // ── Playhead tracking (rAF for a smooth 60fps playhead) ───── useEffect(() => { @@ -357,6 +486,21 @@ export function TimelapseEditor({ return () => v.removeEventListener("timeupdate", onTimeUpdate); }, [unitCount, data?.originalVideoUrl]); + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const updateAspect = () => { + if (v.videoWidth && v.videoHeight) { + setVideoAspect(v.videoWidth / v.videoHeight); + } + }; + v.addEventListener("loadedmetadata", updateAspect); + if (v.videoWidth && v.videoHeight) { + updateAspect(); + } + return () => v.removeEventListener("loadedmetadata", updateAspect); + }, [data?.originalVideoUrl]); + // ── Filmstrip frame source ────────────────────────────────── // // Getting pixels out of the preview has two independent problems, and @@ -389,6 +533,30 @@ export function TimelapseEditor({ previewBytesRef.current = null; }, [data?.originalVideoUrl]); + useEffect(() => { + const videoSrc = data?.originalVideoUrl; + if (!videoSrc) { + if (probeVideoRef.current) { + probeVideoRef.current.removeAttribute("src"); + probeVideoRef.current.load(); + probeVideoRef.current = null; + } + return; + } + const v = document.createElement("video"); + if (!videoSrc.startsWith("blob:")) v.crossOrigin = "anonymous"; + v.muted = true; + v.playsInline = true; + v.preload = "auto"; + v.src = videoSrc; + probeVideoRef.current = v; + return () => { + v.removeAttribute("src"); + v.load(); + probeVideoRef.current = null; + }; + }, [data?.originalVideoUrl]); + // Track width drives the filmstrip: tiles are whole frames at the // video's own aspect ratio, so how many fit is a function of the track, // not of how many minutes were recorded. @@ -402,7 +570,7 @@ export function TimelapseEditor({ ro.observe(el); setStripWidth(el.getBoundingClientRect().width); return () => ro.disconnect(); - }, [data]); + }, [data, zoom]); /** * Filmstrip: whole, uncropped frames tiled across the track — the @@ -562,14 +730,248 @@ export function TimelapseEditor({ ); const seekTo = useCallback( - (t: number) => { + (t: number, smooth: boolean = false) => { const v = videoRef.current; if (!v) return; - v.currentTime = Math.max(0, Math.min(unitCount - 0.05, t)); + const target = Math.max(0, Math.min(unitCount - 0.05, t)); + v.currentTime = target; + setTime(target); + if (smooth) { + if (smoothSeekTimerRef.current) clearTimeout(smoothSeekTimerRef.current); + setIsSmoothSeek(true); + smoothSeekTimerRef.current = setTimeout(() => { + setIsSmoothSeek(false); + }, 320); + } + else { + setIsSmoothSeek(false); + } }, [unitCount], ); + const applyZoom = useCallback( + (newZoom: number, anchorClientX?: number) => { + const clamped = Math.max(1, Math.min(16, Math.round(newZoom * 100) / 100)); + const prevZoom = zoomRef.current; + if (clamped === prevZoom) return; + zoomRef.current = clamped; + + const scrollEl = timelineScrollRef.current; + if (!scrollEl) { + setZoom(clamped); + return; + } + + const rect = scrollEl.getBoundingClientRect(); + let anchorXInViewport = rect.width / 2; + if (anchorClientX !== undefined) { + anchorXInViewport = Math.max(0, Math.min(rect.width, anchorClientX - rect.left)); + } + else { + const totalUnits = Math.max(1, unitCount); + const curPlayheadFrac = Math.min(time, totalUnits) / totalUnits; + const curPlayheadPx = curPlayheadFrac * scrollEl.scrollWidth; + const playheadInView = curPlayheadPx - scrollEl.scrollLeft; + if (playheadInView >= 0 && playheadInView <= rect.width) { + anchorXInViewport = playheadInView; + } + } + + const trackFrac = + (scrollEl.scrollLeft + anchorXInViewport) / Math.max(1, scrollEl.scrollWidth); + + zoomAnchorRef.current = { trackFrac, anchorXInViewport }; + setZoom(clamped); + }, + [unitCount, time], + ); + + useLayoutEffect(() => { + const anchor = zoomAnchorRef.current; + if (!anchor || !timelineScrollRef.current) return; + const scrollEl = timelineScrollRef.current; + const newScrollLeft = anchor.trackFrac * scrollEl.scrollWidth - anchor.anchorXInViewport; + scrollEl.scrollLeft = Math.max(0, newScrollLeft); + zoomAnchorRef.current = null; + }, [zoom]); + + const zoomIn = useCallback(() => { + applyZoom(zoomRef.current * 1.5); + }, [applyZoom]); + + const zoomOut = useCallback(() => { + applyZoom(zoomRef.current / 1.5); + }, [applyZoom]); + + const resetZoom = useCallback(() => { + applyZoom(1); + }, [applyZoom]); + + useEffect(() => { + const target = timelineAreaRef.current || timelineScrollRef.current; + if (!target) return; + + const onWheel = (e: WheelEvent) => { + if (dragRef.current) return; + const scrollEl = timelineScrollRef.current; + if (!scrollEl) return; + + // 1. Shift + scroll: shifts the timeline horizontally left/right + if (e.shiftKey) { + e.preventDefault(); + const shiftDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + scrollEl.scrollLeft += shiftDelta; + return; + } + + // 2. Trackpad pinch in Chrome / Ctrl+scroll / Cmd+scroll: zoom centered on cursor + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const factor = Math.exp(-e.deltaY * 0.01); + applyZoom(zoomRef.current * factor, e.clientX); + return; + } + + // 3. Trackpad horizontal swipe: pan horizontally + if (Math.abs(e.deltaX) > Math.abs(e.deltaY) && Math.abs(e.deltaX) > 1) { + scrollEl.scrollLeft += e.deltaX; + return; + } + + // 4. Mouse vertical scroll wheel: increases or decreases the size + if (Math.abs(e.deltaY) > 0) { + e.preventDefault(); + if (Math.abs(e.deltaY) >= 40 || e.deltaMode !== 0) { + const factor = e.deltaY < 0 ? 1.25 : (1 / 1.25); + applyZoom(zoomRef.current * factor, e.clientX); + } + else { + const factor = Math.exp(-e.deltaY * 0.005); + applyZoom(zoomRef.current * factor, e.clientX); + } + } + }; + + // Safari / WebKit trackpad pinch gesture support + let gestureStartZoom = 1; + + const onGestureStart = (e: Event) => { + if (dragRef.current) return; + e.preventDefault(); + gestureStartZoom = zoomRef.current; + }; + + const onGestureChange = (e: Event) => { + if (dragRef.current) return; + e.preventDefault(); + const ge = e as UIEvent & { scale?: number; clientX?: number }; + if (typeof ge.scale === "number" && ge.scale > 0) { + const targetZoom = gestureStartZoom * ge.scale; + const clientX = typeof ge.clientX === "number" ? ge.clientX : undefined; + applyZoom(targetZoom, clientX); + } + }; + + const onGestureEnd = (e: Event) => { + e.preventDefault(); + }; + + target.addEventListener("wheel", onWheel, { passive: false }); + target.addEventListener("gesturestart", onGestureStart, { passive: false }); + target.addEventListener("gesturechange", onGestureChange, { passive: false }); + target.addEventListener("gestureend", onGestureEnd, { passive: false }); + + return () => { + target.removeEventListener("wheel", onWheel); + target.removeEventListener("gesturestart", onGestureStart); + target.removeEventListener("gesturechange", onGestureChange); + target.removeEventListener("gestureend", onGestureEnd); + }; + }, [applyZoom]); + + useEffect(() => { + if (!playing || zoom <= 1) return; + const scrollEl = timelineScrollRef.current; + if (!scrollEl || unitCount <= 0) return; + + const playheadPx = (time / unitCount) * scrollEl.scrollWidth; + const scrollLeft = scrollEl.scrollLeft; + const clientWidth = scrollEl.clientWidth; + + const margin = clientWidth * 0.15; + if (playheadPx > scrollLeft + clientWidth - margin) { + scrollEl.scrollLeft = playheadPx - clientWidth + margin; + } + else if (playheadPx < scrollLeft + margin) { + scrollEl.scrollLeft = Math.max(0, playheadPx - margin); + } + }, [playing, time, zoom, unitCount]); + + const [scrollProgress, setScrollProgress] = useState(0); + const [isMinimapDragging, setIsMinimapDragging] = useState(false); + const [isMinimapThumbHovered, setIsMinimapThumbHovered] = useState(false); + + useEffect(() => { + const el = timelineScrollRef.current; + if (!el) return; + const onScroll = () => { + const maxScroll = el.scrollWidth - el.clientWidth; + if (maxScroll > 0) { + setScrollProgress(el.scrollLeft / maxScroll); + } + else { + setScrollProgress(0); + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + return () => el.removeEventListener("scroll", onScroll); + }, [zoom, stripWidth]); + + const onMinimapPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + const track = e.currentTarget; + const rect = track.getBoundingClientRect(); + const scrollEl = timelineScrollRef.current; + if (!scrollEl) return; + const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth; + if (maxScroll <= 0) return; + + const currentZoom = zoomRef.current; + const thumbRatio = 1 / currentZoom; + const thumbW = rect.width * thumbRatio; + const usableW = rect.width - thumbW; + if (usableW <= 0) return; + + setIsMinimapDragging(true); + + const updateScroll = (clientX: number) => { + const xInTrack = Math.max(0, Math.min(usableW, clientX - rect.left - thumbW / 2)); + const frac = xInTrack / usableW; + scrollEl.scrollLeft = frac * maxScroll; + setScrollProgress(frac); + }; + + updateScroll(e.clientX); + + let rafId: number | null = null; + const onPointerMove = (moveEv: PointerEvent) => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + updateScroll(moveEv.clientX); + }); + }; + const onPointerUp = () => { + setIsMinimapDragging(false); + if (rafId !== null) cancelAnimationFrame(rafId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + }, []); + const beginDrag = useCallback((e: React.PointerEvent, state: DragState) => { dragRef.current = state; (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); @@ -580,9 +982,14 @@ export function TimelapseEditor({ const onTimelinePointerDown = useCallback( (e: React.PointerEvent) => { if (saving) return; + if (mode === "mask") { + beginDrag(e, { kind: "scrub" }); + seekTo(unitFromEvent(e)); + return; + } beginDrag(e, { kind: "maybe", downUnitF: unitFromEvent(e) }); }, - [beginDrag, saving, unitFromEvent], + [beginDrag, mode, saving, seekTo, unitFromEvent], ); const onRulerPointerDown = useCallback( @@ -590,6 +997,7 @@ export function TimelapseEditor({ if (saving) return; beginDrag(e, { kind: "scrub" }); seekTo(unitFromEvent(e)); + setSelectedMaskId(null); }, [beginDrag, saving, seekTo, unitFromEvent], ); @@ -606,6 +1014,7 @@ export function TimelapseEditor({ } if (drag.kind === "maybe") { + if (mode !== "cut") return; // Click-vs-drag disambiguation: past a third of a unit of travel, // the gesture becomes a new cut region growing from the press point. if (Math.abs(unitF - drag.downUnitF) < 0.34) return; @@ -626,36 +1035,77 @@ export function TimelapseEditor({ return; } - setRegions((prev) => { - const next = prev.map((r) => ({ ...r })); - const r = next[drag.index]; - if (!r) return prev; + if (drag.kind === "region") { + if (mode !== "cut") return; + setRegions((prev) => { + const next = prev.map((r) => ({ ...r })); + const r = next[drag.index]; + if (!r) return prev; + if (drag.mode === "move") { + const width = r.endUnit - r.startUnit; + let start = Math.round(unitF - drag.grabOffset); + start = Math.max(0, Math.min(unitCount - width, start)); + r.startUnit = start; + r.endUnit = start + width; + } else if (drag.mode === "start") { + r.startUnit = Math.max(0, Math.min(r.endUnit - 1, Math.round(unitF))); + seekTo(r.startUnit + 0.02); + } else { + const anchor = drag.anchorUnit; + const rounded = Math.round(unitF); + if (rounded <= anchor) { + r.startUnit = Math.max(0, rounded); + r.endUnit = anchor + 1; + seekTo(r.startUnit + 0.02); + } else { + r.endUnit = Math.min(unitCount, Math.max(r.startUnit + 1, rounded)); + seekTo(Math.min(unitCount - 0.05, r.endUnit + 0.02)); + } + } + return next; + }); + setSelected(drag.index); + return; + } + + if (drag.kind === "mask") { + const id = drag.id; + const m = masksRef.current.find((item) => item.id === id); + if (!m) return; + if (drag.mode === "move") { - const width = r.endUnit - r.startUnit; + const width = drag.initialWidth; let start = Math.round(unitF - drag.grabOffset); start = Math.max(0, Math.min(unitCount - width, start)); - r.startUnit = start; - r.endUnit = start + width; + const end = start + width; + setMasks((prev) => + prev.map((item) => + item.id === id ? { ...item, startUnit: start, endUnit: end } : item, + ), + ); + seekTo(start + 0.01); } else if (drag.mode === "start") { - r.startUnit = Math.max(0, Math.min(r.endUnit - 1, Math.round(unitF))); - seekTo(r.startUnit + 0.02); - } else { - const anchor = drag.anchorUnit; - const rounded = Math.round(unitF); - if (rounded <= anchor) { - r.startUnit = Math.max(0, rounded); - r.endUnit = anchor + 1; - seekTo(r.startUnit + 0.02); - } else { - r.endUnit = Math.min(unitCount, Math.max(r.startUnit + 1, rounded)); - seekTo(Math.min(unitCount - 0.05, r.endUnit + 0.02)); - } + const newStart = Math.max(0, Math.min(m.endUnit - 1, Math.round(unitF))); + setMasks((prev) => + prev.map((item) => + item.id === id ? { ...item, startUnit: newStart } : item, + ), + ); + seekTo(newStart + 0.01); + } else if (drag.mode === "end") { + const newEnd = Math.min(unitCount, Math.max(m.startUnit + 1, Math.round(unitF))); + setMasks((prev) => + prev.map((item) => + item.id === id ? { ...item, endUnit: newEnd } : item, + ), + ); + seekTo(Math.min(unitCount - 0.05, newEnd - 0.01)); } - return next; - }); - setSelected(drag.index); + setSelectedMaskId(id); + return; + } }, - [seekTo, unitCount, unitFromEvent], + [seekTo, unitCount, unitFromEvent, mode, data?.shots], ); const onPointerUp = useCallback(() => { @@ -666,9 +1116,15 @@ export function TimelapseEditor({ // A plain click on open track: seek there and drop any selection. seekTo(drag.downUnitF); setSelected(null); + setSelectedMaskId(null); + return; + } + if (drag.kind === "mask") { + setSelectedMaskId(drag.id); return; } if (drag.kind === "region") { + if (mode !== "cut") return; // Keep the region selected after the gesture. Clearing it here meant // a selection could never outlive the click that made it, so "Remove // cut" was unreachable. Normalizing can merge regions and shift @@ -684,23 +1140,24 @@ export function TimelapseEditor({ : -1; setSelected(idx >= 0 ? idx : null); } - }, [seekTo]); + }, [seekTo, mode]); const onRegionPointerDown = useCallback( - (e: React.PointerEvent, index: number, mode: "move" | "start" | "end") => { - if (saving) return; + (e: React.PointerEvent, index: number, dragMode: "move" | "start" | "end") => { + if (saving || mode !== "cut") return; const r = regionsRef.current[index]; if (!r) return; setSelected(index); + setSelectedMaskId(null); beginDrag(e, { kind: "region", index, - mode, + mode: dragMode, grabOffset: unitFromEvent(e) - r.startUnit, anchorUnit: r.startUnit, }); }, - [beginDrag, saving, unitFromEvent], + [beginDrag, mode, saving, unitFromEvent], ); const togglePlay = useCallback(() => { @@ -716,6 +1173,7 @@ export function TimelapseEditor({ }, [unitCount]); const cutHere = useCallback(() => { + if (mode !== "cut") return; const v = videoRef.current; if (!v || unitCount === 0) return; const at = unitAtTime(v.currentTime, unitCount); @@ -724,121 +1182,936 @@ export function TimelapseEditor({ setSelected(next.findIndex((r) => at >= r.startUnit && at < r.endUnit)); return next; }); - }, [unitCount]); + }, [mode, unitCount]); - // ── Keyboard ──────────────────────────────────────────────── - // Capture phase + preventDefault so hosting apps' global key handlers - // (e.g. the desktop router's Backspace-goes-back) never fire underneath - // an open editor — losing unsaved cuts to a stray Backspace is the worst - // possible outcome of this surface. - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement | null; - if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return; - if (e.metaKey || e.ctrlKey) return; - if (e.key === " " || e.key === "k") { - e.preventDefault(); - togglePlay(); - } else if (e.key === "x" || e.key === "c") { - e.preventDefault(); - cutHere(); - } else if (e.key === "Delete" || e.key === "Backspace") { - e.preventDefault(); - if (selected !== null) { - setRegions((prev) => prev.filter((_, i) => i !== selected)); - setSelected(null); - } - } else if (e.key === "Escape") { - e.preventDefault(); - setSelected(null); - } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - e.preventDefault(); - const step = e.shiftKey ? 10 : 1; - seekTo( - (videoRef.current?.currentTime ?? 0) + - (e.key === "ArrowLeft" ? -step : step), - ); + const onStagePointerDown = useCallback( + (e: React.PointerEvent) => { + if (saving || mode !== "mask") return; + const curTime = videoRef.current?.currentTime ?? 0; + if (!canAddMaskAtTime(curTime, masksRef.current)) { + setMaskLimitNotice("Maximum 3 overlapping masks allowed"); + setTimeout(() => setMaskLimitNotice(null), 2500); + return; } - }; - window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); - }, [selected, seekTo, togglePlay, cutHere]); - - // ── Publish ───────────────────────────────────────────────── - const save = useCallback(async () => { - if (!data) return; - setSaving(true); - setSaveError(null); - try { - const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units); - await client.setCuts(cuts); - const result = await client.applyCuts(); - onApplied?.(result); - } catch (err) { - setSaveError(err instanceof Error ? err.message : String(err)); - setSaving(false); - } - }, [client, data, onApplied]); + const rect = overlayRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0 || rect.height === 0) return; + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - // ── Derived display values ────────────────────────────────── - const normalized = useMemo(() => normalizeRegions(regions), [regions]); - // Count what the SERVER will count. The footer used to count region - // widths in unit space while the server counted timestamp membership on - // the serialized intervals — so the two could disagree, and the editor - // would happily offer a Save the server then rejected. Same input, same - // shared function, no daylight between them. - const serializedCuts = useMemo( - () => (data ? regionsToCuts(normalized, data.units) : []), - [normalized, data], - ); - const unitTimesMs = useMemo( - () => units.map((u) => Date.parse(u.capturedAt)), - [units], - ); - const removedUnits = useMemo( - () => countCutUnits(unitTimesMs, serializedCuts), - [unitTimesMs, serializedCuts], + setSelectedMaskId(null); + setSelected(null); + setDrawingMask({ startX: x, startY: y, currentX: x, currentY: y }); + try { + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + } + catch { + // Fallback for Safari pointer capture + } + e.preventDefault(); + e.stopPropagation(); + }, + [mode, saving], ); - const keptUnits = unitCount - removedUnits; - const allCut = unitCount > 0 && keptUnits === 0; - const gaps = useMemo(() => (data ? gapIndices(data.units) : []), [data]); - const step = useMemo( - () => rulerStep(unitCount, stripWidth), - [unitCount, stripWidth], + + const onMaskBoxPointerDown = useCallback( + ( + e: React.PointerEvent, + id: string, + handle: "move" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w", + ) => { + if (saving || mode !== "mask") return; + const mask = masksRef.current.find((b) => b.id === id); + if (!mask) return; + setSelectedMaskId(id); + setSelected(null); + + const rect = overlayRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0 || rect.height === 0) return; + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + + setMaskDrag({ + id, + handle, + startX: x, + startY: y, + initial: { ...mask }, + }); + try { + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + } + catch { + // Fallback for Safari pointer capture + } + e.preventDefault(); + e.stopPropagation(); + }, + [mode, saving], ); - const ticks = useMemo(() => rulerTicks(unitCount, step), [unitCount, step]); - const currentUnit = unitAtTime(time, Math.max(1, unitCount)); - const inCutNow = regionAtTime(time, normalized) !== null; - const pct = (u: number) => `${(u / Math.max(1, unitCount)) * 100}%`; - // Keep the host informed of the working cut list, so closing the - // window can publish exactly what's on screen. - const onCutsChangeRef = useRef(onCutsChange); - onCutsChangeRef.current = onCutsChange; - useEffect(() => { - if (!data) return; - const saved = JSON.stringify(data.cuts ?? []); - onCutsChangeRef.current?.( - serializedCuts, - JSON.stringify(serializedCuts) !== saved, - ); - }, [serializedCuts, data]); + const onStagePointerMove = useCallback( + (e: React.PointerEvent) => { + const rect = overlayRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0 || rect.height === 0) return; + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - // ── Render ────────────────────────────────────────────────── - if (loadError) { - return ( -
- - {onCancel && ( -
- -
- )} -
- ); - } + if (drawingMask) { + setDrawingMask((prev) => (prev ? { ...prev, currentX: x, currentY: y } : null)); + return; + } + + if (maskDrag) { + const { handle, startX, startY, initial, id } = maskDrag; + const dx = x - startX; + const dy = y - startY; + + setMasks((prev) => + prev.map((b) => { + if (b.id !== id) return b; + const updated = { ...b }; + if (handle === "move") { + const newX = Math.max(0, Math.min(1 - initial.width, initial.x + dx)); + const newY = Math.max(0, Math.min(1 - initial.height, initial.y + dy)); + updated.x = newX; + updated.y = newY; + } + else if (handle === "se") { + updated.width = Math.max(0.02, Math.min(1 - initial.x, initial.width + dx)); + updated.height = Math.max(0.02, Math.min(1 - initial.y, initial.height + dy)); + } + else if (handle === "nw") { + const newX = Math.max(0, Math.min(initial.x + initial.width - 0.02, initial.x + dx)); + const newY = Math.max(0, Math.min(initial.y + initial.height - 0.02, initial.y + dy)); + updated.width = initial.width - (newX - initial.x); + updated.height = initial.height - (newY - initial.y); + updated.x = newX; + updated.y = newY; + } + else if (handle === "ne") { + const newY = Math.max(0, Math.min(initial.y + initial.height - 0.02, initial.y + dy)); + updated.height = initial.height - (newY - initial.y); + updated.y = newY; + updated.width = Math.max(0.02, Math.min(1 - initial.x, initial.width + dx)); + } + else if (handle === "sw") { + const newX = Math.max(0, Math.min(initial.x + initial.width - 0.02, initial.x + dx)); + updated.width = initial.width - (newX - initial.x); + updated.x = newX; + updated.height = Math.max(0.02, Math.min(1 - initial.y, initial.height + dy)); + } + else if (handle === "n") { + const newY = Math.max(0, Math.min(initial.y + initial.height - 0.02, initial.y + dy)); + updated.height = initial.height - (newY - initial.y); + updated.y = newY; + } + else if (handle === "s") { + updated.height = Math.max(0.02, Math.min(1 - initial.y, initial.height + dy)); + } + else if (handle === "e") { + updated.width = Math.max(0.02, Math.min(1 - initial.x, initial.width + dx)); + } + else if (handle === "w") { + const newX = Math.max(0, Math.min(initial.x + initial.width - 0.02, initial.x + dx)); + updated.width = initial.width - (newX - initial.x); + updated.x = newX; + } + return updated; + }), + ); + } + }, + [drawingMask, maskDrag], + ); + + const seekProbeVideo = useCallback((video: HTMLVideoElement, time: number): Promise => { + return new Promise((resolve) => { + if (Math.abs(video.currentTime - time) < 0.02) { + resolve(); + return; + } + let settled = false; + const done = () => { + if (settled) return; + settled = true; + video.removeEventListener("seeked", onSeeked); + video.removeEventListener("error", onError); + resolve(); + }; + const onSeeked = () => done(); + const onError = () => done(); + video.addEventListener("seeked", onSeeked, { once: true }); + video.addEventListener("error", onError, { once: true }); + setTimeout(done, 250); + try { + video.currentTime = time; + } + catch { + done(); + } + }); + }, []); + + const scanRegionShots = useCallback( + async (mask: UnitMaskRegion, anchorTime?: number, updateMaskDuration = true) => { + setExpandingMaskId(mask.id); + const scanToken = ++activeScanTokenRef.current; + try { + const probeVideo = probeVideoRef.current; + const liveVideo = videoRef.current; + if (!probeVideo || !liveVideo) return; + + const vw = liveVideo.videoWidth || 1280; + const vh = liveVideo.videoHeight || 720; + const sampleW = 48; + const sampleH = 48; + const sampleCanvas = document.createElement("canvas"); + sampleCanvas.width = sampleW; + sampleCanvas.height = sampleH; + const sCtx = sampleCanvas.getContext("2d", { willReadFrequently: true }); + if (!sCtx) return; + + const cropSx = Math.max(0, Math.floor(mask.x * vw)); + const cropSy = Math.max(0, Math.floor(mask.y * vh)); + const cropSw = Math.max(16, Math.floor(mask.width * vw)); + const cropSh = Math.max(16, Math.floor(mask.height * vh)); + + const totalDur = probeVideo.duration || unitCount; + if (totalDur <= 0) return; + + const targetTime = anchorTime ?? videoRef.current?.currentTime ?? mask.startUnit; + const WINDOW_RADIUS = 30; + const windowStart = Math.max(0, targetTime - WINDOW_RADIUS); + const windowEnd = Math.min(totalDur, targetTime + WINDOW_RADIUS); + + const cacheKey = `${mask.x.toFixed(2)}:${mask.y.toFixed(2)}:${mask.width.toFixed(2)}:${mask.height.toFixed(2)}@${Math.floor(windowStart)}_${Math.floor(windowEnd)}`; + const cached = dynamicShotsCacheRef.current.get(cacheKey); + if (cached && cached.length > 0) { + setDynamicShots(cached); + if (updateMaskDuration) { + const matchingShot = cached.find((s) => targetTime >= s.startSec - 0.001 && targetTime < s.endSec + 0.001) + ?? cached.find((s) => s.endSec >= targetTime) + ?? cached[0]; + if (matchingShot) { + setMasks((prev) => + prev.map((b) => + b.id === mask.id + ? { ...b, startUnit: matchingShot.startSec, endUnit: matchingShot.endSec } + : b, + ), + ); + seekTo(computeSafeCursorTime(matchingShot.startSec, matchingShot.endSec, targetTime), true); + } + } + return; + } + + const baseShots = data?.shots && data.shots.length > 0 ? data.shots : []; + const candidateTimes: { t: number; boundary: number }[] = []; + + if (baseShots.length > 1) { + for (const s of baseShots) { + if (s.endSec >= windowStart && s.startSec <= windowEnd) { + candidateTimes.push({ + t: (s.startSec + s.endSec) / 2, + boundary: s.startSec, + }); + } + } + } + else { + const step = 0.2; + const scanStart = Math.max(0.1, windowStart); + for (let t = scanStart; t <= windowEnd; t += step) { + candidateTimes.push({ + t: Math.round(t * 100) / 100, + boundary: Math.round(t * 100) / 100, + }); + } + } + + const detectedCuts: number[] = []; + let prevImgData: ImageData["data"] | null = null; + + for (let i = 0; i < candidateTimes.length; i++) { + if (scanToken !== activeScanTokenRef.current) return; + const { t, boundary } = candidateTimes[i]; + + await seekProbeVideo(probeVideo, Math.max(0, Math.min(totalDur - 0.05, t))); + if (scanToken !== activeScanTokenRef.current) return; + + let curData: ImageData["data"] | null = null; + try { + sCtx.drawImage(probeVideo, cropSx, cropSy, cropSw, cropSh, 0, 0, sampleW, sampleH); + curData = sCtx.getImageData(0, 0, sampleW, sampleH).data; + } + catch { + break; + } + + if (prevImgData && curData) { + let diffSum = 0; + for (let p = 0; p < curData.length; p += 4) { + const dr = Math.abs(prevImgData[p] - curData[p]); + const dg = Math.abs(prevImgData[p + 1] - curData[p + 1]); + const db = Math.abs(prevImgData[p + 2] - curData[p + 2]); + diffSum += (dr + dg + db) / (3 * 255); + } + const meanDiff = diffSum / (curData.length / 4); + if (meanDiff > 0.035 && boundary > 0.05) { + detectedCuts.push(boundary); + prevImgData = curData; + } + } + else if (curData) { + prevImgData = curData; + } + + if (i % 2 === 0) { + await new Promise((r) => setTimeout(r, 16)); + } + } + + if (scanToken !== activeScanTokenRef.current) return; + + const outsideCuts = baseShots.length > 1 + ? baseShots.map((s) => s.startSec).filter((c) => c > 0.05 && c < totalDur - 0.05 && (c < windowStart || c > windowEnd)) + : []; + const allCuts = [...outsideCuts, ...detectedCuts]; + const sortedCuts = Array.from(new Set(allCuts)).sort((a, b) => a - b); + const boundaries = [0, ...sortedCuts.filter((c) => c > 0.05 && c < totalDur - 0.05), totalDur]; + const newShots: VideoShot[] = []; + for (let i = 0; i < boundaries.length - 1; i++) { + const s = Math.round(boundaries[i] * 10_000) / 10_000; + const e = Math.round(boundaries[i + 1] * 10_000) / 10_000; + const dur = Math.round((e - s) * 10_000) / 10_000; + if (dur > 0.01) { + newShots.push({ + id: `region-shot-${i}`, + unitIndex: Math.floor(s), + frameIndex: i, + startSec: s, + endSec: e, + duration: dur, + }); + } + } + + if (newShots.length > 0) { + dynamicShotsCacheRef.current.set(cacheKey, newShots); + setDynamicShots(newShots); + if (updateMaskDuration) { + const matchingShot = newShots.find((s) => targetTime >= s.startSec - 0.001 && targetTime < s.endSec + 0.001) + ?? newShots.find((s) => s.endSec >= targetTime) + ?? newShots[0]; + if (matchingShot) { + setMasks((prev) => + prev.map((b) => + b.id === mask.id + ? { ...b, startUnit: matchingShot.startSec, endUnit: matchingShot.endSec } + : b, + ), + ); + seekTo(computeSafeCursorTime(matchingShot.startSec, matchingShot.endSec, targetTime), true); + } + } + } + } + catch (err) { + console.warn("[editor] scanRegionShots failed:", err); + } + finally { + if (scanToken === activeScanTokenRef.current) { + setTimeout(() => setExpandingMaskId(null), 250); + } + } + }, + [data?.shots, unitCount, seekProbeVideo, seekTo], + ); + + const recalculateMaskSpan = useCallback( + async ( + mask: UnitMaskRegion, + curTime: number, + direction: "forward" | "backward" | "both", + ) => { + setExpandingMaskId(mask.id); + const scanToken = ++activeScanTokenRef.current; + try { + const probeVideo = probeVideoRef.current; + const liveVideo = videoRef.current; + if (!probeVideo || !liveVideo) return; + + const vw = liveVideo.videoWidth || 1280; + const vh = liveVideo.videoHeight || 720; + const sampleW = 48; + const sampleH = 48; + const sampleCanvas = document.createElement("canvas"); + sampleCanvas.width = sampleW; + sampleCanvas.height = sampleH; + const sCtx = sampleCanvas.getContext("2d", { willReadFrequently: true }); + if (!sCtx) return; + + const cropSx = Math.max(0, Math.floor(mask.x * vw)); + const cropSy = Math.max(0, Math.floor(mask.y * vh)); + const cropSw = Math.max(16, Math.floor(mask.width * vw)); + const cropSh = Math.max(16, Math.floor(mask.height * vh)); + + const shots = activeShots.length > 0 ? activeShots : []; + if (shots.length === 0) return; + + let curIdx = shots.findIndex((s) => curTime >= s.startSec - 0.001 && curTime < s.endSec + 0.001); + if (curIdx === -1) { + curIdx = shots.findIndex((s) => s.endSec >= curTime); + if (curIdx === -1) curIdx = 0; + } + + const baseTime = (shots[curIdx].startSec + shots[curIdx].endSec) / 2; + await seekProbeVideo(probeVideo, Math.max(0, Math.min(probeVideo.duration - 0.05, baseTime))); + if (scanToken !== activeScanTokenRef.current) return; + + let baseImgData: ImageData["data"] | null = null; + try { + sCtx.drawImage(probeVideo, cropSx, cropSy, cropSw, cropSh, 0, 0, sampleW, sampleH); + baseImgData = sCtx.getImageData(0, 0, sampleW, sampleH).data; + } + catch { + return; + } + if (!baseImgData) return; + + let curStart = mask.startUnit; + let curEnd = mask.endUnit; + + const checkMatch = async (t: number, lastData: ImageData["data"]) => { + await seekProbeVideo(probeVideo, Math.max(0, Math.min(probeVideo.duration - 0.05, t))); + sCtx.drawImage(probeVideo, cropSx, cropSy, cropSw, cropSh, 0, 0, sampleW, sampleH); + const probeData = sCtx.getImageData(0, 0, sampleW, sampleH).data; + let diffSum = 0; + for (let p = 0; p < probeData.length; p += 4) { + const dr = Math.abs(lastData[p] - probeData[p]); + const dg = Math.abs(lastData[p + 1] - probeData[p + 1]); + const db = Math.abs(lastData[p + 2] - probeData[p + 2]); + diffSum += (dr + dg + db) / (3 * 255); + } + const meanDiff = diffSum / (probeData.length / 4); + return { matches: meanDiff <= 0.04, imgData: probeData }; + }; + + if (direction === "forward" || direction === "both") { + let lastData = baseImgData; + for (let i = curIdx + 1; i < shots.length; i++) { + if (scanToken !== activeScanTokenRef.current) return; + const res = await checkMatch((shots[i].startSec + shots[i].endSec) / 2, lastData); + if (res.matches) { + lastData = res.imgData; + curEnd = shots[i].endSec; + setMasks((prev) => + prev.map((b) => (b.id === mask.id ? { ...b, endUnit: curEnd } : b)), + ); + await new Promise((r) => setTimeout(r, 16)); + } + else { + break; + } + } + } + + if (direction === "backward" || direction === "both") { + let lastData = baseImgData; + for (let i = curIdx - 1; i >= 0; i--) { + if (scanToken !== activeScanTokenRef.current) return; + const res = await checkMatch((shots[i].startSec + shots[i].endSec) / 2, lastData); + if (res.matches) { + lastData = res.imgData; + curStart = shots[i].startSec; + setMasks((prev) => + prev.map((b) => (b.id === mask.id ? { ...b, startUnit: curStart } : b)), + ); + await new Promise((r) => setTimeout(r, 16)); + } + else { + break; + } + } + } + } + catch (err) { + console.warn("[editor] recalculateMaskSpan failed:", err); + } + finally { + if (scanToken === activeScanTokenRef.current) { + setTimeout(() => setExpandingMaskId(null), 250); + } + } + }, + [activeShots, seekProbeVideo], + ); + + const onStagePointerUp = useCallback(() => { + if (drawingMask) { + const w = Math.abs(drawingMask.currentX - drawingMask.startX); + const h = Math.abs(drawingMask.currentY - drawingMask.startY); + if (w >= 0.02 && h >= 0.02) { + const curTime = videoRef.current?.currentTime ?? 0; + if (!canAddMaskAtTime(curTime, masksRef.current)) { + setDrawingMask(null); + setMaskLimitNotice("Maximum 3 overlapping masks allowed"); + setTimeout(() => setMaskLimitNotice(null), 2500); + return; + } + const snappedTime = activeShots.length > 0 + ? snapToNearestShotBoundary(curTime, activeShots, unitCount) + : curTime; + const lookupTime = Math.abs(curTime - snappedTime) < 0.05 ? snappedTime + 0.001 : curTime; + const shot = findShotAtTime(lookupTime, activeShots) ?? findShotAtTime(curTime, activeShots); + const safeStart = shot + ? shot.startSec + : Math.max(0, Math.min(Math.max(0, unitCount - 1), Math.floor(curTime))); + const safeEnd = shot + ? shot.endSec + : Math.max(safeStart + 1, Math.min(unitCount, safeStart + 1)); + const newMask: UnitMaskRegion = { + id: `mask-${Math.random().toString(36).slice(2, 9)}`, + startUnit: safeStart, + endUnit: safeEnd, + x: Math.min(drawingMask.startX, drawingMask.currentX), + y: Math.min(drawingMask.startY, drawingMask.currentY), + width: w, + height: h, + }; + setMasks((prev) => [...prev, newMask]); + setSelectedMaskId(newMask.id); + seekTo(computeSafeCursorTime(safeStart, safeEnd, curTime), true); + void scanRegionShots(newMask, curTime, true); + } + setDrawingMask(null); + } + if (maskDrag) { + const { id, initial } = maskDrag; + const b = masksRef.current.find((item) => item.id === id); + if ( + b && + (Math.abs(b.x - initial.x) > 0.005 || + Math.abs(b.y - initial.y) > 0.005 || + Math.abs(b.width - initial.width) > 0.005 || + Math.abs(b.height - initial.height) > 0.005) + ) { + const curTime = videoRef.current?.currentTime ?? b.startUnit; + const adjustedMask: UnitMaskRegion = { ...b, startUnit: initial.startUnit, endUnit: initial.endUnit }; + setMasks((prev) => prev.map((item) => (item.id === id ? adjustedMask : item))); + void scanRegionShots(adjustedMask, curTime, false); + } + setMaskDrag(null); + } + }, [drawingMask, maskDrag, unitCount, activeShots, seekTo, scanRegionShots]); + + const onMaskTrackPointerDown = useCallback( + (e: React.PointerEvent) => { + if (saving) return; + seekTo(unitFromEvent(e)); + setSelectedMaskId(null); + setSelected(null); + }, + [saving, seekTo, unitFromEvent], + ); + + const onMaskSpanPointerDown = useCallback( + ( + e: React.PointerEvent, + id: string, + dragType: "move" | "start" | "end", + ) => { + if (saving) return; + if (mode !== "mask") return; + e.preventDefault(); + e.stopPropagation(); + + const b = masksRef.current.find((item) => item.id === id); + if (!b) return; + + setSelectedMaskId(id); + setSelected(null); + + const timelineEl = maskTrackRef.current || timelineRef.current; + if (!timelineEl) return; + const rect = timelineEl.getBoundingClientRect(); + if (rect.width === 0) return; + + const initialStart = b.startUnit; + const initialEnd = b.endUnit; + const initialWidth = Math.max(0.01, initialEnd - initialStart); + const startClientX = e.clientX; + const totalUnits = Math.max(1, unitCount); + let shiftHeld = e.shiftKey; + let hasDragged = false; + + if (dragType === "end") { + seekTo(Math.max(b.startUnit + 0.005, b.endUnit - 0.05), true); + } + else { + seekTo(initialStart + 0.005, true); + } + + const onWindowPointerMove = (ev: PointerEvent) => { + ev.preventDefault(); + const deltaPx = ev.clientX - startClientX; + if (Math.abs(deltaPx) > 3) { + hasDragged = true; + } + if (!hasDragged) return; + + shiftHeld = ev.shiftKey || shiftHeld; + const deltaUnits = (deltaPx / rect.width) * totalUnits; + const isPrecise = ev.shiftKey || shiftHeld; + + if (dragType === "move") { + const rawStart = initialStart + deltaUnits; + let newStart = Math.max(0, Math.min(totalUnits - initialWidth, rawStart)); + if (!isPrecise) { + const startShot = findShotAtTime(rawStart + 0.001, activeShots); + newStart = startShot ? startShot.startSec : snapToNearestShotBoundary(rawStart, activeShots, totalUnits); + newStart = Math.max(0, Math.min(totalUnits - initialWidth, newStart)); + } + const newEnd = newStart + initialWidth; + + setMasks((prev) => + prev.map((item) => + item.id === id ? { ...item, startUnit: newStart, endUnit: newEnd } : item, + ), + ); + seekTo(newStart + 0.005); + } + else if (dragType === "start") { + const rawStart = initialStart + deltaUnits; + let newStart = Math.max(0, Math.min(totalUnits - 0.02, rawStart)); + if (!isPrecise) { + const shot = findShotAtTime(rawStart + 0.001, activeShots); + newStart = shot ? shot.startSec : snapToNearestShotBoundary(rawStart, activeShots, totalUnits); + } + let newEnd = initialEnd; + if (newStart >= initialEnd) { + newStart = Math.max(0, initialEnd - 0.02); + } + + setMasks((prev) => + prev.map((item) => + item.id === id ? { ...item, startUnit: newStart, endUnit: newEnd } : item, + ), + ); + seekTo(newStart + 0.005); + } + else if (dragType === "end") { + const rawEnd = initialEnd + deltaUnits; + let newEnd = Math.min(totalUnits, Math.max(0.02, rawEnd)); + if (!isPrecise) { + const shot = findShotAtTime(rawEnd - 0.001, activeShots); + newEnd = shot ? shot.endSec : snapToNearestShotBoundary(rawEnd, activeShots, totalUnits); + } + let newStart = initialStart; + if (newEnd <= initialStart) { + newEnd = Math.min(totalUnits, initialStart + 0.02); + } + + setMasks((prev) => + prev.map((item) => + item.id === id ? { ...item, startUnit: newStart, endUnit: newEnd } : item, + ), + ); + seekTo(Math.max(newStart + 0.005, newEnd - 0.05)); + } + }; + + const onWindowPointerUp = (ev: PointerEvent) => { + ev.preventDefault(); + window.removeEventListener("pointermove", onWindowPointerMove); + window.removeEventListener("pointerup", onWindowPointerUp); + window.removeEventListener("pointercancel", onWindowPointerUp); + + if (!hasDragged) return; + + const isPrecise = ev.shiftKey || shiftHeld; + + setMasks((prev) => + prev.map((item) => { + if (item.id !== id) return item; + let s: number; + let e: number; + if (isPrecise) { + s = Math.max(0, Math.min(totalUnits - 0.033, Math.round(item.startUnit * 10_000) / 10_000)); + e = Math.max(s + 0.033, Math.min(totalUnits, Math.round(item.endUnit * 10_000) / 10_000)); + } + else { + const startShot = findShotAtTime(item.startUnit + 0.001, activeShots); + const endShot = findShotAtTime(item.endUnit - 0.001, activeShots); + s = startShot ? startShot.startSec : snapToNearestShotBoundary(item.startUnit, activeShots, totalUnits); + e = endShot ? endShot.endSec : snapToNearestShotBoundary(item.endUnit, activeShots, totalUnits); + if (e <= s) { + const shot = findShotAtTime(s + 0.001, activeShots) ?? findShotAtTime(s, activeShots); + e = shot ? shot.endSec : Math.min(totalUnits, s + 1); + } + } + if (dragType === "end") { + seekTo(Math.max(s + 0.005, e - 0.05)); + } + else { + seekTo(s + 0.005); + } + const updated = { ...item, startUnit: s, endUnit: e }; + if (dragType === "end") { + void recalculateMaskSpan(updated, Math.max(s, e - 0.05), "forward"); + } + else if (dragType === "start") { + void recalculateMaskSpan(updated, s + 0.05, "backward"); + } + else if (dragType === "move") { + void recalculateMaskSpan(updated, s, "both"); + } + return updated; + }), + ); + }; + + window.addEventListener("pointermove", onWindowPointerMove); + window.addEventListener("pointerup", onWindowPointerUp); + window.addEventListener("pointercancel", onWindowPointerUp); + }, + [saving, seekTo, unitCount, activeShots, mode, recalculateMaskSpan], + ); + + // ── Keyboard ──────────────────────────────────────────────── + // Capture phase + preventDefault so hosting apps' global key handlers + // (e.g. the desktop router's Backspace-goes-back) never fire underneath + // an open editor — losing unsaved cuts to a stray Backspace is the worst + // possible outcome of this surface. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return; + if ((e.metaKey || e.ctrlKey) && e.key === "0") { + e.preventDefault(); + resetZoom(); + return; + } + if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { + e.preventDefault(); + zoomIn(); + return; + } + if ((e.metaKey || e.ctrlKey) && (e.key === "-" || e.key === "_")) { + e.preventDefault(); + zoomOut(); + return; + } + if (e.metaKey || e.ctrlKey) return; + if (e.key === " " || e.key === "k") { + e.preventDefault(); + togglePlay(); + } else if ((e.key === "x" || e.key === "c") && mode === "cut") { + e.preventDefault(); + cutHere(); + } else if (e.key === "+" || e.key === "=") { + e.preventDefault(); + zoomIn(); + } else if (e.key === "-" || e.key === "_") { + e.preventDefault(); + zoomOut(); + } else if (e.key === "0") { + e.preventDefault(); + resetZoom(); + } else if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + if (mode === "mask" && selectedMaskId !== null) { + setMasks((prev) => prev.filter((b) => b.id !== selectedMaskId)); + setSelectedMaskId(null); + } else if (mode === "cut" && selected !== null) { + setRegions((prev) => prev.filter((_, i) => i !== selected)); + setSelected(null); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setSelected(null); + setSelectedMaskId(null); + setMaskMenuOpen(false); + } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + seekTo( + (videoRef.current?.currentTime ?? 0) + + (e.key === "ArrowLeft" ? -step : step), + ); + } + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [selected, selectedMaskId, seekTo, togglePlay, cutHere, zoomIn, zoomOut, resetZoom, mode]); + + useEffect(() => { + if (!maskMenuOpen) return; + const onPointerDownOutside = (e: PointerEvent) => { + if (maskMenuRef.current && !maskMenuRef.current.contains(e.target as Node)) { + setMaskMenuOpen(false); + } + }; + window.addEventListener("pointerdown", onPointerDownOutside); + return () => { + window.removeEventListener("pointerdown", onPointerDownOutside); + }; + }, [maskMenuOpen]); + + // ── Publish ───────────────────────────────────────────────── + const save = useCallback(async () => { + if (!data || saving) return; + setSaving(true); + setSaveError(null); + setSaveSuccess(false); + try { + const cuts = regionsToCuts(normalizeRegions(regionsRef.current), data.units); + const masksList = unitMasksToMasks(masksRef.current, data.units); + await client.setCuts(cuts, masksList); + const result = await client.applyCuts(); + if (!result.instant && result.status === "compiling") { + const pollStart = Date.now(); + const maxWaitMs = 60_000; + while (Date.now() - pollStart < maxWaitMs) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + const st = await client.getStatus(); + if (st.status === "complete") break; + if (st.status === "failed") throw new Error("Compilation failed on server"); + if (!st.editable && st.status !== "compiling") break; + } + catch (pollErr) { + if (pollErr instanceof Error && pollErr.message.includes("Compilation failed")) throw pollErr; + } + } + } + setSaveSuccess(true); + setSaving(false); + onApplied?.(result); + } + catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + setSaving(false); + } + }, [client, data, saving, onApplied]); + + // ── Derived display values ────────────────────────────────── + const normalized = useMemo(() => normalizeRegions(regions), [regions]); + // Count what the SERVER will count. The footer used to count region + // widths in unit space while the server counted timestamp membership on + // the serialized intervals — so the two could disagree, and the editor + // would happily offer a Save the server then rejected. Same input, same + // shared function, no daylight between them. + const serializedCuts = useMemo( + () => (data ? regionsToCuts(normalized, data.units) : []), + [normalized, data], + ); + const unitTimesMs = useMemo( + () => units.map((u) => Date.parse(u.capturedAt)), + [units], + ); + const removedUnits = useMemo( + () => countCutUnits(unitTimesMs, serializedCuts), + [unitTimesMs, serializedCuts], + ); + const keptUnits = unitCount - removedUnits; + const totalMaskedSec = useMemo(() => { + if (masks.length === 0 || unitCount === 0) return 0; + const intervals: Array<[number, number]> = masks.map((b) => [ + Math.max(0, b.startUnit), + Math.min(unitCount, b.endUnit), + ]); + intervals.sort((a, b) => a[0] - b[0]); + let mergedUnits = 0; + let curInterval: [number, number] | null = null; + for (const [start, end] of intervals) { + if (!curInterval) { + curInterval = [start, end]; + } + else if (start <= curInterval[1]) { + curInterval[1] = Math.max(curInterval[1], end); + } + else { + mergedUnits += Math.max(0, curInterval[1] - curInterval[0]); + curInterval = [start, end]; + } + } + if (curInterval) { + mergedUnits += Math.max(0, curInterval[1] - curInterval[0]); + } + return mergedUnits * 60; + }, [masks, unitCount]); + + const [flowMaskedSec, setFlowMaskedSec] = useState(0); + useEffect(() => { + const target = Math.max(0, Math.round(totalMaskedSec)); + if (target === 0) { + setFlowMaskedSec(0); + return; + } + const raf = requestAnimationFrame(() => { + setFlowMaskedSec(target); + }); + return () => cancelAnimationFrame(raf); + }, [totalMaskedSec]); + + const selectedMask = useMemo( + () => masks.find((b) => b.id === selectedMaskId) ?? null, + [masks, selectedMaskId], + ); + const allCut = unitCount > 0 && keptUnits === 0; + const gaps = useMemo(() => (data ? gapIndices(data.units) : []), [data]); + const step = useMemo( + () => rulerStep(unitCount, stripWidth), + [unitCount, stripWidth], + ); + const ticks = useMemo(() => rulerTicks(unitCount, step), [unitCount, step]); + const currentUnit = unitAtTime(time, Math.max(1, unitCount)); + const inCutNow = regionAtTime(time, normalized) !== null; + const pct = (u: number) => `${(u / Math.max(1, unitCount)) * 100}%`; + + // Keep the host informed of the working cut list, so closing the + // window can publish exactly what's on screen. + const onCutsChangeRef = useRef(onCutsChange); + onCutsChangeRef.current = onCutsChange; + useEffect(() => { + if (!data) return; + const saved = JSON.stringify(data.cuts ?? []); + onCutsChangeRef.current?.( + serializedCuts, + JSON.stringify(serializedCuts) !== saved, + ); + }, [serializedCuts, data]); + + const onMasksChangeRef = useRef(onMasksChange); + onMasksChangeRef.current = onMasksChange; + const onBlursChangeRef = useRef(onBlursChange); + onBlursChangeRef.current = onBlursChange; + useEffect(() => { + if (!data) return; + const serializedMasks = unitMasksToMasks(masks, data.units); + const saved = JSON.stringify(data.masks ?? (data as any).blurs ?? []); + const isDirty = JSON.stringify(serializedMasks) !== saved; + onMasksChangeRef.current?.(serializedMasks, isDirty); + onBlursChangeRef.current?.(serializedMasks, isDirty); + }, [masks, data]); + + // ── Render ────────────────────────────────────────────────── + if (loadError) { + return ( +
+ + {onCancel && ( +
+ +
+ )} +
+ ); + } if (!data) { return ( @@ -893,19 +2166,21 @@ export function TimelapseEditor({ return (
- {/* ── Stage: the only row that flexes ──────────────────── */} + {/* ── Stage: shrinks into whatever space the dock leaves ── */}
-