From dd8ca3447bceb37db16009583439dc611e80eeb4 Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:50:16 +0800 Subject: [PATCH 1/6] feat: add a clip/edit step to the recording flow Bumps the vendored Lookout SDK to 1e4fdd7, which ships the timelapse clip editor and the server-side clips/edit-hold support, and wires it into Lapse: - Enable clips on session creation (`clips: true`), so the session is editable regardless of the Lookout server's default. Kept explicit rather than relying on the server default so it works against deployments that still default clips off. - Request the edit hold on stop (`actions.stop({ edit: true })`) so the session stays open for editing instead of compiling straight to complete. - After recording, present the TimelapseEditor in a Lapse-styled modal before handing off to the publish page; dismissing or saving continues the existing publish flow. - Recolor the SDK's controls and the editor to Lapse's red via the provider's `accentColor`. --- .../components/lookout/LookoutRecorder.tsx | 69 +++++++++++++++++-- apps/server/src/lookout.ts | 7 +- apps/server/src/routers/timelapse.ts | 2 +- pnpm-lock.yaml | 46 +++++++++++++ vendor/lookout | 2 +- 5 files changed, 117 insertions(+), 9 deletions(-) diff --git a/apps/client/src/components/lookout/LookoutRecorder.tsx b/apps/client/src/components/lookout/LookoutRecorder.tsx index 0cb98f0..a9dd057 100644 --- a/apps/client/src/components/lookout/LookoutRecorder.tsx +++ b/apps/client/src/components/lookout/LookoutRecorder.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/router"; import clsx from "clsx"; import Icon from "@hackclub/icons"; -import { LookoutProvider, useLookout } from "@lookout/react"; +import { LookoutProvider, useLookout, TimelapseEditor } from "@lookout/react"; import type { CaptureMode } from "@lookout/react"; import type { IconGlyph } from "@/common"; @@ -41,6 +41,10 @@ interface LookoutSessionConfig { type RecordingMode = "desktop" | "screen" | "camera"; +// Lapse's brand red (mirrors --color-red in globals.css). Passed to the Lookout SDK's +// accentColor so its components match the rest of the app. +const LAPSE_ACCENT = "#ec3750"; + function RecordingModeOption({ icon, title, description, selected, onClick, recommended, dimmed }: { icon: IconGlyph; title: string; @@ -724,6 +728,10 @@ export default function LookoutRecorder() { token={config.lookoutToken} apiBaseUrl={config.lookoutApiBaseUrl} appName="Lapse" + // Recolor the Lookout SDK's controls, focus rings, and the editor to Lapse's + // red (--color-red). Applied to the document root, so it also reaches the + // editor even though our Modal portals to . + accentColor={LAPSE_ACCENT} capture={captureMode === "camera" ? { mode: "camera", camera: cameraDeviceId ? { deviceId: cameraDeviceId } : undefined } : undefined @@ -731,6 +739,8 @@ export default function LookoutRecorder() { > { releasePendingStream(); setCaptureMode(null); setCameraDeviceId(null); }} onBrowserError={(message) => { // Browser capture failed — return to the selector (the "picker") and remember why, @@ -868,8 +878,10 @@ function CameraPreviewVideo({ stream }: { stream: MediaStream }) { // it as "go back to mode selection" rather than a scary error — see the error effect. const SCREEN_CANCELLED_MESSAGE = "Screen sharing was cancelled."; -function LapseRecorder({ draftId, onShareFailed, onBrowserError }: { +function LapseRecorder({ draftId, lookoutToken, apiBaseUrl, onShareFailed, onBrowserError }: { draftId: string; + lookoutToken: string; + apiBaseUrl: string; onShareFailed: () => void; onBrowserError: (message: string) => void; }) { @@ -877,6 +889,11 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: { const { state, actions } = useLookout(); const [error, setError] = useState(null); const [captureError, setCaptureError] = useState(null); + // Once recording stops we offer the cut/edit step before handing off to the publish + // page. `editorDone` flips when the user saves or dismisses the editor, which is what + // actually releases them to publish. + const [editorOpen, setEditorOpen] = useState(false); + const [editorDone, setEditorDone] = useState(false); const screenStarted = useRef(false); const isCamera = state.captureMode === "camera"; @@ -937,10 +954,18 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: { }, 30 * 1000); useEffect(() => { - if (state.status === "stopped" || state.status === "compiling" || state.status === "complete") { + const recordingEnded = state.status === "stopped" || state.status === "compiling" || state.status === "complete"; + if (!recordingEnded) return; + + // The user has finished with the editor (saved cuts or dismissed it) — hand off to + // the existing publish page. Without a token to edit against, skip straight there so + // the flow never gets stuck. + if (editorDone || !lookoutToken) { router.push(`/timelapse/publish/${draftId}`); + } else { + setEditorOpen(true); } - }, [state.status, draftId, router]); + }, [state.status, editorDone, lookoutToken, draftId, router]); function handleStartSharing() { actions.startSharing().catch((err) => @@ -958,12 +983,46 @@ function LapseRecorder({ draftId, onShareFailed, onBrowserError }: { async function stopRecording() { try { - await actions.stop(); + // `edit: true` asks the server to hold the session open for editing after stop + // instead of compiling straight to `complete`. Without it the cut/edit step below + // gets a `not_ready` session ("isn't available for editing"). + await actions.stop({ edit: true }); } catch (err) { setError(err instanceof Error ? err.message : "Failed to stop recording"); } } + // After recording stops, the cut/edit step is offered in a Lapse-styled modal. This + // must precede the capture-state branches below (once stopped, `state.isSharing` is + // false, which would otherwise render the empty placeholder). Dismissing or saving + // sets `editorDone`, which the effect above turns into the publish-page handoff — the + // editor itself publishes the (possibly cut) session; we just continue Lapse's flow. + if (editorOpen && lookoutToken) { + return ( + + + { setEditorOpen(false); setEditorDone(true); }} + /> + +
+ { setEditorOpen(false); setEditorDone(true); }} + onCancel={() => { setEditorOpen(false); setEditorDone(true); }} + /> +
+
+
+
+ ); + } + // Camera failures show an inline modal; screen failures are handled by the effect above, // which sends the user back to the selector with the error recorded. if (captureError) { diff --git a/apps/server/src/lookout.ts b/apps/server/src/lookout.ts index 34f8359..20b8bb4 100644 --- a/apps/server/src/lookout.ts +++ b/apps/server/src/lookout.ts @@ -56,11 +56,14 @@ async function lookoutFetch(path: string, options?: RequestInit): Promise export async function createSession( name?: string, - metadata?: Record + metadata?: Record, + options?: { clips?: boolean } ): Promise { const result = await lookoutFetch("/api/internal/sessions", { method: "POST", - body: JSON.stringify({ name, metadata }), + // `clips` (not `clip`/`clipsEnabled`) is the field that flips `clipsEnabled` on + // the session, unlocking the cut/edit flow. + body: JSON.stringify({ name, metadata, clips: options?.clips }), }); logInfo(`Created Lookout session ${result.sessionId}`); diff --git a/apps/server/src/routers/timelapse.ts b/apps/server/src/routers/timelapse.ts index 27be7f5..176cab0 100644 --- a/apps/server/src/routers/timelapse.ts +++ b/apps/server/src/routers/timelapse.ts @@ -508,7 +508,7 @@ export default os.router({ lapseUserId: caller.id, lapseUserHandle: caller.handle, source: "lapse", - }); + }, { clips: true }); const draft = await database().draftLookoutTimelapse.create({ data: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59de37c..f4501b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -429,6 +429,12 @@ importers: '@lookout/shared': specifier: workspace:* version: link:../../packages/shared + '@number-flow/react': + specifier: ^0.6.2 + version: 0.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@squircle-js/react': specifier: ^1.3.0 version: 1.3.0(@types/react@19.2.18)(react@19.2.8) @@ -512,6 +518,9 @@ packages: '@aws-sdk/core@3.977.4': resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. '@aws-sdk/credential-provider-env@3.972.65': resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} @@ -2594,6 +2603,12 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@number-flow/react@0.6.2': + resolution: {integrity: sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -2718,6 +2733,13 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -4935,6 +4957,9 @@ packages: jiti: optional: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6231,6 +6256,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + number-flow@0.6.2: + resolution: {integrity: sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -9867,6 +9895,13 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@number-flow/react@0.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + esm-env: 1.2.2 + number-flow: 0.6.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@open-draft/deferred-promise@2.2.0': optional: true @@ -10078,6 +10113,11 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@pinojs/redact@0.4.0': {} '@polka/url@1.0.0-next.29': {} @@ -12464,6 +12504,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.18.0 @@ -13756,6 +13798,10 @@ snapshots: dependencies: boolbase: 1.0.0 + number-flow@0.6.2: + dependencies: + esm-env: 1.2.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} diff --git a/vendor/lookout b/vendor/lookout index a76dcf4..1e4fdd7 160000 --- a/vendor/lookout +++ b/vendor/lookout @@ -1 +1 @@ -Subproject commit a76dcf48c2fefb68ea3ba1caba12f68c14b25e2b +Subproject commit 1e4fdd7523a00b27c4a1b08b8bfc857f92ca900d From b32b40737af88a31010a0de3184803ab2f1b56ef Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:54:48 +0800 Subject: [PATCH 2/6] feat: resume an interrupted edit from the publish page If the browser closes (or the page refreshes) while the edit hold is live, the publish page previously sat on the compile spinner until the hold lapsed and the session auto-published uncut. Now it probes the session's edit hold via the device's stored Lookout token and reopens the editor while editing is still possible. The editor modal is extracted into a shared EditorModal used by both the recorder and the publish page; since the editor is the only SDK-rendered surface in Lapse, the accent color moves there too (setAccentColor on mount) instead of living on LookoutProvider. --- .../src/components/lookout/EditorModal.tsx | 54 +++++++++++++++++++ .../components/lookout/LookoutRecorder.tsx | 35 +++--------- .../src/pages/timelapse/publish/[id].tsx | 47 +++++++++++++++- 3 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 apps/client/src/components/lookout/EditorModal.tsx diff --git a/apps/client/src/components/lookout/EditorModal.tsx b/apps/client/src/components/lookout/EditorModal.tsx new file mode 100644 index 0000000..7b3436a --- /dev/null +++ b/apps/client/src/components/lookout/EditorModal.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; +import { TimelapseEditor, setAccentColor } from "@lookout/react"; + +import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal"; + +// Lapse's brand red (mirrors --color-red in globals.css). Applied to the Lookout SDK's +// document-root accent variables so the editor matches the rest of the app. +const LAPSE_ACCENT = "#ec3750"; + +/** + * The Lookout cut/edit step, wrapped in a Lapse-styled modal. Used right after a + * recording stops, and again by the publish page to resume an interrupted edit while + * the session's edit hold is still live. + * + * `onDone` fires when the user is finished with the editor — cuts applied or the + * editor dismissed. Either way the session publishes (the edit hold is a lease, and + * leaving the editor is the decision to publish), so callers should continue to the + * publish flow. + */ +export function EditorModal({ token, apiBaseUrl, onDone }: { + token: string; + apiBaseUrl: string; + onDone: () => void; +}) { + // The editor is the only SDK-rendered surface in Lapse, so the accent lives here + // rather than on LookoutProvider. Set on the document root (the editor portals to + // ), restored on unmount so the accent never leaks past the editor. + useEffect(() => { + setAccentColor(LAPSE_ACCENT, null); + return () => setAccentColor(null, null); + }, []); + + return ( + + + +
+ +
+
+
+ ); +} diff --git a/apps/client/src/components/lookout/LookoutRecorder.tsx b/apps/client/src/components/lookout/LookoutRecorder.tsx index a9dd057..a9edac0 100644 --- a/apps/client/src/components/lookout/LookoutRecorder.tsx +++ b/apps/client/src/components/lookout/LookoutRecorder.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/router"; import clsx from "clsx"; import Icon from "@hackclub/icons"; -import { LookoutProvider, useLookout, TimelapseEditor } from "@lookout/react"; +import { LookoutProvider, useLookout } from "@lookout/react"; import type { CaptureMode } from "@lookout/react"; import type { IconGlyph } from "@/common"; @@ -15,6 +15,7 @@ import { removeStoredSession, } from "@/components/lookout/sessions"; import RootLayout from "@/components/layout/RootLayout"; +import { EditorModal } from "@/components/lookout/EditorModal"; import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal"; import { LoadingModal } from "@/components/layout/LoadingModal"; import { ErrorModal } from "@/components/layout/ErrorModal"; @@ -41,10 +42,6 @@ interface LookoutSessionConfig { type RecordingMode = "desktop" | "screen" | "camera"; -// Lapse's brand red (mirrors --color-red in globals.css). Passed to the Lookout SDK's -// accentColor so its components match the rest of the app. -const LAPSE_ACCENT = "#ec3750"; - function RecordingModeOption({ icon, title, description, selected, onClick, recommended, dimmed }: { icon: IconGlyph; title: string; @@ -728,10 +725,6 @@ export default function LookoutRecorder() { token={config.lookoutToken} apiBaseUrl={config.lookoutApiBaseUrl} appName="Lapse" - // Recolor the Lookout SDK's controls, focus rings, and the editor to Lapse's - // red (--color-red). Applied to the document root, so it also reaches the - // editor even though our Modal portals to . - accentColor={LAPSE_ACCENT} capture={captureMode === "camera" ? { mode: "camera", camera: cameraDeviceId ? { deviceId: cameraDeviceId } : undefined } : undefined @@ -1000,25 +993,11 @@ function LapseRecorder({ draftId, lookoutToken, apiBaseUrl, onShareFailed, onBro if (editorOpen && lookoutToken) { return ( - - { setEditorOpen(false); setEditorDone(true); }} - /> - -
- { setEditorOpen(false); setEditorDone(true); }} - onCancel={() => { setEditorOpen(false); setEditorDone(true); }} - /> -
-
-
+ { setEditorOpen(false); setEditorDone(true); }} + />
); } diff --git a/apps/client/src/pages/timelapse/publish/[id].tsx b/apps/client/src/pages/timelapse/publish/[id].tsx index af8efb1..91b13a6 100644 --- a/apps/client/src/pages/timelapse/publish/[id].tsx +++ b/apps/client/src/pages/timelapse/publish/[id].tsx @@ -7,7 +7,8 @@ import type { TimelapseVisibility } from "@hackclub/lapse-api"; import { api } from "@/api"; import { useAuth } from "@/hooks/useAuth"; import { useInterval } from "@/hooks/useInterval"; -import { removeStoredSession } from "@/components/lookout/sessions"; +import { getStoredSessions, removeStoredSession, type StoredLookoutSession } from "@/components/lookout/sessions"; +import { EditorModal } from "@/components/lookout/EditorModal"; import RootLayout from "@/components/layout/RootLayout"; import { Button } from "@/components/ui/Button"; @@ -64,6 +65,20 @@ export default function Page() { const [loadStatus, setLoadStatus] = useState(null); const [step, setStep] = useState("details"); + + // Resuming an interrupted edit: if this page loads (e.g. after a refresh) while the + // session's edit hold is still live, the cut/edit modal reopens instead of leaving + // the user staring at the compile spinner until the hold lapses. The Lookout token + // for the draft is only known on this device, via the recorder's stored sessions. + const [storedSession, setStoredSession] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editorDone, setEditorDone] = useState(false); + + useEffect(() => { + // localStorage is client-only, so this can't run during render. + if (!draftId) return; + setStoredSession(getStoredSessions().find(s => s.draftId === draftId) ?? null); + }, [draftId]); const [hackatimeProject, setHackatimeProject] = useState(null); const [isLoadingHackatime, setIsLoadingHackatime] = useState(false); const [isPublishing, setIsPublishing] = useState(false); @@ -78,6 +93,28 @@ export default function Page() { useInterval(async () => { if (!draftId || compilationStatus !== "waiting") return; + // While the session's edit hold is live, offer to resume editing instead of + // waiting out the hold. Asked directly of Lookout (the hold isn't visible through + // Lapse's status poll), and only until a definitive answer: a live hold opens the + // editor; a session past its hold can never become editable again, so stop asking. + if (storedSession && !editorOpen && !editorDone) { + try { + const res = await fetch( + `${storedSession.lookoutApiBaseUrl}/api/sessions/${storedSession.lookoutToken}/status` + ); + if (res.ok) { + const status: { editable?: boolean; editHoldUntil?: string } = await res.json(); + if (status.editable || status.editHoldUntil) { + setEditorOpen(true); + } else { + setEditorDone(true); + } + } + } catch (err) { + console.warn("(publish.tsx) edit-hold probe error:", err); + } + } + try { const res = await api.timelapse.pollLookoutStatus({ draftId }); if (!res.ok) { @@ -323,6 +360,14 @@ export default function Page() { )} + {editorOpen && storedSession && ( + { setEditorOpen(false); setEditorDone(true); }} + /> + )} + Date: Mon, 10 Aug 2026 06:05:54 +0800 Subject: [PATCH 3/6] chore: bump Lookout to 80eb3c6 for the WebCodecs clip encode path --- vendor/lookout | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/lookout b/vendor/lookout index 1e4fdd7..80eb3c6 160000 --- a/vendor/lookout +++ b/vendor/lookout @@ -1 +1 @@ -Subproject commit 1e4fdd7523a00b27c4a1b08b8bfc857f92ca900d +Subproject commit 80eb3c6aa2f834380969b5988a341bcf78583d93 From 0ca89b0c9b165bcdeb9832fc4a2d9ba271983dcc Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:07:47 +0800 Subject: [PATCH 4/6] chore: lock mp4-muxer for the SDK's WebCodecs clip encoder --- pnpm-lock.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4501b3..1775028 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,9 @@ importers: motion: specifier: ^12.38.0 version: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + mp4-muxer: + specifier: ^5.2.2 + version: 5.2.2 devDependencies: '@testing-library/dom': specifier: ^10.4.1 @@ -3582,6 +3585,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dom-webcodecs@0.1.18': + resolution: {integrity: sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -3644,6 +3650,9 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/wicg-file-system-access@2020.9.8': + resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -6113,6 +6122,10 @@ packages: react-dom: optional: true + mp4-muxer@5.2.2: + resolution: {integrity: sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==} + deprecated: This library is superseded by Mediabunny. Please migrate to it. + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -10863,6 +10876,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dom-webcodecs@0.1.18': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -10925,6 +10940,8 @@ snapshots: '@types/whatwg-mimetype@3.0.2': {} + '@types/wicg-file-system-access@2020.9.8': {} + '@types/ws@8.18.1': dependencies: '@types/node': 26.1.2 @@ -13631,6 +13648,11 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + mp4-muxer@5.2.2: + dependencies: + '@types/dom-webcodecs': 0.1.18 + '@types/wicg-file-system-access': 2020.9.8 + mrmime@2.0.1: {} ms@2.1.3: {} From 09b5bfbe5483392316cdc1860f62768f08e5aac3 Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:24:12 +0800 Subject: [PATCH 5/6] chore: hoist vitest for the vendored shared package's build The Lookout bump adds `clockOffset.test.ts` to `@lookout/shared`, whose build is a plain `tsc` over all of `src`. Nothing in the Lapse workspace provides `vitest`, so the test file's import fails to resolve and the root build dies on TS2307 before anything else compiles. Add `vitest` alongside the `tsup` that's already hoisted for the vendored React client, so the vendored packages' tooling resolves from the workspace root the same way it does in the Lookout repo. Co-Authored-By: Claude Opus 5 --- package.json | 3 ++- pnpm-lock.yaml | 64 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f4de402..6fd5318 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", "devDependencies": { "tsup": "^8.5.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "scripts": { "build": "pnpm --filter @lookout/shared run build && pnpm -r run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1775028..fc6eb96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)) apps/client: dependencies: @@ -11249,7 +11252,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)) '@vitest/expect@4.1.10': dependencies: @@ -11260,6 +11263,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 + '@vitest/mocker@4.1.10(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@26.1.2)(typescript@6.0.3) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2) + '@vitest/mocker@4.1.10(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.10 @@ -11296,7 +11308,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)) '@vitest/utils@4.1.10': dependencies: @@ -15100,6 +15112,22 @@ snapshots: optionalDependencies: typescript: 6.0.3 + vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.49.0 + tsx: 4.23.4 + yaml: 2.8.2 + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2): dependencies: lightningcss: 1.33.0 @@ -15116,6 +15144,38 @@ snapshots: tsx: 4.23.4 yaml: 2.8.2 + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.2 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + '@vitest/ui': 4.1.10(vitest@4.1.10) + happy-dom: 20.11.1 + jsdom: 27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1) + transitivePeerDependencies: + - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.1)(jsdom@27.4.0(@noble/hashes@2.2.0)(supports-color@8.1.1))(msw@2.12.10(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.10 From 2c7bd0e91fc126c3498d767be4942b0aedfac76c Mon Sep 17 00:00:00 2001 From: Anson Chung <58066418+anscg@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:11:00 +0800 Subject: [PATCH 6/6] feat: bring desktop recorders back to Lapse when their timelapse lands Desktop is the default recording mode, and the tab that launched it had no way of knowing the recording ever finished: the SDK status effect that navigates to publish only exists while Lapse itself is capturing, and the site-wide draft check in `_app.tsx` deliberately skips `/timelapse/create`. So the one page a desktop recorder is left on is the one page that can't move them along, and getting to publish meant wandering back into Lapse and being redirected from somewhere else. Lookout has had a redirect hook for this since `3bbf3ca` - the desktop app opens it in the user's default browser the moment the session flips to `complete`, including after publishing from its own editor. We just never set it. Now session creation points it at a handoff page of our own. The hook is immutable once a session exists, which shapes two things: the draft's ID is minted before the session so the URL can contain it, and the hook targets `/timelapse/handoff/:draftId` rather than the publish page directly, so the URL every past session carries stays valid if the flow behind it moves. That page also can't assume a signed-in user, since it opens in whichever browser is the OS default. Rather than a second sign-in, it points them back to the browser they recorded from - where opening Lapse at all lands them on publish already - and keeps sign-in as a fallback. Alongside it, two things the desktop flow was missing: - The `lookout://` deep link is now shown on the "Opening Lookout" screen, copyable. Lookout has a paste-a-link box for when the handoff doesn't fire, and this is the only place that link exists to be copied from. - Lapse no longer opens its own cut editor for a desktop recording. The edit hold on such a session belongs to the desktop app's editor window, which is likely open on it right now; a second editor here had both surfaces renewing the same lease and racing to write cuts. The status poll now reports whether Lookout recorded the session from the desktop (via its `clientInfo`), and runs before the edit-hold probe so the probe knows to stay out of it. Deploying this needs `WEB_BASE_URL` set - it's where the hook points, and session creation throws without it. Co-Authored-By: Claude Opus 5 --- .../components/lookout/LookoutRecorder.tsx | 51 +++++-- apps/client/src/components/ui/CopyField.tsx | 96 +++++++++++++ apps/client/src/pages/_app.tsx | 6 +- .../src/pages/timelapse/handoff/[id].tsx | 128 ++++++++++++++++++ .../src/pages/timelapse/publish/[id].tsx | 57 +++++--- apps/server/.env.example | 4 + apps/server/src/env.ts | 7 + apps/server/src/lookout.ts | 20 ++- apps/server/src/routers/timelapse.ts | 16 ++- packages/api/src/contracts/timelapse.ts | 3 + 10 files changed, 355 insertions(+), 33 deletions(-) create mode 100644 apps/client/src/components/ui/CopyField.tsx create mode 100644 apps/client/src/pages/timelapse/handoff/[id].tsx diff --git a/apps/client/src/components/lookout/LookoutRecorder.tsx b/apps/client/src/components/lookout/LookoutRecorder.tsx index a9edac0..3f3e144 100644 --- a/apps/client/src/components/lookout/LookoutRecorder.tsx +++ b/apps/client/src/components/lookout/LookoutRecorder.tsx @@ -19,8 +19,14 @@ import { EditorModal } from "@/components/lookout/EditorModal"; import { Modal, ModalHeader, ModalContent } from "@/components/layout/Modal"; import { LoadingModal } from "@/components/layout/LoadingModal"; import { ErrorModal } from "@/components/layout/ErrorModal"; +import { CopyField } from "@/components/ui/CopyField"; import { PillControlButton } from "@/components/ui/PillControlButton"; +/** The deep link that hands a recording session to the Lookout desktop app. */ +function desktopSessionLink(token: string): string { + return `lookout://session?token=${token}`; +} + function formatTrackedTime(totalSeconds: number): string { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); @@ -625,7 +631,7 @@ export default function LookoutRecorder() { function startWithConfig(cfg: LookoutSessionConfig) { if (selectedMode === "desktop") { - window.location.href = `lookout://session?token=${cfg.lookoutToken}`; + window.location.href = desktopSessionLink(cfg.lookoutToken); setDesktopLaunched(true); return; } @@ -677,9 +683,11 @@ export default function LookoutRecorder() { } if (desktopLaunched && config) { + const sessionLink = desktopSessionLink(config.lookoutToken); + return ( -
+
Lookout
@@ -687,8 +695,12 @@ export default function LookoutRecorder() {

The Lookout app should have opened on your desktop. If nothing happened, you may need to install it first.

+

+ We'll bring you back here to publish once your timelapse is done. +

+ {/* Installing is the way out of this screen for most people who end up reading it, so it leads. */} Get Lookout - + +
+ + + Open Lookout again + +
+
+ + {/* + The deep link, in the flesh. Lookout has a "paste a lookout:// link" box precisely for when the + handoff doesn't fire on its own (a browser that swallows unknown schemes, a fresh install that + hasn't registered one yet), and this is the only place the link exists to be copied from. + */} +
+

+ Still nothing? Copy this link and paste it into Lookout. +

+
diff --git a/apps/client/src/components/ui/CopyField.tsx b/apps/client/src/components/ui/CopyField.tsx new file mode 100644 index 0000000..6b6eccf --- /dev/null +++ b/apps/client/src/components/ui/CopyField.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import Icon from "@hackclub/icons"; + +/** + * A read-only field holding something the user has to take somewhere else - a link, a token - with a copy button + * beside it. The value stays visible and selects itself on focus, because copying needs a secure context and the + * user's permission, and a button that silently does nothing is worse than a field they can select by hand. + */ +export function CopyField({ value, label, className }: { + value: string; + + /** Describes the value for screen readers, e.g. "Recording session link". */ + label: string; + + className?: string; +}) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef | null>(null); + + // Copying right before this unmounts would otherwise leave the timer to set state on a gone component. + useEffect(() => () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }, []); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + } catch { + // No clipboard access - the field is selectable for exactly this case, so leave it be. + return; + } + + setCopied(true); + if (resetTimer.current) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), 2000); + } + + return ( +
+ e.currentTarget.select()} + aria-label={label} + className="flex-1 min-w-0 h-10 bg-darkless border border-slate rounded-lg px-3 text-xs font-mono text-secondary" + /> + + +
+ ); +} diff --git a/apps/client/src/pages/_app.tsx b/apps/client/src/pages/_app.tsx index 739bc2a..9f694ac 100644 --- a/apps/client/src/pages/_app.tsx +++ b/apps/client/src/pages/_app.tsx @@ -31,7 +31,11 @@ const App: AppType = ({ Component, pageProps }) => { }, [router]); useEffect(() => { - if (router.pathname.startsWith("/timelapse/publish") || router.pathname.startsWith("/timelapse/create")) + // `/timelapse/handoff` is exempt for the same reason as the other two: it's already taking the user to + // publish, and it has an unauthenticated state to show first that this redirect would trample. + if (router.pathname.startsWith("/timelapse/publish") + || router.pathname.startsWith("/timelapse/create") + || router.pathname.startsWith("/timelapse/handoff")) return; // Never probe a protected endpoint on the auth pages or without a session. The request would 401 and diff --git a/apps/client/src/pages/timelapse/handoff/[id].tsx b/apps/client/src/pages/timelapse/handoff/[id].tsx new file mode 100644 index 0000000..a31dc78 --- /dev/null +++ b/apps/client/src/pages/timelapse/handoff/[id].tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import Icon from "@hackclub/icons"; + +import { api } from "@/api"; +import { useAuthContext } from "@/context/AuthContext"; + +import RootLayout from "@/components/layout/RootLayout"; +import { Button } from "@/components/ui/Button"; +import { CopyField } from "@/components/ui/CopyField"; +import { LoadingModal } from "@/components/layout/LoadingModal"; +import { NOT_FOUND_STATUS, StatusPage } from "@/components/layout/StatusPage"; + +/** + * Where Lookout sends the user when a timelapse they recorded in the desktop app finishes compiling (its + * `redirectUrl` hook, set when we create the session). It opens in their *default* browser, which is not + * necessarily the one they started the recording in - so unlike the rest of the recording flow, this page + * can't assume a signed-in session, and says so instead of bouncing them into OAuth unannounced. + * + * It exists as its own route rather than pointing the hook straight at `/timelapse/publish/:id` because the + * hook is immutable: every session ever created carries the URL it was born with, forever. Keeping a page of + * our own in front of the publish flow means that URL stays valid even if the flow behind it moves. + */ +export default function Page() { + const router = useRouter(); + const { currentUser, isLoading } = useAuthContext(); + + const rawId = router.query.id as string | undefined; + const draftId = rawId && rawId !== "undefined" ? rawId : undefined; + + const [gone, setGone] = useState(false); + // This page's own URL, for the "wrong browser" case below. Read after mount - it doesn't exist during + // prerender, and it has to be the absolute form to be worth pasting anywhere. + const [handoffUrl, setHandoffUrl] = useState(""); + + useEffect(() => { + setHandoffUrl(window.location.href); + }, []); + + useEffect(() => { + if (!router.isReady || isLoading || !draftId) return; + + // Signing in returns here, not to the homepage, so the handoff survives the detour. + if (!currentUser) return; + + (async () => { + // The draft is the whole point of the trip - if it's already been published or discarded (from another + // tab, another device, or an earlier visit to this same link), there's nothing to hand off to. + try { + const res = await api.timelapse.pollLookoutStatus({ draftId }); + // Only a missing draft ends the trip here. Any other failure (Lookout being down, say) is the publish + // page's problem - it polls the same endpoint and reports errors properly. + if (!res.ok && res.error === "NOT_FOUND") { + setGone(true); + return; + } + } catch { + // A network blip shouldn't strand the user here either. + } + + router.replace(`/timelapse/publish/${draftId}`); + })(); + }, [router, router.isReady, isLoading, currentUser, draftId]); + + if (router.isReady && !draftId) { + return ; + } + + if (gone) { + return ( + +
+
+ +
+

Nothing left to publish

+

+ This recording has already been published or discarded. Your timelapses are on your profile. +

+
+ +
+
+
+ ); + } + + // Lookout opens the redirect in the user's *default* browser, which isn't necessarily the one they started + // recording in. Rather than pushing them through a second sign-in here, point them back at the browser that + // already has their session: opening Lapse there lands them on this timelapse's publish page by itself (see + // the draft check in `_app.tsx`). Signing in here still works for anyone who'd rather - or has to. + if (!isLoading && !currentUser) { + return ( + +
+
+ +
+

