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 0cb98f0..3f3e144 100644 --- a/apps/client/src/components/lookout/LookoutRecorder.tsx +++ b/apps/client/src/components/lookout/LookoutRecorder.tsx @@ -15,11 +15,18 @@ 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"; +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); @@ -624,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; } @@ -676,9 +683,11 @@ export default function LookoutRecorder() { } if (desktopLaunched && config) { + const sessionLink = desktopSessionLink(config.lookoutToken); + return ( -
+
Lookout
@@ -686,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. +

+
@@ -731,6 +767,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 +906,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 +917,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 +982,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 +1011,32 @@ 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); }} + /> + + ); + } + // 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/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 af8efb1..6b9133b 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,23 @@ 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); + // 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. + 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 +96,10 @@ export default function Page() { useInterval(async () => { if (!draftId || compilationStatus !== "waiting") return; + // 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 }); if (!res.ok) { @@ -87,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); @@ -98,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() { @@ -323,6 +375,14 @@ export default function Page() { )}
+ {editorOpen && storedSession && ( + { setEditorOpen(false); setEditorDone(true); }} + /> + )} + (path: string, options?: RequestInit): Promise export async function createSession( name?: string, - metadata?: Record + metadata?: Record, + options?: { clips?: boolean; redirectUrl?: string } ): 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. + // + // `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}`); @@ -71,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 27be7f5..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, + // 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/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/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."), }) ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59de37c..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: @@ -429,6 +432,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) @@ -438,6 +447,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 @@ -512,6 +524,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 +2609,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 +2739,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==} @@ -3560,6 +3588,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==} @@ -3622,6 +3653,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==} @@ -4935,6 +4969,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} @@ -6088,6 +6125,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'} @@ -6231,6 +6272,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 +9911,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 +10129,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': {} @@ -10823,6 +10879,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dom-webcodecs@0.1.18': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -10885,6 +10943,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 @@ -11192,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: @@ -11203,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 @@ -11239,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: @@ -12464,6 +12533,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.18.0 @@ -13589,6 +13660,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: {} @@ -13756,6 +13832,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: {} @@ -15032,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 @@ -15048,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 diff --git a/vendor/lookout b/vendor/lookout index a76dcf4..80eb3c6 160000 --- a/vendor/lookout +++ b/vendor/lookout @@ -1 +1 @@ -Subproject commit a76dcf48c2fefb68ea3ba1caba12f68c14b25e2b +Subproject commit 80eb3c6aa2f834380969b5988a341bcf78583d93