Your timelapse is ready

+

+ This browser isn't signed in to Lapse. Head back to the browser you started recording + in - it'll pick up right where you left off. +

+
+ +
+

Or paste this link over there:

+ +
+ + + Sign in here instead + +
+
+
+ ); + } + + return ( + + + + ); +} diff --git a/apps/client/src/pages/timelapse/publish/[id].tsx b/apps/client/src/pages/timelapse/publish/[id].tsx index 91b13a6..6b9133b 100644 --- a/apps/client/src/pages/timelapse/publish/[id].tsx +++ b/apps/client/src/pages/timelapse/publish/[id].tsx @@ -73,6 +73,9 @@ export default function Page() { const [storedSession, setStoredSession] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [editorDone, setEditorDone] = useState(false); + // Set from the status poll. Desktop recordings are edited in the desktop app, never here. `null` until a poll + // says either way - the editor stays shut while we don't know, rather than guessing and opening it. + const [recordedOnDesktop, setRecordedOnDesktop] = useState(null); useEffect(() => { // localStorage is client-only, so this can't run during render. @@ -93,27 +96,9 @@ export default function Page() { useInterval(async () => { if (!draftId || compilationStatus !== "waiting") return; - // While the session's edit hold is live, offer to resume editing instead of - // waiting out the hold. Asked directly of Lookout (the hold isn't visible through - // Lapse's status poll), and only until a definitive answer: a live hold opens the - // editor; a session past its hold can never become editable again, so stop asking. - if (storedSession && !editorOpen && !editorDone) { - try { - const res = await fetch( - `${storedSession.lookoutApiBaseUrl}/api/sessions/${storedSession.lookoutToken}/status` - ); - if (res.ok) { - const status: { editable?: boolean; editHoldUntil?: string } = await res.json(); - if (status.editable || status.editHoldUntil) { - setEditorOpen(true); - } else { - setEditorDone(true); - } - } - } catch (err) { - console.warn("(publish.tsx) edit-hold probe error:", err); - } - } + // The status poll comes first: it's what tells us whether this session was recorded on the desktop, which + // the edit-hold probe below needs to know before it decides to open anything. + let onDesktop = recordedOnDesktop; try { const res = await api.timelapse.pollLookoutStatus({ draftId }); @@ -124,6 +109,9 @@ export default function Page() { return; } + onDesktop = res.data.recordedOnDesktop; + setRecordedOnDesktop(onDesktop); + if (res.data.lookoutStatus === "complete") { setCompilationStatus("ready"); setVideoUrl(res.data.videoUrl); @@ -135,6 +123,33 @@ export default function Page() { } catch (err) { console.warn("(publish.tsx) poll error:", err); } + + // While the session's edit hold is live, offer to resume editing instead of + // waiting out the hold. Asked directly of Lookout (the hold isn't visible through + // Lapse's status poll), and only until a definitive answer: a live hold opens the + // editor; a session past its hold can never become editable again, so stop asking. + // + // Desktop recordings are the exception. Their hold belongs to the desktop app's own editor window, which + // is very likely open on this exact session right now - a second editor here would have both surfaces + // renewing the same lease and racing to write cuts, last one winning. So we stay out of it and just wait + // for the compile, which is what the user is watching the app finish anyway. + if (storedSession && onDesktop === false && !editorOpen && !editorDone) { + try { + const res = await fetch( + `${storedSession.lookoutApiBaseUrl}/api/sessions/${storedSession.lookoutToken}/status` + ); + if (res.ok) { + const status: { editable?: boolean; editHoldUntil?: string } = await res.json(); + if (status.editable || status.editHoldUntil) { + setEditorOpen(true); + } else { + setEditorDone(true); + } + } + } catch (err) { + console.warn("(publish.tsx) edit-hold probe error:", err); + } + } }, 3000); function handleVisibilitySelect() { diff --git a/apps/server/.env.example b/apps/server/.env.example index 415362f..6df981c 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -25,6 +25,10 @@ LOOKOUT_API_KEY= # Hackatime for the callback URL. BASE_URL=http://localhost:3123 +# Where the canonical web client lives. This is the one we send *users* to from external services - for example, Lookout +# sends a user to "$WEB_BASE_URL/timelapse/handoff/:draftId" once their timelapse finishes compiling. No trailing slash. +WEB_BASE_URL=http://localhost:3000 + # We'll take the user here when they need to authorize an OAuth2 app. This should point to a client that is canonical (or otherwise # has access to the `elevated` scope for users) CONSENT_URL=http://localhost:3000/oauth/authorize diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts index 8806904..582c71f 100644 --- a/apps/server/src/env.ts +++ b/apps/server/src/env.ts @@ -7,6 +7,13 @@ export const env = { */ get BASE_URL() { return required("BASE_URL") }, + /** + * The base URL the canonical web client is hosted on (no trailing slash). Unlike `BASE_URL`, this points at the + * frontend - it's what we hand to external services that send the *user* (not a request) somewhere in Lapse, such + * as Lookout's redirect hook. + */ + get WEB_BASE_URL() { return required("WEB_BASE_URL") }, + /** * The S3 name for the bucket that stores encrypted (private) user content. */ diff --git a/apps/server/src/lookout.ts b/apps/server/src/lookout.ts index 20b8bb4..1a24b2c 100644 --- a/apps/server/src/lookout.ts +++ b/apps/server/src/lookout.ts @@ -57,13 +57,17 @@ async function lookoutFetch(path: string, options?: RequestInit): Promise export async function createSession( name?: string, metadata?: Record, - options?: { clips?: boolean } + options?: { clips?: boolean; redirectUrl?: string } ): Promise { const result = await lookoutFetch("/api/internal/sessions", { method: "POST", // `clips` (not `clip`/`clipsEnabled`) is the field that flips `clipsEnabled` on // the session, unlocking the cut/edit flow. - body: JSON.stringify({ name, metadata, clips: options?.clips }), + // + // `redirectUrl` is Lookout's redirect hook: the recording client sends the user there once the + // timelapse finishes compiling (the desktop app opens it in their default browser). It's immutable + // after creation, and Lookout only accepts http(s). + body: JSON.stringify({ name, metadata, clips: options?.clips, redirectUrl: options?.redirectUrl }), }); logInfo(`Created Lookout session ${result.sessionId}`); @@ -74,6 +78,18 @@ export async function getSession(sessionId: string): Promise(`/api/internal/sessions/${sessionId}`); } +/** + * Whether a session was recorded through the Lookout desktop app, rather than in a browser through our own recorder. + * + * `clientInfo` is Lookout's User-Agent-like telemetry string, recorded on the session's *first* upload - so it's + * `null` for a session that never uploaded anything. The format is a convention rather than something Lookout + * enforces (see `formatClientInfo` in `@lookout/shared`), which is why this is a lenient prefix match: anything we + * can't confidently read as the desktop app counts as not-desktop, leaving Lapse's own behaviour as the default. + */ +export function isDesktopClient(clientInfo: string | null): boolean { + return /^Lookout Desktop\b/i.test(clientInfo ?? ""); +} + export async function getTimings(token: string): Promise { const url = `${env.LOOKOUT_API_BASE_URL}/api/sessions/${token}/timings`; const res = await fetch(url); diff --git a/apps/server/src/routers/timelapse.ts b/apps/server/src/routers/timelapse.ts index 176cab0..599bbbd 100644 --- a/apps/server/src/routers/timelapse.ts +++ b/apps/server/src/routers/timelapse.ts @@ -504,14 +504,27 @@ export default os.router({ .handler(async (req) => { const caller = req.context.user; + // Lookout's redirect hook has to be set at creation and can never be changed, so the draft's ID - which + // the hook's URL points at - has to exist before the session does. Mint it here instead of letting the + // database default do it; `lapseId()` is the same 12-character NanoID the schema would have generated. + const draftId = lapseId(); + const session = await lookout.createSession(undefined, { lapseUserId: caller.id, lapseUserHandle: caller.handle, source: "lapse", - }, { clips: true }); + }, { + clips: true, + // Recording in the desktop app leaves this tab with no way of knowing the timelapse is done, so we ask + // Lookout to send the user back to us the moment it compiles. Built here rather than accepted from the + // caller: any client holding `timelapse:write` could otherwise have the desktop app open an arbitrary + // URL in the user's default browser. + redirectUrl: `${env.WEB_BASE_URL}/timelapse/handoff/${draftId}`, + }); const draft = await database().draftLookoutTimelapse.create({ data: { + id: draftId, lookoutSessionId: session.sessionId, lookoutToken: session.token, ownerId: caller.id, @@ -592,6 +605,7 @@ export default os.router({ lookoutStatus: session.session.status, videoUrl: session.session.videoUrl, thumbnailUrl: session.session.thumbnailUrl, + recordedOnDesktop: lookout.isDesktopClient(session.clientInfo), }); }), diff --git a/packages/api/src/contracts/timelapse.ts b/packages/api/src/contracts/timelapse.ts index db139c5..870bd3f 100644 --- a/packages/api/src/contracts/timelapse.ts +++ b/packages/api/src/contracts/timelapse.ts @@ -305,6 +305,9 @@ export const timelapseRouterContract = { .describe("The video URL, if compilation is complete."), thumbnailUrl: z.string().nullable() .describe("The thumbnail URL, if compilation is complete."), + + recordedOnDesktop: z.boolean() + .describe("Whether the session was recorded through the Lookout desktop app. Editing such a session belongs to the desktop app's own editor, so Lapse leaves it alone."), }) ),