diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 554024276..bd60324ee 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,6 +53,23 @@ jobs: - name: Type-check working-directory: website run: npm run typecheck + # The walkthrough's clips and stills are committed to this repo, which has + # no LFS filter — their size is permanent, so a budget that lives only in a + # design note drifts on the first re-cut. This also fails any clip encoded + # with an audio track, which iOS Safari refuses to autoplay without a + # gesture even when the element is muted. + - name: Check media budget + working-directory: website + run: npm run check:media + # The recreation's data is generated from the app's own source and a + # vendored project document. It is committed, so it can go stale in either + # direction — a locale key moves, a timeline constant changes — and the + # page would then be showing numbers the application no longer produces. + # The generator compares its own output byte for byte, which is why the + # formatter is told to leave that one file alone (biome.json). + - name: Check recreation data + working-directory: website + run: npm run check:recreation - name: Build working-directory: website run: npm run build diff --git a/biome.json b/biome.json index b7c87298d..4fa1d2fdc 100644 --- a/biome.json +++ b/biome.json @@ -7,6 +7,7 @@ }, "formatter": { "enabled": true, + "includes": ["**", "!website/src/components/Recreation/generated.ts"], "indentStyle": "tab", "formatWithErrors": true, "lineEnding": "lf", diff --git a/website/docs/ai-editing.md b/website/docs/ai-editing.md index 93d88e9dd..a4cab587e 100644 --- a/website/docs/ai-editing.md +++ b/website/docs/ai-editing.md @@ -13,10 +13,10 @@ keywords: # AI editing -OpenScreen ships an optional agent that edits your project from a chat panel. It is **off until you connect a provider yourself**, and it is the only part of the app that talks to a network. +OpenScreen ships an optional agent that edits your project from a chat panel. It is **off until you connect a provider yourself**. Apart from the one-time Whisper model download, the provider you connect is the only network OpenScreen uses — for this agent, and for [caption translation](./captions.md#translation). :::tip -None of this is required. Recording, editing, transcription, captions, and export all work with zero network access and no account, whether or not you ever open the chat panel. +None of this is required. Recording, editing, transcription, captions, and export all work with no account and no provider, whether or not you ever open the chat panel — the only network any of them touches is the [one-time Whisper model download](./captions.md#transcribing) on your first transcription. ::: ## Connecting a provider diff --git a/website/docs/captions.md b/website/docs/captions.md index 9d7c14a23..ce2fa849a 100644 --- a/website/docs/captions.md +++ b/website/docs/captions.md @@ -14,7 +14,7 @@ keywords: # Captions & transcript -OpenScreen transcribes your recording's audio **entirely on-device** — nothing is uploaded, and it works offline. That one transcript is then the source for two things: the captions burned into your video, and a text view you can edit your recording from. +OpenScreen transcribes your recording's audio **entirely on-device** — your audio is never uploaded, and once the model is on disk it works offline. That one transcript is then the source for two things: the captions burned into your video, and a text view you can edit your recording from. ## Transcribing @@ -23,7 +23,7 @@ Every clip carries its own transcript. Run it either way: - From the **Media** stage — select an asset card and hit **Regenerate**. This is also where you force a language (Auto, English, French, Spanish) instead of letting Whisper detect it, and where per-asset status lives (Pending, Transcribing, Generated, Failed). - From the **Captions** facet in the editor's inspector — **Transcribe video** runs the same pipeline on the current media. -The first run downloads a local Whisper model (~264 MB, SHA-256 verified, written atomically so a half-download can never be picked up). After that, transcription is fully offline. It runs natively — whisper.cpp with a GPU backend picked at runtime: Metal on Apple Silicon, Vulkan on Windows and Linux, CPU everywhere else. +The whisper.cpp engine ships inside the app; the model does not. The first run downloads it from huggingface.co (~264 MB, SHA-256 verified, written atomically so a half-download can never be picked up) — the one moment transcription needs a network. After that it is fully offline, on a GPU backend picked at runtime: Metal on Apple Silicon, Vulkan on Windows and Linux, CPU everywhere else. Word timings come from Whisper's own DTW token timestamps, then get re-anchored on the audio itself — every boundary is pulled back to the quietest moment just before it. This is what makes a transcript-driven cut land where the word actually starts instead of a syllable late. @@ -42,7 +42,7 @@ Open the **Captions** facet in the inspector: | **Position** | Top / Middle / Bottom, left / center / right alignment, a fine vertical offset, and band width as a % of the frame. | | **Line length** | Min and max words per line (1–12). Lines are packed inside that range. | -Size is expressed in pixels at a 1080-high frame and scales with the real output, so captions look the same at 720p, 1080p, or source. Preview and export share the same layout code — what you see is what gets burned in. +Size is expressed in pixels at a 1080-high frame and scales with the real output, so captions look the same at 720p, 1080p, or source. Preview and export share the same layout code — what you see is what gets burned in. Burned in is the only form they take: OpenScreen writes no sidecar `.srt` or `.vtt`, so captions can't be turned off by whoever watches the file. ### Translation diff --git a/website/docs/export.md b/website/docs/export.md index e0cad0739..b62981808 100644 --- a/website/docs/export.md +++ b/website/docs/export.md @@ -43,12 +43,12 @@ If something fails during render or write, the dialog shows the error so you can ## How MP4 is rendered -MP4 export runs through the same native Rust + Direct3D 11 compositor that draws the live preview, one clip at a time, on a single GPU device: demux → decode → composite → hardware encode → mux, with no CPU readback in between. The preview pauses itself for the duration so the two aren't fighting over the GPU. +MP4 export runs through the same native Rust compositor that draws the live preview — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — one clip at a time, on a single GPU device: demux → decode → composite → encode → mux. On Windows and macOS the encoder takes the composed frame straight off the GPU, with no CPU readback in between; on Linux the frame is read back and encoded in software. The preview pauses itself for the duration so the two aren't fighting over the GPU. Because preview and export consume the same scene description, the frame you're looking at is the frame you get — there is no separate export renderer that could drift. -:::warning Platform support -The native compositor is **Windows-only today**, so MP4 export currently requires Windows. GIF export has its own renderer-side path and works on all three platforms. See the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md) for status. +:::note Platform support +MP4 and GIF export both work on Windows, macOS, and Linux. The one difference left is speed: the Linux encode is software rather than hardware today, so the same export takes longer there. See the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md) for status. ::: ## Exported file vs. project file diff --git a/website/docs/installation.md b/website/docs/installation.md index 0184d9bb4..4a7e87809 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -100,7 +100,7 @@ You may need to grant screen-recording permission depending on your desktop envi ## Platform differences -The editing tools are the same everywhere — zooms, backgrounds, crop/trim/speed, annotations, transcription, captions, and projects. **Capture** and **MP4 export** differ: +The editing tools are the same everywhere — zooms, backgrounds, crop/trim/speed, annotations, transcription, captions, and projects. Every export format works on every platform; what differs is **capture**, and how fast MP4 encodes on Linux: | | macOS | Windows | Linux | |---|---|---|---| @@ -108,12 +108,12 @@ The editing tools are the same everywhere — zooms, backgrounds, crop/trim/spee | Custom cursor themes / click effects | ✅ | ✅ | ❌ (position-only, used for auto-zoom) | | Webcam | Native capture | Native capture | Browser capture (still works as PiP) | | System audio | macOS 13+; permission prompt on 14.2+; not available on macOS 12 and below | Works out of the box | Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+) | -| MP4 export | ❌ not yet | ✅ | ❌ not yet | +| MP4 export | ✅ | ✅ | ✅ (software encode) | | GIF export | ✅ | ✅ | ✅ | | On-device transcription | Metal (Apple Silicon) / CPU | Vulkan / CPU | Vulkan / CPU | -:::warning MP4 export is Windows-only for now -The GPU compositor behind the live preview and MP4 export is built on Direct3D 11 and currently ships only in the Windows build. Recording, editing, and GIF export work on all three platforms; MP4 export does not yet. Track it on the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md). +:::note MP4 export on Linux +The GPU compositor behind the live preview and MP4 export has three backends — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — and ships in all three builds. The Linux one encodes in software rather than on the GPU, so an export there takes longer than the same one on Windows or macOS; hardware encode is tracked on the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md). ::: Next: [Quick start](./quick-start.md) walks through your first recording. diff --git a/website/docs/intro.md b/website/docs/intro.md index 3db3a65be..2c404220b 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -15,7 +15,7 @@ keywords: # Welcome to OpenScreen -OpenScreen is a **free, open-source screen recorder and editor**. It uses native capture APIs (ScreenCaptureKit on macOS, Windows Graphics Capture on Windows) for low-overhead recording, and composites both the live preview and the final export on the GPU through a native Rust + Direct3D 11 renderer — one path, so what you see in the editor is what comes out of the export. +OpenScreen is a **free, open-source screen recorder and editor**. It uses native capture APIs (ScreenCaptureKit on macOS, Windows Graphics Capture on Windows) for low-overhead recording, and composites both the live preview and the final export on the GPU through a native Rust renderer (Direct3D 11 on Windows, Metal on macOS, wgpu on Linux) — one path, so what you see in the editor is what comes out of the export. :::warning OpenScreen is **not production-grade**. The project is in active development and rough edges are expected. @@ -31,7 +31,7 @@ OpenScreen is **not production-grade**. The project is in active development and - [Export](./export.md) to MP4 (720p/1080p/source, H.264 or H.265) or animated GIF. :::note -Recording, editing, transcription, captions, and export all work fully offline with no account. AI chat editing and caption translation are the only features that talk to a network — and only once you connect a provider yourself. +Recording, editing, transcription, captions, and export all work offline with no account. The one exception is the first transcription you ever run, which downloads its Whisper model (~264 MB) once — after that, transcription is offline too. AI chat editing and caption translation are the only features that keep talking to a network, and only once you connect a provider yourself. ::: ## Project facts diff --git a/website/package.json b/website/package.json index 41f29f241..f4fab4764 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,9 @@ "build": "docusaurus build", "serve": "docusaurus serve", "clear": "docusaurus clear", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:media": "node scripts/check-media-budget.mjs", + "check:recreation": "node scripts/gen-recreation.mjs --check" }, "dependencies": { "@docusaurus/core": "^3.9.2", diff --git a/website/scripts/check-media-budget.mjs b/website/scripts/check-media-budget.mjs new file mode 100644 index 000000000..0d008fe53 --- /dev/null +++ b/website/scripts/check-media-budget.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +/** + * Guards the landing page's media budget. + * + * The walkthrough's page weight is the whole argument for shipping video at all + * — a demo that makes the page slow has argued against the product it is + * demonstrating. Byte budgets that live only in a design document drift on the + * first re-cut, so they live here and the build fails on them. + * + * Everything under website/static/ is committed to git permanently: this repo + * has no LFS filter, and video neither compresses nor deltas, so a re-cut costs + * its full size again rather than a diff. The directory ceiling is the real + * constraint; the per-file ones just localise the failure. + * + * The audio check is not a nicety. WebKit grants gesture-free autoplay to media + * that contains no audio track — the `muted` attribute alone is not enough on + * iOS — and the masters these clips are cut from carry an AAC stream of digital + * silence. Encoding without `-an` produces a clip that silently refuses to play + * for a large share of visitors, and looks perfect on the machine that made it. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const ROOT = resolve(import.meta.dirname, ".."); + +const RULES = [ + { dir: "static/video", ext: [".mp4"], max: 200_000 }, + { dir: "static/img/walkthrough", ext: [".jpg", ".avif"], max: 70_000 }, + // The cursor packs, copied out of the application's own public/cursors. Each + // is a 32-logical sprite at 128px, so a five-figure file here means someone + // has shipped a full-resolution source by mistake. + { dir: "static/img/cursors", ext: [".png"], max: 12_000 }, +]; + +/** + * A `-scrub` clip is a different kind of file and gets a different ceiling. + * + * It is never played; scroll position seeks it. A seek into a long GOP has to + * decode from the preceding keyframe, so these are encoded all-intra — every + * frame its own keyframe — which is the entire reason they are large. Measured + * on the export beat at 960x540: 32 KB at `-g 60`, 402 KB all-intra at 30fps, + * 268 KB once dropped to 20fps. Twenty frames a second is finer than a scroll + * resolves, and it is where this ceiling was set. + * + * The generous per-file number is safe because TOTAL_MAX is the rule that + * actually protects the repository, and it did not move. + */ +const SCRUB_MAX = 300_000; +const TOTAL_MAX = 1_600_000; + +const problems = []; +let total = 0; +let counted = 0; + +function walk(dir) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; // an absent directory is a section not yet shot, not a failure + } + return entries.flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)])); +} + +/** The boxes a track's `hdlr` can be nested inside. Everything else is skipped + * whole — including `mdat`, which is the point. */ +const MP4_CONTAINERS = new Set(["moov", "trak", "mdia"]); + +/** + * True if the MP4 declares a sound track. Reads the `hdlr` boxes rather than + * shelling out to ffprobe, which is not guaranteed on a CI runner and would make + * this check quietly skippable — which is how the guarantee would be lost. + * + * Walked as a box tree, not searched for as a byte pattern. `mdat` is compressed + * picture: the twelve bytes that spell a sound handler can occur in it by + * chance, and the failure that would produce — a silent clip rejected for an + * audio track it does not have — is unreproducible and looks like a bug in this + * file rather than in the clip. + */ +function hasAudioTrack(file) { + const buf = readFileSync(file); + const walk = (start, end) => { + let pos = start; + while (pos + 8 <= end) { + let size = buf.readUInt32BE(pos); + let head = 8; + if (size === 1) { + if (pos + 16 > end) return false; + size = Number(buf.readBigUInt64BE(pos + 8)); + head = 16; + } else if (size === 0) { + size = end - pos; // the last box, extending to the end of the file + } + // A size that runs past its parent means the file is not what it says + // it is; stop rather than resync, which is how a scan gets back into + // payload bytes. + if (size < head || pos + size > end) return false; + const type = buf.toString("latin1", pos + 4, pos + 8); + if (type === "hdlr") { + // FullBox: 4 version/flags, 4 pre_defined, then handler_type. + if (buf.toString("latin1", pos + head + 8, pos + head + 12) === "soun") return true; + } else if (MP4_CONTAINERS.has(type) && walk(pos + head, pos + size)) { + return true; + } + pos += size; + } + return false; + }; + return walk(0, buf.length); +} + +/** + * Why an AVIF might not belong. It is offered *ahead of* the JPEG and never + * instead of it — an engine that cannot decode AVIF falls through to the same + * 's JPEG, and the schema.org screenshot points at the JPEG as well — + * so the pair only pays for itself while the AVIF is the smaller of the two. An + * encoder run that came out heavier would hand every modern browser the worse + * file, and the JPEG would still be in git behind it: two costs for no win. + */ +function avifProblem(file, bytes) { + const fallback = file.replace(/\.avif$/, ".jpg"); + if (!existsSync(fallback)) { + return "has no .jpg beside it — engines without AVIF would have nothing to fall back to"; + } + const fallbackBytes = statSync(fallback).size; + if (bytes >= fallbackBytes) { + return ( + `${bytes.toLocaleString()} B is no smaller than its ${fallbackBytes.toLocaleString()} B ` + + `JPEG. Re-encode it lower, or drop the AVIF and ship the JPEG alone.` + ); + } + return null; +} + +for (const rule of RULES) { + const abs = join(ROOT, rule.dir); + for (const file of walk(abs)) { + const rel = relative(ROOT, file); + const bytes = statSync(file).size; + total += bytes; + counted += 1; + + if (!rule.ext.some((e) => file.endsWith(e))) { + problems.push( + `${rel}: unexpected file type in ${rule.dir} (only ${rule.ext.join(" or ")} belongs here)`, + ); + continue; + } + const ceiling = /-scrub(-sm)?\.mp4$/.test(file) ? SCRUB_MAX : rule.max; + if (bytes > ceiling) { + problems.push( + `${rel}: ${bytes.toLocaleString()} B exceeds the ${ceiling.toLocaleString()} B ceiling`, + ); + } + const avifFault = file.endsWith(".avif") && avifProblem(file, bytes); + if (avifFault) problems.push(`${rel}: ${avifFault}`); + if (file.endsWith(".mp4") && hasAudioTrack(file)) { + problems.push( + `${rel}: carries an audio track. Re-encode with -an — iOS Safari refuses ` + + `gesture-free autoplay for media that has one, muted or not.`, + ); + } + } +} + +if (total > TOTAL_MAX) { + problems.push( + `walkthrough media totals ${total.toLocaleString()} B, over the ` + + `${TOTAL_MAX.toLocaleString()} B ceiling. These bytes are permanent in git history.`, + ); +} + +const summary = `${counted} file${counted === 1 ? "" : "s"}, ${total.toLocaleString()} B of ${TOTAL_MAX.toLocaleString()}`; + +if (problems.length) { + console.error(`media budget FAILED — ${summary}`); + for (const p of problems) console.error(` · ${p}`); + process.exit(1); +} + +console.log(`media budget ok — ${summary}`); diff --git a/website/scripts/gen-recreation.mjs b/website/scripts/gen-recreation.mjs new file mode 100644 index 000000000..f24e7ed92 --- /dev/null +++ b/website/scripts/gen-recreation.mjs @@ -0,0 +1,1446 @@ +#!/usr/bin/env node +/** + * Emits the data the scroll-driven editor recreation renders. + * + * The recreation draws the app's chrome in live DOM. Every string and every + * number it shows has to come from the same place the app gets it, or the page + * becomes a drawing of the product that drifts away from it silently — which is + * exactly what got the previous mock deleted. So nothing here is typed by hand + * that can be read or computed instead: + * + * - locale strings come from `src/i18n/locales/en/*.json`, through a `t()` + * that throws on a missing key and on an unresolved `{{interpolation}}`; + * - timecodes come from `formatSec` / `formatMs`, IMPORTED from + * `src/lib/ai-edition/timeline/format.ts`. A reimplementation is not a + * shortcut, it is the bug: a floor/remainder version renders `0:60.0` at + * 59.96 where the real one renders `1:00.0`, which is the carry defect that + * file's header documents as fixed. It does not bite a 40.033 s fixture, + * and that is what makes it dangerous; + * - zoom pill labels come from `effectiveZoomScale` / `ZOOM_DEPTH_SCALES`, + * imported from `zoom-scale.ts`. Three parts of the app each invented their + * own factor for depth 3 and all three were wrong; a hand-typed "1.8×" + * re-commits that bug in public and permanently; + * - the transcript flow — silence insertion, kept/removed tagging, trim runs + * — is produced by the app's own `buildClipSection`, imported from + * `aggregated-transcript.ts`; + * - the ruler step, the ruler label format and the waveform bar arithmetic are + * extracted, as source text, out of `V4Timeline.tsx` and evaluated. That + * file cannot be imported (it reaches Zustand, Radix and the `@/` alias), + * so the next best thing is to run its actual expressions rather than a + * paraphrase of them. + * + * Node 22 imports those `.ts` files directly, with no flags and no config + * (22.18+ strips types by default). The one thing it will not do is resolve the + * app's extensionless relative specifiers, so a resolve hook adds `.ts` — see + * `registerHooks` below. + * + * USAGE + * node scripts/gen-recreation.mjs # emit generated.ts from the vendored fixture + * node scripts/gen-recreation.mjs --check # emit nowhere; fail if the checked-in file differs + * node scripts/gen-recreation.mjs --vendor # re-read the .openscreen document + decode the + * # recording's audio, rewriting fixture-slim.json. + * # Needs the app's Application Support directory and + * # ffmpeg; run it on a machine that has the project. + * node scripts/gen-recreation.mjs --media # re-cut static/video/canvas-loop{,-sm}.mp4 and + * # static/img/walkthrough/canvas-poster.jpg. Needs + * # ffmpeg and the recording. Same caveat. + * + * `--check` is the mode CI runs. It must work from a clean checkout with no + * ffmpeg and no Application Support directory, which is the whole reason + * `fixture-slim.json` is vendored at all. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// ── where everything lives ────────────────────────────────────────────── +const HERE = dirname(fileURLToPath(import.meta.url)); +const WEBSITE = resolve(HERE, ".."); +const APP = resolve(WEBSITE, ".."); +const OUT_DIR = resolve(WEBSITE, "src/components/Recreation"); +const FIXTURE = resolve(OUT_DIR, "fixture-slim.json"); +const GENERATED = resolve(OUT_DIR, "generated.ts"); + +const ARGS = new Set(process.argv.slice(2)); +const CHECK = ARGS.has("--check"); + +/** The document this page is a recreation of. Outside the repo on purpose: it is + * a real project file in the app's own store, and `--vendor` slims it in. */ +const DOC_PATH = resolve( + homedir(), + "Library/Application Support/Openscreen/projects/proj_97e2ec4f-78a1-4198-9743-05511c204daa.openscreen", +); +/** The recording that document edits. 8 MB; never vendored, only sampled. */ +const RECORDING = resolve( + homedir(), + "Library/Application Support/openscreen/recordings/recording-1786032000000.mp4", +); +const FFMPEG = process.env.FFMPEG ?? "ffmpeg"; + +const SCHEMA_VERSION = 7; + +// ── importing the app's TypeScript directly ───────────────────────────── +// The app writes `import { trimAppliesToClip } from "./trim-mapping"` — +// extensionless, which the bundler resolves and Node's ESM resolver does not. +// Rather than copy the functions (the thing this generator exists to avoid), +// teach the resolver the same rule the bundler uses. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith(".") && !/\.[cm]?[jt]sx?$/.test(specifier)) { + const candidate = new URL(`${specifier}.ts`, context.parentURL); + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true }; + } + } + return nextResolve(specifier, context); + }, +}); + +const appModule = (rel) => import(pathToFileURL(resolve(APP, rel)).href); + +const { formatSec, formatMs } = await appModule("src/lib/ai-edition/timeline/format.ts"); +const { effectiveZoomScale } = await appModule("src/lib/ai-edition/timeline/zoom-scale.ts"); +const { buildClipSection, isSilenceWord, SILENCE_THRESHOLD_SEC } = await appModule( + "src/lib/ai-edition/timeline/aggregated-transcript.ts", +); +// Importable as-is: its only import is `import type { NativeCursorType }`, which +// Node's type stripping removes, so the `@/` alias never has to resolve. +const { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID, resolveCursorSprites } = await appModule( + "src/lib/cursor/cursorThemes.ts", +); + +// ── locale access that fails loudly ───────────────────────────────────── +const locale = (ns) => + JSON.parse(readFileSync(resolve(APP, `src/i18n/locales/en/${ns}.json`), "utf8")); +const LOCALES = { + editor: locale("editor"), + settings: locale("settings"), + timeline: locale("timeline"), +}; + +/** + * `t("settings:transcript.silence", { duration: "2.2" })`. + * + * Throws on a missing key and on a `{{placeholder}}` left in the result. A + * renamed key must break the build here rather than ship "transcript.title" as + * a heading, and a renamed *placeholder* must break it too — that failure is + * silent in the app (i18next leaves the token in place) and would be invisible + * on a marketing page nobody reads with a translator's eye. + */ +function t(path, vars = {}) { + const [ns, key] = path.split(":"); + let node = LOCALES[ns]; + if (!node) throw new Error(`unknown locale namespace ${ns}`); + for (const part of key.split(".")) { + node = node?.[part]; + if (node === undefined) throw new Error(`missing locale key ${path}`); + } + if (typeof node !== "string") throw new Error(`locale key ${path} is not a string`); + const out = node.replace(/\{\{(\w+)\}\}/g, (m, name) => { + if (!(name in vars)) throw new Error(`${path}: no value for {{${name}}}`); + return String(vars[name]); + }); + if (/\{\{/.test(out)) + throw new Error(`${path}: unresolved interpolation in ${JSON.stringify(out)}`); + return out; +} + +// ── expressions lifted out of V4Timeline.tsx ──────────────────────────── +// That file is 1,600 lines of Zustand, Radix and `@/` imports, so it cannot be +// imported. Its *arithmetic* can be, as text: pull the exact source of the +// numbers this page reproduces and evaluate it. A change to any of them lands +// here as a changed output, which `--check` turns into a failed build. +const V4_SRC = readFileSync(resolve(APP, "src/components/ai-edition/v4/V4Timeline.tsx"), "utf8"); + +function lift(re, what) { + const m = V4_SRC.match(re); + if (!m) + throw new Error(`could not lift ${what} out of V4Timeline.tsx — it moved or changed shape`); + return m[1]; +} + +const TICK_STEPS_SEC = JSON.parse( + `[${lift(/const TICK_STEPS_SEC = \[([^\]]+)\]/, "TICK_STEPS_SEC") + .replace(/\s+/g, "") + .replace(/,$/, "")}]`, +); +const MIN_LABEL_GAP_PX = Number(lift(/const MIN_LABEL_GAP_PX = (\d+)/, "MIN_LABEL_GAP_PX")); +const MINOR_PER_MAJOR = Number(lift(/const MINOR_PER_MAJOR = (\d+)/, "MINOR_PER_MAJOR")); +const AI_ENHANCE_PROMPT = JSON.parse( + lift(/const AI_ENHANCE_PROMPT =\s*\n?\s*("(?:[^"\\]|\\.)*")/, "AI_ENHANCE_PROMPT"), +); + +/** The ruler's label formatter, run rather than paraphrased. */ +const fmtTick = new Function( + `${lift(/(function fmtTick\(sec: number, stepSec: number\): string \{[\s\S]*?\n\})/, "fmtTick") + .replace(/: number/g, "") + .replace(/\): string/, ")")}; return fmtTick;`, +)(); + +/** ClipWaveform's three expressions, in its own words. */ +const waveBarCount = new Function( + "sourceStartSec", + "sourceEndSec", + `return ${lift(/const barCount = ([^;]+);/, "waveform barCount")};`, +); +const waveBarHeightPct = new Function( + "h", + `return ${lift(/height: `\$\{([^}]+)\}%`/, "waveform bar height")};`, +); +const waveBarOpacity = new Function( + "h", + `return ${lift(/opacity: ([^,\n]+),\n/, "waveform bar opacity")};`, +); + +// ── the fixture ───────────────────────────────────────────────────────── +if (ARGS.has("--vendor")) vendorFixture(); + +const doc = JSON.parse(readFileSync(FIXTURE, "utf8")); +if (doc.schemaVersion !== SCHEMA_VERSION) { + throw new Error(`fixture schemaVersion ${doc.schemaVersion}, expected ${SCHEMA_VERSION}`); +} + +/** + * Slim the real project document into the repo. + * + * Kept: everything the recreation reads, in its stored shape, so the vendored + * copy is still a schemaVersion-7 document and the guard's `schemaVersion` + * assertion means something. Dropped: `transcripts[]` (a byte-for-byte + * duplicate of `transcript`) and `transcript.segments` (103 single-word + * segments that restate `transcript.words`; the pane reads `words`, and the + * schema defaults `segments` to `[]`). That is half the file for nothing. + * + * Added: `vendoredWaveform`, which is NOT part of schemaVersion 7 and is + * namespaced so it reads as an intruder. The waveform is a property of the + * recording's audio, the recording is 8 MB and lives outside the repo, and the + * generator has to run in CI without it — so its 320 decoded amplitudes are + * vendored beside the document for exactly the reason the document itself is. + * + * Scrubbed: the two stored paths. They are absolute paths in the store of + * whoever last ran `--vendor`, which is a local account name committed to a + * public repository. Nothing here reads more than the file name — `decodePeaks` + * names its source with it, and the recreation never opens either file — so the + * directory is replaced with a placeholder home rather than dropped, and the + * document keeps the shape schemaVersion 7 gives it. + */ +const VENDOR_HOME = "/Users/example/Library/Application Support/openscreen/recordings"; +const scrubPath = (p) => + typeof p === "string" ? `${VENDOR_HOME}/${p.replace(/^.*[/\\]/, "")}` : p; + +function vendorFixture() { + const full = JSON.parse(readFileSync(DOC_PATH, "utf8")); + if (full.schemaVersion !== SCHEMA_VERSION) { + throw new Error(`document schemaVersion ${full.schemaVersion}, expected ${SCHEMA_VERSION}`); + } + const slim = { + schemaVersion: full.schemaVersion, + project: full.project, + assets: full.assets.map((a) => ({ + ...a, + originalPath: scrubPath(a.originalPath), + ...(a.cameraTrack + ? { cameraTrack: { ...a.cameraTrack, sourcePath: scrubPath(a.cameraTrack.sourcePath) } } + : {}), + })), + transcript: { + assetId: full.transcript.assetId, + language: full.transcript.language, + words: full.transcript.words, + }, + timeline: full.timeline, + annotations: full.annotations, + zoomRanges: full.zoomRanges, + legacyEditor: full.legacyEditor, + vendoredWaveform: decodePeaks(full.assets[0]), + }; + writeFileSync(FIXTURE, `${JSON.stringify(slim, null, "\t")}\n`); + console.log(`vendored ${FIXTURE}`); +} + +/** + * The 320 bar amplitudes the app's clip card draws. + * + * `audioPeaksWorker.ts` is a Web Worker whose only entry point is + * `self.onmessage`, so it cannot be imported the way `format.ts` can. Its block + * arithmetic is reproduced here — the one piece of app logic this file + * duplicates rather than runs, and the reason it is written out in full instead + * of summarised. Everything downstream of it (bar count, bar height, bar + * opacity) is lifted from V4Timeline.tsx and evaluated, not retyped. + */ +function decodePeaks(asset) { + const pcm = execFileSync( + FFMPEG, + ["-v", "error", "-i", RECORDING, "-vn", "-ac", "2", "-ar", "48000", "-f", "f32le", "-"], + { maxBuffer: 1 << 30, encoding: "buffer" }, + ); + const interleaved = new Float32Array( + pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength), + ); + const frames = interleaved.length / 2; + const duration = frames / 48000; + + // audioPeaksWorker.ts: N blocks of min/max over the channel-averaged signal. + const N = Math.min(24000, Math.ceil(duration * 200)); + const blockSize = frames / N; + const peaks = new Float32Array(N * 2); + for (let i = 0; i < N; i++) { + const start = Math.floor(i * blockSize); + const end = Math.floor((i + 1) * blockSize); + let minVal = 0; + let maxVal = 0; + for (let j = start; j < end; j++) { + const sample = (interleaved[j * 2] + interleaved[j * 2 + 1]) / 2; + if (sample < minVal) minVal = sample; + if (sample > maxVal) maxVal = sample; + } + peaks[i * 2] = minVal; + peaks[i * 2 + 1] = maxVal; + } + + // V4Timeline.tsx ClipWaveform: reduce the blocks to one amplitude per bar. + const durationSec = asset.durationSec; + const barCount = waveBarCount(0, durationSec); + const blocksPerSec = N / durationSec; + const endBlock = Math.min(N, Math.ceil(durationSec * blocksPerSec)); + const rangeBlocks = Math.max(1, endBlock); + const amps = []; + for (let i = 0; i < barCount; i++) { + const blockStart = Math.floor((i / barCount) * rangeBlocks); + const blockEnd = Math.max(blockStart + 1, Math.floor(((i + 1) / barCount) * rangeBlocks)); + let amp = 0; + for (let b = blockStart; b < blockEnd && b < N; b++) { + amp = Math.max(amp, Math.abs(peaks[b * 2]), Math.abs(peaks[b * 2 + 1])); + } + amps.push(Number(amp.toFixed(4))); + } + return { + note: "NOT schemaVersion 7. Decoded from the asset's audio track by scripts/gen-recreation.mjs --vendor; vendored because the recording is not in this repo.", + source: asset.originalPath.replace(/^.*\//, ""), + sampleRateHz: 48000, + blocks: N, + barCount, + amps, + }; +} + +// ── the pieces of the document the recreation draws ───────────────────── +const asset = doc.assets[0]; +const clip = doc.timeline.clips[0]; +const totalSec = asset.durationSec; +const trims = doc.timeline.trimRanges; + +const section = buildClipSection(clip, { ...doc.transcript, segments: [] }, asset, trims); + +const pct = (sec) => Number(((sec / totalSec) * 100).toFixed(4)); + +// ── transcript ────────────────────────────────────────────────────────── +// `buildClipSection` has already inserted the silence pseudo-words, tagged every +// word kept/removed against the two agent trims, and grouped the removed ones +// into runs. All that is left is the label each one carries. +const WORDS = section.words.map((cw, i) => { + const silence = isSilenceWord(cw.word); + const durationSec = cw.word.endSec - cw.word.startSec; + return { + i, + id: cw.id, + kind: silence ? "silence" : "word", + text: silence + ? t("settings:transcript.silence", { duration: durationSec.toFixed(1) }) + : cw.word.text, + startSec: cw.word.startSec, + endSec: cw.word.endSec, + kept: cw.kept, + trimId: cw.trimId, + }; +}); + +const INSPECTOR = { + title: t("settings:transcript.title"), + indexBadge: "1", + filename: asset.label, + // RightPanes.tsx:958 — `clipLabel` and the source range joined by " · ", + // the range itself being two `formatMs` calls around an em dash. + clipRange: `${t("settings:transcript.clipLabel", { index: 1 })} · ${formatMs( + clip.sourceStartSec * 1000, + )}—${formatMs(clip.sourceEndSec * 1000)}`, + wordCount: WORDS.filter((w) => w.kind === "word").length, + silenceCount: WORDS.filter((w) => w.kind === "silence").length, + silenceThresholdSec: SILENCE_THRESHOLD_SEC, +}; + +// ── lanes and pills ───────────────────────────────────────────────────── +// The app's order, top to bottom (V4Timeline.tsx:1521-1531): annotation, speed, +// trim, zoom, cameraFullscreen. A lane shows its shortcut hint if and only if it +// is empty — which is why the hint list is derived from the pill list and never +// written down beside it. +const zoomPills = doc.zoomRanges.map((z) => ({ + lane: "zoom", + id: z.id, + label: `${effectiveZoomScale(z).toFixed(2)}×`, + startSec: z.sourceStartSec, + endSec: z.sourceEndSec, + leftPct: pct(z.sourceStartSec), + widthPct: pct(z.sourceEndSec - z.sourceStartSec), + depth: z.depth, + focusMode: z.focusMode, +})); + +const trimPills = trims.map((r) => ({ + lane: "trim", + id: r.id, + label: formatSec(r.endSec - r.startSec), + startSec: r.startSec, + endSec: r.endSec, + leftPct: pct(r.startSec), + widthPct: pct(r.endSec - r.startSec), + origin: r.origin, +})); + +const LANE_ORDER = [ + { id: "annotation", hintKey: "timeline:hints.pressAnnotation", pills: [] }, + { id: "speed", hintKey: "timeline:hints.pressSpeed", pills: [] }, + { id: "trim", hintKey: "timeline:hints.pressTrim", pills: trimPills }, + { id: "zoom", hintKey: "timeline:hints.pressZoom", pills: zoomPills }, + { id: "cameraFullscreen", hintKey: "timeline:hints.pressCameraFullscreen", pills: [] }, +]; + +// Cross-check against the document rather than trusting the table above: a lane +// that says it is empty while the document holds regions is the exact failure +// the recreation exists to make impossible. +const DOC_REGION_COUNTS = { + annotation: doc.annotations.length, + speed: doc.timeline.speedRanges.length, + trim: doc.timeline.trimRanges.length, + zoom: doc.zoomRanges.length, + cameraFullscreen: doc.legacyEditor.cameraFullscreenRegions.length, +}; +for (const lane of LANE_ORDER) { + if (lane.pills.length !== DOC_REGION_COUNTS[lane.id]) { + throw new Error( + `${lane.id} lane emits ${lane.pills.length} pills, document holds ${DOC_REGION_COUNTS[lane.id]}`, + ); + } +} + +const LANES = LANE_ORDER.map((lane) => ({ + id: lane.id, + hint: lane.pills.length === 0 ? t(lane.hintKey) : null, + pills: lane.pills, +})); +const PILLS = LANES.flatMap((lane) => lane.pills); + +// ── ruler ─────────────────────────────────────────────────────────────── +// The step is the first "nice" one whose on-screen gap clears MIN_LABEL_GAP_PX. +// The recreation's timeline canvas is as wide as its world, so pxPerSec is +// world / duration and the step re-derives per breakpoint exactly as the app's +// does per zoom level. At 1920 that lands on 2 s — arithmetically identical to +// the shipped 02-timeline-a.jpg, which is what makes the plate a pixel oracle. +function rulerFor(worldPx) { + const pxPerSec = worldPx / totalSec; + const step = + TICK_STEPS_SEC.find((s) => s * pxPerSec >= MIN_LABEL_GAP_PX) ?? + TICK_STEPS_SEC[TICK_STEPS_SEC.length - 1]; + const minor = step / MINOR_PER_MAJOR; + const labels = []; + for (let i = 0; i * step <= totalSec + 1e-6; i++) { + const sec = i * step; + labels.push({ sec, leftPct: pct(sec), text: fmtTick(sec, step) }); + } + return { + worldPx, + pxPerSec: Number(pxPerSec.toFixed(4)), + stepSec: step, + minorSec: minor, + minorPct: Number(((minor / totalSec) * 100).toFixed(6)), + labels, + }; +} + +/** Width at which the step drops to the next entry down. Below it the 1920 world + * applies; at or above it the world is the viewport and the rule re-steps. */ +const wideStep = TICK_STEPS_SEC.indexOf(rulerFor(1920).stepSec); +// Guarded, because the failure is silent otherwise: at index 0 the lookup below +// is TICK_STEPS_SEC[-1], the division is NaN, and JSON.stringify writes NaN as +// `null` — so the breakpoint would ship as null rather than fail the build. +if (wideStep < 1) { + throw new Error( + `the 1920 ruler already uses the finest step (${rulerFor(1920).stepSec}s), so there is ` + + "no next entry down for the wide breakpoint to be the width of", + ); +} +const WIDE_BREAKPOINT_PX = Math.ceil((MIN_LABEL_GAP_PX * totalSec) / TICK_STEPS_SEC[wideStep - 1]); + +const RULER = { + minLabelGapPx: MIN_LABEL_GAP_PX, + minorPerMajor: MINOR_PER_MAJOR, + tickStepsSec: TICK_STEPS_SEC, + wideBreakpointPx: WIDE_BREAKPOINT_PX, + variants: [rulerFor(1920), rulerFor(3440)], +}; + +// ── waveform ──────────────────────────────────────────────────────────── +// 320 bars are 320 elements for a shape nobody can resolve at 2 px. They become +// five s, one per opacity bucket: the app's own `0.5 + amp*0.5` rounded +// down to the nearest tenth, which is the whole of the declared quantisation. +// Geometry is left in ruler units — one unit per bar slot, 100 units tall — so +// the component can stretch it with `preserveAspectRatio="none"` and keep the +// bars at the app's literal 2 px with `vector-effect: non-scaling-stroke`. +const OPACITY_BUCKETS = [0.5, 0.6, 0.7, 0.8, 0.9]; + +function buildWaveform() { + const amps = doc.vendoredWaveform.amps; + const buckets = OPACITY_BUCKETS.map(() => []); + for (let i = 0; i < amps.length; i++) { + const h = amps[i]; + const exact = Number(waveBarOpacity(h)); + const bucket = Math.min(OPACITY_BUCKETS.length - 1, Math.floor((exact - 0.5) / 0.1 + 1e-9)); + const heightPct = waveBarHeightPct(h); + const y0 = (100 - heightPct) / 2; + const y1 = y0 + heightPct; + buckets[bucket].push(`M${i + 0.5} ${trimNum(y0)}V${trimNum(y1)}`); + } + return { + viewBox: `0 0 ${amps.length} 100`, + barCount: amps.length, + strokeWidthPx: 2, + gapPx: 1, + note: "preserveAspectRatio=none; stroke-width 2 with vector-effect:non-scaling-stroke reproduces .tlWave span {max-width:2px}", + paths: buckets.map((segments, i) => ({ + opacity: OPACITY_BUCKETS[i], + bars: segments.length, + d: segments.join(""), + })), + }; +} + +const trimNum = (n) => String(Number(n.toFixed(1))); +const WAVEFORM = buildWaveform(); + +// ── chat ──────────────────────────────────────────────────────────────── +/** + * The agent's reply. + * + * THE WEAKEST PROVENANCE ON THIS PAGE, and it is labelled as such in + * PROVENANCE. The conversation is not in the project document and there is no + * conversations store on disk: its only existence is + * `static/img/walkthrough/04-agent-a.jpg`, the plate this section was built + * from. That photograph is no longer shipped — the four feature panels are drawn + * in DOM now — so it survives only in this repository's history, which is the + * same footing `editor-1560.jpg` below has always been on. This is a + * transcription of the message body visible in it. + * + * What is NOT transcribed: every number and every quoted word. The two timecode + * pairs, the two silence durations, the two quoted words and the cut total are + * `{{placeholders}}` filled from `timeline.trimRanges` and `transcript.words`, + * so a re-cut document moves them and a stale transcription cannot outlive the + * edit it describes. + * + * What is transcribed and deliberately left alone: "40.0s → 33.0s" and + * "1.8×/2.2×". Both are the model's own rounding of values this file computes + * differently (32.93 s; 1.80×/2.20×). They stay as the model wrote them because + * the page is quoting a message, not rendering a readout — see PROVENANCE. + * + * The photograph's scrollback also shows a fragment above this body + * (`"think."). Cutting both:Both trims landed. …`) — the tail of the agent's + * tool-call narration, cut off mid-sentence by the top of the pane. It is not + * transcribed: half a sentence is not a source. + */ +const AGENT_REPLY_MARKDOWN = `Here's what I found and cut: + +**Finding:** The speech itself is nearly gapless — every pause between spoken words is well under a second, so there were no internal silences to remove. The only silences over 1s were at the two ends: + +**Cuts applied (both on your single placed clip, which is untouched otherwise):** + +- **{{trim1}}** — the {{dur1}}s dead lead-in before "{{firstWord}}" (cut) +- **{{trim2}}** — the ~{{dur2}}s dead tail after "{{lastWord}}" (cut) + +The video now runs from "{{firstWord}}" straight through to "{{lastWord}}" — about **{{cutSeconds}} seconds shorter** (40.0s → 33.0s of playable footage). + +Your zooms (incl. the manual 1.8×/2.2× punches) stay anchored to the same moments in the narration. + +One note: the cut starts the video right at the first word with no lead-in. If you'd rather keep a half-second of breathing room before "{{firstWord}}" say the word and I'll restore a short lead.`; + +/** `35.12`, `40.03`, `0` — the agent's own way of writing a source second. */ +const sourceSec = (n) => String(Number(n.toFixed(2))); + +const spokenWords = WORDS.filter((w) => w.kind === "word"); +const cutSec = trims.reduce((sum, r) => sum + (r.endSec - r.startSec), 0); + +const agentVars = { + trim1: `${sourceSec(trims[0].startSec)}–${sourceSec(trims[0].endSec)}s`, + trim2: `${sourceSec(trims[1].startSec)}–${sourceSec(trims[1].endSec)}s`, + dur1: (trims[0].endSec - trims[0].startSec).toFixed(1), + dur2: (trims[1].endSec - trims[1].startSec).toFixed(1), + firstWord: spokenWords[0].text, + lastWord: spokenWords[spokenWords.length - 1].text, + cutSeconds: String(Math.round(cutSec)), +}; + +const agentReplyText = AGENT_REPLY_MARKDOWN.replace(/\{\{(\w+)\}\}/g, (m, name) => { + if (!(name in agentVars)) throw new Error(`agent reply: no value for {{${name}}}`); + return agentVars[name]; +}); +if (/\{\{/.test(agentReplyText)) throw new Error("agent reply: unresolved interpolation"); + +/** + * Minimal block structure so the component does not need a markdown parser for + * one message. `**bold**` becomes runs; `- ` becomes a list item. + */ +function parseReply(md) { + return md + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const li = line.startsWith("- "); + const body = li ? line.slice(2) : line; + const runs = body + .split(/(\*\*[^*]+\*\*)/g) + .filter(Boolean) + .map((piece) => + piece.startsWith("**") + ? { text: piece.slice(2, -2), strong: true } + : { text: piece, strong: false }, + ); + return { kind: li ? "li" : "p", runs }; + }); +} + +/** + * `applied: added 2 trims`. + * + * The prefix is a locale string; the rest is the shape the bulk-add tool emits + * (`electron/ai-edition/agent-tools.ts`: `added ${n} ${noun}${n === 1 ? "" : "s"}`) + * with n counted off the document — the trims whose `origin` is `agent`. + */ +const agentTrimCount = trims.filter((r) => r.origin === "agent").length; +const CHAT = { + conversationTitle: `${t("editor:chat.untitledConversation")} 1`, + emptyState: t("editor:chat.emptyState"), + authorUser: t("editor:chat.authorUser"), + authorAssistant: t("editor:chat.authorAssistant"), + userPrompt: AI_ENHANCE_PROMPT, + agentReply: parseReply(agentReplyText), + agentReplyText, + applied: `${t("editor:chat.appliedPrefix")} added ${agentTrimCount} trim${agentTrimCount === 1 ? "" : "s"}`, + composerPlaceholder: t("editor:chat.composerPlaceholder"), + modelChip: "deepseek-v4-flash", +}; + +// ── stage, transport, toolbar ─────────────────────────────────────────── +// The recreation shows the loop that plays inside zoom region 2, so the picture +// on the canvas, the 2.20× pill under it and the playhead's position are three +// renderings of one fact and cannot disagree. +const LOOP_ZOOM = doc.zoomRanges[1]; +const LOOP = { + startSec: LOOP_ZOOM.sourceStartSec, + /** Where the footage stops. The clip ends 0.2 s later on a held frame — see + * the encode note at the bottom of this file. */ + contentEndSec: 24.3, + contentDurationSec: 6.8, + durationSec: 7, + dissolveSec: 0.6, + holdSec: 0.2, + zoomScale: effectiveZoomScale(LOOP_ZOOM), + crop: { w: 872, h: 490, x: 524, y: 295 }, + src: "/video/canvas-loop.mp4", + srcSmall: "/video/canvas-loop-sm.mp4", + poster: "/img/walkthrough/canvas-poster.jpg", + width: 836, + height: 470, + widthSmall: 640, + heightSmall: 360, + /** `t = startSec + Math.min(video.currentTime, contentDurationSec)` — the + * driver must clamp, or the last 0.2 s reports time the footage never + * reaches. */ + timeMapping: "startSec + min(currentTime, contentDurationSec)", +}; + +// PreviewCanvas.tsx:238 — padding shrinks the whole content block, not just the +// screen: paddingFit = clamp(1 - (padding/100) * 0.4, 0.4, 1). +const FRAME = { width: 1071, height: 603 }; +const paddingFit = Math.min( + 1, + Math.max(0.4, 1 - (Math.min(100, Math.max(0, doc.legacyEditor.padding)) / 100) * 0.4), +); +const contentBox = { + width: Math.round(FRAME.width * paddingFit), + height: Math.round(FRAME.height * paddingFit), +}; +const screenScale = Math.min( + contentBox.width / asset.video.width, + contentBox.height / asset.video.height, +); +const STAGE = { + aspectRatio: doc.legacyEditor.aspectRatio, + wallpaper: doc.legacyEditor.wallpaper, + padding: doc.legacyEditor.padding, + paddingFit: Number(paddingFit.toFixed(3)), + borderRadius: doc.legacyEditor.borderRadius, + frame: FRAME, + contentBox, + screen: { + width: Math.round(asset.video.width * screenScale), + height: Math.round(asset.video.height * screenScale), + }, + webcam: null, + webcamReason: + 'assets[0].cameraTrack.visible is false and legacyEditor.webcamLayoutPreset is "no-webcam" — there is no bubble to draw', +}; + +// ── the inspector's facet panels ──────────────────────────────────────── +// FloatingInspector.tsx:59-65 is the rail in its own order; these are three of +// its six facets — the ones the scroll opens before it reaches the transcript. +// Every label is a locale key, so renaming a control in the app breaks this +// build rather than shipping a word the app stopped using. +// +// `wallpaper.ts` is lifted by regex rather than imported: it opens with +// `import { getAssetPath } from "@/lib/assetPath"`, and the resolver hook above +// teaches Node relative extensionless specifiers, not the `@/` alias. +const wallpaperSrc = readFileSync(resolve(APP, "src/lib/wallpaper.ts"), "utf8"); +const WALLPAPER_COUNT = Number(/WALLPAPER_COUNT\s*=\s*(\d+)/.exec(wallpaperSrc)?.[1]); +if (!Number.isInteger(WALLPAPER_COUNT) || WALLPAPER_COUNT < 1) { + throw new Error("could not read WALLPAPER_COUNT out of src/lib/wallpaper.ts"); +} + +/** + * PreviewCanvas.tsx:238 as coefficients rather than as one baked number, so the + * page can evaluate the app's own formula on every frame while the reader drags + * the padding slider — `clamp(1 - (padding/100) * factor, min, 1)`. The single + * `STAGE.paddingFit` above is this same formula at the document's own setting. + */ +const EFFECTS = { + paddingDefault: doc.legacyEditor.padding, + borderRadiusDefault: doc.legacyEditor.borderRadius, + paddingFitFactor: 0.4, + paddingFitMin: 0.4, +}; + +/** + * Every slider and toggle on the two panels, at the document's own settings and + * with the range, suffix and precision RightPanes.tsx gives it. + * + * The app stores most of these as fractions and displays them scaled — cursor + * size is `size * 10` over 5–100 with one decimal and no suffix, smoothing is + * `smoothing * 100` with a per-cent sign — so a panel that showed the stored + * number would be wrong in a way that looks entirely plausible. The scaling + * lives here, next to the value it scales. + */ +const le = doc.legacyEditor; +const defaultsSrc = [ + [ + "src/components/video-editor/types.ts", + readFileSync(resolve(APP, "src/components/video-editor/types.ts"), "utf8"), + ], + [ + "src/lib/ai-edition/store/editorSettings.ts", + readFileSync(resolve(APP, "src/lib/ai-edition/store/editorSettings.ts"), "utf8"), + ], +]; +/** `DEFAULT_EDITOR_SETTINGS` spells some of its defaults as named constants and + * others inline, so look for both shapes and fail rather than assume. */ +const defaultBool = (name) => { + for (const [, src] of defaultsSrc) { + const m = new RegExp(`${name}\\s*[=:]\\s*(true|false)`).exec(src); + if (m) return m[1] === "true"; + } + throw new Error( + `could not read a boolean default for ${name} out of ${defaultsSrc.map(([f]) => f).join(" or ")}`, + ); +}; +const asPercent = (v) => `${Math.round(v * 100)}%`; +const slider = (label, value, min, max, suffix, display) => ({ + label, + value, + min, + max, + suffix, + display, +}); + +const CONTROLS = { + // RightPanes.tsx:1443, :1397, :1412, :1427 — Video Effects, in its order. + padding: slider(t("settings:effects.padding"), le.padding, 0, 100, "%", `${le.padding}%`), + blurBg: { label: t("settings:effects.blurBg"), on: le.showBlur }, + motionBlur: slider( + t("settings:effects.motionBlur"), + le.motionBlurAmount * 100, + 0, + 100, + "%", + asPercent(le.motionBlurAmount), + ), + shadow: slider( + t("settings:effects.shadow"), + le.shadowIntensity * 100, + 0, + 100, + "%", + asPercent(le.shadowIntensity), + ), + roundness: slider( + t("settings:effects.roundness"), + le.borderRadius, + 0, + 64, + "px", + `${le.borderRadius}px`, + ), + // RightPanes.tsx:1755, :1771 — Cursor. `show` and `clipToBounds` are not in + // this document, so they are the app's own defaults rather than a guess. + cursorShow: { label: t("settings:cursor.show"), on: defaultBool("cursorShow") }, + clipToBounds: { + label: t("settings:cursor.clipToBounds"), + on: defaultBool("DEFAULT_CURSOR_CLIP_TO_BOUNDS"), + }, + cursorTheme: { label: t("settings:cursor.theme"), value: le.cursorTheme }, + cursorSize: slider( + t("settings:cursor.size"), + le.cursorSize * 10, + 5, + 100, + "", + (le.cursorSize * 10).toFixed(1), + ), + smoothing: slider( + t("settings:cursor.smoothing"), + le.cursorSmoothing * 100, + 0, + 100, + "%", + asPercent(le.cursorSmoothing), + ), +}; + +/** + * The cursor packs the Cursor panel's picker shows, and the two macOS shapes the + * demonstration pointer swaps to. + * + * Every one is a pack the application actually ships in `public/cursors/`, with + * the hotspot the application actually uses — which is the whole reason to read + * this table rather than eyeball a `transform-origin`. The two sources spell + * hotspots differently and that is the trap: `DEFAULT_CURSOR_SPRITES` stores + * fractions of the image (0.119), while a theme stores the same quantity against + * the 32-logical reference the file header describes (1.5, i.e. 1.5/32). The + * magnitude cannot tell them apart — a theme's `hotspotX: 1` is one unit in, an + * arrow tip, and reads as the sprite's right edge if taken for a fraction — so + * the application's own `resolveCursorSprites` does the conversion here too. + */ +const CURSOR_PICKER = [ + DEFAULT_CURSOR_THEME_ID, + "pink-glossy-arrow-and-hand-3d", + "spring-gradient", + "black-and-rainbow-stroke-gradient-animated", + "among-us-sus-knife-and-red-animated", + "hollow-knight-and-game-arrow", + "mickey-mouse-black-hand-inflated-glove", + "sanrio-kuromi-skull-arrow", + "old-roblox", + "pokemon-neon-gengar", +]; + +const round4 = (v) => Number(v.toFixed(4)); + +function cursorSprite(id, kind) { + const isDefault = id === DEFAULT_CURSOR_THEME_ID; + const theme = isDefault ? null : CURSOR_THEMES.find((t) => t.id === id); + if (!isDefault && !theme) { + throw new Error(`unknown cursor theme ${id} — it is not in CURSOR_THEMES`); + } + // resolveCursorSprites falls back to the built-in art for a kind the pack + // does not ship, which is right in the application and wrong here: the + // picker would show the default arrow under a pack's name. + if (theme && !theme.assets[kind]) { + throw new Error(`cursor theme ${id} ships no ${kind} sprite`); + } + const sprite = resolveCursorSprites(theme?.id ?? null)[kind]; + if (!sprite) throw new Error(`default sprites have no ${kind}`); + return { + name: theme?.name ?? "Default", + hotspotX: round4(sprite.hotspotX), + hotspotY: round4(sprite.hotspotY), + }; +} + +const CURSORS = { + themeCount: CURSOR_THEMES.length + 1, + themes: CURSOR_PICKER.map((id, i) => ({ + id, + ...cursorSprite(id, "arrow"), + src: `/img/cursors/${String(i).padStart(2, "0")}-arrow.png`, + })), + // The demonstration pointer over a control and over text. Default pack only: + // a theme may ship an arrow and a pointer, and none ships a text caret. + pointer: { + ...cursorSprite(DEFAULT_CURSOR_THEME_ID, "pointer"), + src: "/img/cursors/mac-pointer.png", + }, + text: { ...cursorSprite(DEFAULT_CURSOR_THEME_ID, "text"), src: "/img/cursors/mac-text.png" }, +}; + +const PANELS = { + background: { + title: t("settings:background.title"), + tabs: [ + t("settings:background.image"), + t("settings:background.color"), + t("settings:background.gradient"), + ], + uploadCustom: t("settings:background.uploadCustom"), + wallpaperCount: WALLPAPER_COUNT, + swatchLabels: Array.from({ length: WALLPAPER_COUNT }, (_, i) => + t("settings:background.imageLabel", { index: i + 1 }), + ), + }, + effects: { + title: t("settings:effects.title"), + padding: t("settings:effects.padding"), + blurBg: t("settings:effects.blurBg"), + motionBlur: t("settings:effects.motionBlur"), + shadow: t("settings:effects.shadow"), + roundness: t("settings:effects.roundness"), + }, + cursor: { + title: t("settings:cursor.title"), + show: t("settings:cursor.show"), + clipToBounds: t("settings:cursor.clipToBounds"), + theme: t("settings:cursor.theme"), + size: t("settings:cursor.size"), + smoothing: t("settings:cursor.smoothing"), + }, +}; + +const TRANSPORT = { + restCurrent: formatSec(LOOP.startSec), + endCurrent: formatSec(LOOP.contentEndSec), + total: formatSec(totalSec), + restLeftPct: pct(LOOP.startSec), + endLeftPct: pct(LOOP.contentEndSec), +}; + +const TOOLBAR = { + aspectRatio: doc.legacyEditor.aspectRatio, + panKbd: "Shift+Scroll", + panLabel: t("timeline:labels.pan"), + zoomKbd: "Ctrl+Scroll", + zoomLabel: t("timeline:labels.zoom"), + clipLabel: asset.label, +}; + +// ── tokens the recreation renders text on ─────────────────────────────── +// Read out of the app's own dark block so the guard's contrast assertions are +// made against the real values, not a copy of them. +const TOKENS_SRC = readFileSync(resolve(APP, "src/styles/design-tokens.css"), "utf8"); +const darkBlock = TOKENS_SRC.slice(TOKENS_SRC.indexOf(':root[data-theme="dark"]')); +function token(name) { + const m = darkBlock.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{3,8});`)); + if (!m) throw new Error(`token --${name} not found in the dark block of design-tokens.css`); + return m[1]; +} +const TOKENS = { + surface: token("surface"), + surface1: token("surface-1"), + surface2: token("surface-2"), + surface3: token("surface-3"), + border: token("border"), + borderHi: token("border-hi"), + fg: token("fg"), + fg2: token("fg-2"), + muted: token("muted"), + /** The one declared divergence: the app's `--meta` (#5b6470) computes 2.97:1 + * on --surface-1 and fails AA. The lane hints and the ruler labels use + * `--muted` instead, one step up the app's own neutral ramp, at 5.87:1. */ + meta: token("muted"), + metaInApp: token("meta"), + accent: token("accent"), + danger: token("danger"), + playhead: "#6c55ff", +}; + +const CONTRAST_PAIRS = [ + [TOKENS.fg, TOKENS.surface, "transcript word on the inspector"], + [TOKENS.muted, TOKENS.surface1, "clip range on the inspector"], + [TOKENS.meta, TOKENS.surface, "lane hint on the floor"], + [TOKENS.meta, TOKENS.surface, "ruler label on the floor"], + [TOKENS.accent, TOKENS.surface, "zoom pill label"], + [TOKENS.danger, TOKENS.surface, "trim pill label and struck silence"], + [TOKENS.fg, TOKENS.surface2, "chat message body"], + [TOKENS.muted, TOKENS.surface, "chat author label"], + [TOKENS.accent, TOKENS.surface, "applied line"], + [TOKENS.muted, TOKENS.surface1, "composer placeholder"], +]; + +// ── provenance ────────────────────────────────────────────────────────── +// One entry per string the recreation puts on screen. `shown` is the literal +// text; `source` says where it came from, precisely enough that a reader can go +// and check. "computed:" marks a value derived at build time rather than quoted. +const PROVENANCE = [ + { shown: INSPECTOR.title, source: "src/i18n/locales/en/settings.json → transcript.title" }, + { + shown: INSPECTOR.clipRange, + source: `computed: settings.json transcript.clipLabel with index 1, joined to formatMs(${clip.sourceStartSec * 1000}) and formatMs(${clip.sourceEndSec * 1000}) from src/lib/ai-edition/timeline/format.ts`, + }, + { shown: INSPECTOR.filename, source: "fixture assets[0].label" }, + ...WORDS.filter((w) => w.kind === "silence").map((w) => ({ + shown: w.text, + source: `computed: settings.json transcript.silence over the ${(w.endSec - w.startSec).toFixed(3)}s gap ${w.startSec}–${w.endSec} that buildClipSection inserted at SILENCE_THRESHOLD_SEC ${SILENCE_THRESHOLD_SEC}; ${w.kept ? "outside any trim" : `inside trimRange ${w.trimId}`}`, + })), + { + // The whole spoken flow, as one string. The fixture stores ONE WORD per + // entry, so a check that asserts this against the document only resolves + // if it rejoins them in start order — which is the point of quoting it + // whole rather than claiming "103 words" and asserting nothing. + shown: spokenWords.map((w) => w.text).join(" "), + source: + "fixture transcript.words, rejoined in start order; every entry is rendered as its own span", + }, + { + shown: CHAT.conversationTitle, + source: + "computed: editor.json chat.untitledConversation + the session index, as LeftPanel.tsx:1433 renders it", + }, + { shown: CHAT.emptyState, source: "editor.json chat.emptyState" }, + { shown: CHAT.authorUser, source: "editor.json chat.authorUser" }, + { shown: CHAT.authorAssistant, source: "editor.json chat.authorAssistant" }, + { + shown: CHAT.userPrompt, + source: + "src/components/ai-edition/v4/V4Timeline.tsx AI_ENHANCE_PROMPT, lifted verbatim as source text", + }, + { + shown: CHAT.agentReplyText, + source: + 'photograph: static/img/walkthrough/04-agent-a.jpg — hand-transcribed; the conversation is in no store on disk and this photograph is its only existence. THE WEAKEST PROVENANCE ON THE PAGE. Every timecode, duration, quoted word and cut total in it is regenerated from timeline.trimRanges and transcript.words, not transcribed. Two figures are transcribed as the model wrote them and disagree with this file\'s arithmetic on purpose: "33.0s" (recomputed: 32.93s) and "1.8×/2.2×" (the pills read 1.80×/2.20×). The page is quoting a message, not drawing a readout.', + }, + { + shown: CHAT.applied, + source: `computed: editor.json chat.appliedPrefix + the bulk-add tool's own wording (electron/ai-edition/agent-tools.ts: added \${n} \${noun}s) with n = ${agentTrimCount}, the trimRanges whose origin is "agent"`, + }, + { shown: CHAT.composerPlaceholder, source: "editor.json chat.composerPlaceholder" }, + { + shown: CHAT.modelChip, + source: + "photograph: static/img/walkthrough/editor-1560.jpg — what was on screen when the plate was shot. NOT in provider-registry.ts; it was typed into an OpenAI-compatible provider config. The one visible string whose referent cannot be verified in the source, kept because the photographs beside it already ship it.", + }, + ...zoomPills.map((p) => ({ + shown: p.label, + source: `computed: effectiveZoomScale(zoomRanges depth ${p.depth}).toFixed(2) + "×" from src/lib/ai-edition/timeline/zoom-scale.ts`, + })), + ...trimPills.map((p) => ({ + shown: p.label, + source: `computed: formatSec(${p.endSec} − ${p.startSec}) over trimRange ${p.id}`, + })), + ...LANES.filter((l) => l.hint).map((l) => ({ + shown: l.hint, + source: `timeline.json hints.press${l.id[0].toUpperCase()}${l.id.slice(1)} — rendered because the document holds no ${l.id} regions`, + })), + { + shown: RULER.variants[0].labels.map((l) => l.text).join(" "), + source: `computed: fmtTick lifted from V4Timeline.tsx over a ${RULER.variants[0].stepSec}s step, itself derived as the first TICK_STEPS_SEC entry clearing MIN_LABEL_GAP_PX ${MIN_LABEL_GAP_PX} at ${RULER.variants[0].pxPerSec} px/s`, + }, + { shown: TRANSPORT.total, source: `computed: formatSec(assets[0].durationSec = ${totalSec})` }, + { + shown: TRANSPORT.restCurrent, + source: `computed: formatSec(zoomRanges[1].sourceStartSec = ${LOOP.startSec}) — where the loop starts`, + }, + { shown: TOOLBAR.aspectRatio, source: "fixture legacyEditor.aspectRatio" }, + { + shown: `${TOOLBAR.panKbd} ${TOOLBAR.panLabel}`, + source: + "computed: timeline.json labels.pan, with the key hint V4Timeline.tsx:1474 writes beside it", + }, + { + shown: `${TOOLBAR.zoomKbd} ${TOOLBAR.zoomLabel}`, + source: + "computed: timeline.json labels.zoom, with the key hint V4Timeline.tsx:1477 writes beside it", + }, + { shown: TOOLBAR.clipLabel, source: "fixture assets[0].label, as the clip card's own label" }, + { + shown: PANELS.background.title, + source: "src/i18n/locales/en/settings.json → background.title", + }, + { + shown: PANELS.background.tabs[0], + source: "src/i18n/locales/en/settings.json → background.image", + }, + { + shown: PANELS.background.tabs[1], + source: "src/i18n/locales/en/settings.json → background.color", + }, + { + shown: PANELS.background.tabs[2], + source: "src/i18n/locales/en/settings.json → background.gradient", + }, + { + shown: PANELS.background.uploadCustom, + source: "src/i18n/locales/en/settings.json → background.uploadCustom", + }, + { + shown: PANELS.background.swatchLabels[0], + source: `computed: settings.json background.imageLabel over the ${WALLPAPER_COUNT} wallpapers WALLPAPER_COUNT declares in src/lib/wallpaper.ts`, + }, + { shown: PANELS.effects.title, source: "src/i18n/locales/en/settings.json → effects.title" }, + { shown: PANELS.effects.padding, source: "src/i18n/locales/en/settings.json → effects.padding" }, + { shown: PANELS.effects.blurBg, source: "src/i18n/locales/en/settings.json → effects.blurBg" }, + { + shown: PANELS.effects.motionBlur, + source: "src/i18n/locales/en/settings.json → effects.motionBlur", + }, + { shown: PANELS.effects.shadow, source: "src/i18n/locales/en/settings.json → effects.shadow" }, + { + shown: PANELS.effects.roundness, + source: "src/i18n/locales/en/settings.json → effects.roundness", + }, + { shown: PANELS.cursor.title, source: "src/i18n/locales/en/settings.json → cursor.title" }, + { shown: PANELS.cursor.show, source: "src/i18n/locales/en/settings.json → cursor.show" }, + { + shown: PANELS.cursor.clipToBounds, + source: "src/i18n/locales/en/settings.json → cursor.clipToBounds", + }, + { shown: PANELS.cursor.theme, source: "src/i18n/locales/en/settings.json → cursor.theme" }, + { shown: PANELS.cursor.size, source: "src/i18n/locales/en/settings.json → cursor.size" }, + { + shown: PANELS.cursor.smoothing, + source: "src/i18n/locales/en/settings.json → cursor.smoothing", + }, +]; + +// A string that claims a locale source but is not in the locale files is the +// failure this array exists to catch, so catch it here too rather than only in +// the guard. +const localeCorpus = JSON.stringify(LOCALES); +for (const entry of PROVENANCE) { + if (!entry.source.includes(".json →") && !/\.json /.test(entry.source)) continue; + if (entry.source.startsWith("computed:")) continue; + if (!localeCorpus.includes(JSON.stringify(entry.shown).slice(1, -1))) { + throw new Error( + `PROVENANCE claims a locale source for ${JSON.stringify(entry.shown)}, which is not in en/*.json`, + ); + } +} + +// ── emit ──────────────────────────────────────────────────────────────── +const banner = `/** + * GENERATED FILE — do not edit. Run \`node scripts/gen-recreation.mjs\` instead. + * + * Every value below was read out of, or computed from, one of: + * · src/components/Recreation/fixture-slim.json (the project document, schemaVersion ${SCHEMA_VERSION}) + * · ../src/i18n/locales/en/{editor,settings,timeline}.json + * · ../src/lib/ai-edition/timeline/{format,zoom-scale,aggregated-transcript}.ts, imported and run + * · ../src/components/ai-edition/v4/V4Timeline.tsx, lifted as source text and evaluated + * · ../src/styles/design-tokens.css + * · static/img/walkthrough/04-agent-a.jpg and editor-1560.jpg (two strings; see PROVENANCE) + * + * PROVENANCE names the source of every string this file puts on screen. Nothing + * that could not be sourced was invented to fill a gap: the chat's context pill + * is absent for that reason, and so is the webcam bubble. + */`; + +/** + * TypeScript object literals, not JSON: unquoted keys where the key is an + * identifier, trailing commas, tabs. + * + * The generator owns this file's formatting outright — biome.json tells the + * formatter to skip it — because `--check` compares bytes, and a formatter in + * between makes it report drift on a file nobody touched. That is what it did + * for as long as the file was committed: the check could not pass. So what + * comes out of here has to be what a reader will read, and it also has to be + * what biome would leave alone if it ever looked, or the exclusion becomes a + * silent licence to drift. + * + * Non-finite numbers throw rather than serialise. `JSON.stringify` writes NaN + * and Infinity as `null`, which is how a broken derivation ships as a plausible + * value instead of a build failure. + */ +const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const key = (k) => (IDENT.test(k) ? k : JSON.stringify(k)); +const scalar = (v) => { + if (typeof v === "number" && !Number.isFinite(v)) { + throw new Error(`refusing to emit ${v} — a derivation above this produced no number`); + } + return JSON.stringify(v); +}; +const lit = (value, pad = "") => { + if (value === null || typeof value !== "object") return scalar(value); + const inner = `${pad}\t`; + if (Array.isArray(value)) { + if (!value.length) return "[]"; + return `[\n${value.map((v) => `${inner}${lit(v, inner)},`).join("\n")}\n${pad}]`; + } + const entries = Object.entries(value).filter(([, v]) => v !== undefined); + if (!entries.length) return "{}"; + return `{\n${entries.map(([k, v]) => `${inner}${key(k)}: ${lit(v, inner)},`).join("\n")}\n${pad}}`; +}; + +/** The same, on one line. The word list is 106 objects; pretty-printing it costs + * ~11 KB of indentation nobody reads, and one line each stays diffable. */ +const litRow = (value) => { + if (value === null || typeof value !== "object") return scalar(value); + if (Array.isArray(value)) return `[${value.map(litRow).join(", ")}]`; + const entries = Object.entries(value).filter(([, v]) => v !== undefined); + return entries.length + ? `{ ${entries.map(([k, v]) => `${key(k)}: ${litRow(v)}`).join(", ")} }` + : "{}"; +}; +const litRows = (rows) => `[ +${rows.map((r) => ` ${litRow(r)},`).join("\n")} +]`; + +/** The ruler, pretty everywhere except its 21 + 41 label rows. */ +const litRuler = (ruler) => + lit({ + ...ruler, + variants: ruler.variants.map((v, i) => ({ ...v, labels: `__LABELS_${i}__` })), + }).replace(/"__LABELS_(\d+)__"/g, (_m, i) => litRows(ruler.variants[Number(i)].labels)); + +const generated = `${banner} + +export interface RecreationWord { + i: number; + id: string; + kind: "word" | "silence"; + text: string; + startSec: number; + endSec: number; + kept: boolean; + trimId: string | null; +} + +export interface RecreationPill { + lane: string; + id: string; + label: string; + startSec: number; + endSec: number; + leftPct: number; + widthPct: number; + /** zoom lanes only — the stored ordinal the label was derived from. */ + depth?: number; + /** zoom lanes only. */ + focusMode?: string; + /** trim lanes only — "agent" for the two the agent placed. */ + origin?: string; +} + +export interface RecreationLane { + id: string; + hint: string | null; + pills: RecreationPill[]; +} + +export interface ProvenanceEntry { + shown: string; + source: string; +} + +/** Where this data came from, and what it is a recreation of. */ +export const META = ${lit({ + schemaVersion: doc.schemaVersion, + projectId: doc.project.id, + projectTitle: doc.project.title, + assetLabel: asset.label, + assetDurationSec: totalSec, + assetVideo: asset.video, + cameraTrackVisible: asset.cameraTrack.visible, + generator: "website/scripts/gen-recreation.mjs", +})} as const; + +/** The transcript pane's header. */ +export const INSPECTOR = ${lit(INSPECTOR)} as const; + +/** + * The transcript flow, produced by the app's own \`buildClipSection\`: ${INSPECTOR.wordCount} + * words and ${INSPECTOR.silenceCount} silence markers, each tagged kept or removed against the + * document's two agent trims. ${WORDS.filter((w) => !w.kept).length} entries are removed, all of them silences. + */ +export const WORDS: RecreationWord[] = ${litRows(WORDS)}; + +/** The five lanes in the app's order. A lane carries its shortcut hint if and + * only if the document holds no regions for it. */ +export const LANES: RecreationLane[] = ${litRows(LANES)}; + +/** Every pill on the floor, flattened. */ +export const PILLS: RecreationPill[] = ${litRows(PILLS)}; + +/** The ruler, re-derived per breakpoint the way the app re-derives it per zoom. */ +export const RULER = ${litRuler(RULER)} as const; + +/** ${WAVEFORM.barCount} bars in five opacity buckets, five paths. */ +export const WAVEFORM = ${lit(WAVEFORM)} as const; + +/** The conversation. */ +export const CHAT = ${lit(CHAT)} as const; + +/** The preview frame's geometry, and the loop that plays inside it. */ +export const STAGE = ${lit(STAGE)} as const; +export const LOOP = ${lit(LOOP)} as const; + +/** The three facet panels the scroll opens before the transcript, with every + * control's label as the app's locale files spell it. */ +export const PANELS = ${lit(PANELS)} as const; + +/** The padding formula's coefficients, so the slider moves the composite by the + * app's own arithmetic rather than by a number that looks about right. */ +export const EFFECTS = ${lit(EFFECTS)} as const; + +/** Every slider and toggle on those panels, at this document's settings, scaled + * and suffixed the way RightPanes.tsx scales and suffixes it. */ +export const CONTROLS = ${lit(CONTROLS)} as const; + +/** The cursor packs the picker shows, each with the application's own hotspot, + * normalised to a fraction of the sprite. */ +export const CURSORS = ${lit(CURSORS)} as const; + +export const TRANSPORT = ${lit(TRANSPORT)} as const; +export const TOOLBAR = ${lit(TOOLBAR)} as const; + +/** The app's dark tokens, read out of design-tokens.css. */ +export const TOKENS = ${lit(TOKENS)} as const; + +/** One entry per string on screen. */ +export const PROVENANCE: ProvenanceEntry[] = ${litRows(PROVENANCE)}; + +/** What check-recreation.mjs asserts against. */ +export const MARKUP = { + pills: PILLS, + hints: LANES.filter((lane) => lane.hint !== null).map((lane) => lane.id), + words: WORDS, + contrastPairs: ${lit(CONTRAST_PAIRS)} as [string, string, string][], +} as const; +`; + +if (CHECK) { + const current = existsSync(GENERATED) ? readFileSync(GENERATED, "utf8") : ""; + if (current !== generated) { + console.error( + "generated.ts is stale — a locale key, a timeline constant, a token or the document moved.\n" + + "Run `node scripts/gen-recreation.mjs` and commit the result.", + ); + process.exit(1); + } + console.log( + `recreation data up to date — ${PROVENANCE.length} strings, ${WORDS.length} words, ${PILLS.length} pills`, + ); +} else { + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(GENERATED, generated); + console.log( + `wrote ${GENERATED}\n ${WORDS.length} transcript entries (${INSPECTOR.wordCount} words, ${INSPECTOR.silenceCount} silences)\n` + + ` ${PILLS.length} pills, ${LANES.filter((l) => l.hint).length} hints, ${RULER.variants[0].labels.length}/${RULER.variants[1].labels.length} ruler labels\n` + + ` ${WAVEFORM.barCount} waveform bars in ${WAVEFORM.paths.length} paths, ${PROVENANCE.length} provenance entries`, + ); +} + +if (ARGS.has("--media")) cutMedia(); + +/** + * Cut the canvas loop. + * + * The window is zoom region 2 — ${'`'}zoomRanges[1]${'`'}, 17.5→24.5 s at depth 4 — and the + * footage is pre-cropped to that region's 2.20× framing, centred on its focus. + * Choosing that window is what makes the picture, the 2.20× pill under it and + * the playhead's position three renderings of one fact: the clip never leaves + * the region, so no frame of the loop can contradict the chrome around it. + * + * Seamlessness, since the clip loops forever and is never seeked: the first + * frame is the frame at 17.5 s exactly (which is also the poster), and the last + * 0.6 s dissolves back into a held copy of that same first frame, followed by + * 0.2 s of hold. Measured pre-encode, the last four frames are bit-identical to + * frame 0; after encoding they differ by 1.88/255 mean luma, which is the + * quantiser, not the cut. The dissolve lands entirely inside the recording's own + * static stretch (23.3–24.4 s), so nothing that is moving is faded. + * + * -an is not optional: WebKit refuses gesture-free autoplay for media that + * carries an audio track at all, muted or not, and check-media-budget.mjs fails + * the build on one. + */ +function cutMedia() { + const videoDir = resolve(WEBSITE, "static/video"); + const imgDir = resolve(WEBSITE, "static/img/walkthrough"); + const filter = (w, h) => + `[0:v]crop=${LOOP.crop.w}:${LOOP.crop.h}:${LOOP.crop.x}:${LOOP.crop.y},scale=${w}:${h}:flags=lanczos,fps=30,format=yuv420p,setpts=PTS-STARTPTS,split=2[a][h];` + + `[h]trim=start_frame=0:end_frame=1,setpts=PTS-STARTPTS,loop=loop=23:size=1:start=0,setpts=N/30/TB,fps=30[b];` + + `[a][b]xfade=transition=fade:duration=${LOOP.dissolveSec}:offset=${LOOP.contentDurationSec - LOOP.dissolveSec},format=yuv420p[v]`; + const encode = (w, h, bitrate, maxrate, bufsize, out) => + execFileSync( + FFMPEG, + [ + // prettier-ignore + "-y", + "-v", + "error", + "-ss", + String(LOOP.startSec), + "-t", + String(LOOP.contentDurationSec), + "-i", + RECORDING, + "-filter_complex", + filter(w, h), + "-map", + "[v]", + "-an", + "-c:v", + "h264_videotoolbox", + "-b:v", + bitrate, + "-maxrate", + maxrate, + "-bufsize", + bufsize, + "-g", + "60", + // An IDR where the hold begins re-quantises the held frame against the + // same source as frame 0, which halves the visible step at the wrap. + "-force_key_frames", + `0,${LOOP.contentDurationSec}`, + "-movflags", + "+faststart", + resolve(videoDir, out), + ], + { stdio: "inherit" }, + ); + + mkdirSync(videoDir, { recursive: true }); + mkdirSync(imgDir, { recursive: true }); + encode(LOOP.width, LOOP.height, "138k", "230k", "450k", "canvas-loop.mp4"); + encode(LOOP.widthSmall, LOOP.heightSmall, "62k", "110k", "220k", "canvas-loop-sm.mp4"); + + // The poster is the loop's own first frame, at the crop's native size. It is + // NOT upscaled past that: the 2.20× crop is 872 px wide in the master, so a + // 1252 px poster would be invented sharpness on the one image the reader + // looks at longest — it is what shows at rest, under reduced motion, without + // JavaScript, before the driver attaches, and in print. + execFileSync( + FFMPEG, + [ + // prettier-ignore + "-y", + "-v", + "error", + "-ss", + String(LOOP.startSec), + "-i", + RECORDING, + "-frames:v", + "1", + "-vf", + `crop=${LOOP.crop.w}:${LOOP.crop.h}:${LOOP.crop.x}:${LOOP.crop.y}`, + "-c:v", + "mjpeg", + "-q:v", + "4", + "-pix_fmt", + "yuvj420p", + resolve(imgDir, "canvas-poster.jpg"), + ], + { stdio: "inherit" }, + ); + console.log( + "cut static/video/canvas-loop{,-sm}.mp4 and static/img/walkthrough/canvas-poster.jpg", + ); +} diff --git a/website/src/components/Editor/index.tsx b/website/src/components/Editor/index.tsx new file mode 100644 index 000000000..409644253 --- /dev/null +++ b/website/src/components/Editor/index.tsx @@ -0,0 +1,46 @@ +/** + * The page's centre of gravity: nine viewports of editor, and nothing above it. + * + * It sits immediately under the hero because it is the argument. Everything + * after it is either a claim the editor cannot make on its own (the recorder, + * the encoder, captions, the agent) or a property rather than a capability + * (the licence, the privacy, the platforms). + * + * No visible heading, as the design has it. The five beats caption themselves — + * "STYLE / Swap the background", and four more — so a heading above them was a + * lid on a box that labels itself, and the deck under it was the page + * explaining itself to the reader instead of showing them. + * + * Two things survive that removal, both invisible: + * + * The h2, now screen-reader-only. Without it the outline runs h1 straight to + * the five h3 captions, which is a skipped level, and the section's + * aria-labelledby has nothing to point at. + * + * The skip link, visible on focus. The band is nine viewports tall; tabbing + * through it to reach the download link is a long way for someone who has + * already decided. + */ + +import Heading from "@theme/Heading"; + +import Recreation from "../Recreation"; +import styles from "./styles.module.css"; + +export default function Editor() { + return ( +
+
+ + Skip the editor — go to downloads + + + + Five things you will actually do + +
+ + +
+ ); +} diff --git a/website/src/components/Editor/styles.module.css b/website/src/components/Editor/styles.module.css new file mode 100644 index 000000000..2e4213aec --- /dev/null +++ b/website/src/components/Editor/styles.module.css @@ -0,0 +1,73 @@ +/** + * Nothing here is drawn any more except on focus. `.head` exists to give the + * skip link a positioning context and the hidden heading a home; the recreation + * is a sibling of it, not a child, which is why the section has no horizontal + * padding of its own. + */ + +.section { + /* Explicit: the dark-theme document canvas resolves to Infima's own #1b1b1d, + and `html, body { height: 100% }` means the body paints only the first + viewport — so a section without a background sits on the wrong grey. */ + background: var(--os-bg); + color: var(--os-fg); + /* Down from 104px, which was the air a section heading needed. With the + heading gone the hero should hand over to the editor the way the design + has it: the demo begins as the hero leaves. */ + padding: 24px 0 8px; +} + +.head { + position: relative; + max-width: 1096px; + margin: 0 auto; + padding: 0 28px; +} + +/* Read, never seen. Not `display: none`, which would take it out of the + accessibility tree along with the outline it is there to keep. */ +.title { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* Visible only on focus. */ +.skip { + position: absolute; + left: 28px; + top: 0; + transform: translateY(-140%); + padding: 9px 14px; + border-radius: 9px; + border: 1px solid var(--os-accent-border); + background: var(--os-surface-1); + color: var(--os-fg-emphasis); + font-size: 13px; + font-weight: 600; + opacity: 0; + pointer-events: none; +} + +.skip:focus, +.skip:focus-visible { + transform: none; + opacity: 1; + pointer-events: auto; +} + +@media (max-width: 600px) { + .head { + padding: 0 18px; + } + + .skip { + left: 18px; + } +} diff --git a/website/src/components/Recreation/ART-DIRECTION.md b/website/src/components/Recreation/ART-DIRECTION.md new file mode 100644 index 000000000..d75dedcb4 --- /dev/null +++ b/website/src/components/Recreation/ART-DIRECTION.md @@ -0,0 +1,100 @@ +# The recreation — art direction + +Five settings in two acts, told by the scroll. This replaces an earlier build +that drew the whole editor at 1:1 in a 1920px scene and flew a camera over it; +this note records what was wrong with that and what the rules are now, so the +next pass does not rediscover either. + +## What the previous cut got wrong + +1. **The scene was bigger than the screen.** Authored at 1920px and shown in a + 1440px viewport, it could never be seen whole, so every camera hold framed a + fragment of an interface rather than an interface. Nothing else on the list + mattered as much as this one. +2. **It was boxed into the 1040px measure.** Inside the page's content column, + a whole editor had to be drawn at a third of its natural size. +3. **Three shadowed rectangles competed** — chat, canvas, floor — with no ground + under any of them and no clear subject in any frame. +4. **It was generated from a spec and looked at once, at the end.** + +## The rules + +**Nothing is drawn that the current beat does not need.** Act one is a +composite and one inspector panel; there is no timeline, because a background +swap, a padding change and a cursor resize are all legible in a still frame and +the picture would rather have the room. Act two adds the timeline, because +zooms, a pinned note and cuts arriving from a transcript are only meaningful +against one. The floor slides in once, between the acts. + +**Everything is sized to the viewport.** Percentages, `min()` against a measure, +and container units inside the composite. There is no pixel scene and therefore +no framing that can go wrong at a width nobody tested. The band is the one +block on the page that escapes `.inner`. + +**One subject per frame.** A caption and a panel on the left, the composite on +the right, the floor at the bottom in act two. The composite gets a hairline and +a seated shadow, not a glow — a halo reads as a selection state on something +that is not selected. + +**One clock.** Scroll position becomes scene seconds, scene seconds become +document seconds in act two, and the playhead, the pill under it, the composite's +magnification and the transcript's cue are four readings of that one number. No +media element, no second timebase, nothing that can drift. + +**The beats touch.** A gap between beats is a stretch with no caption, no panel +and no palette — which looks like breathing room on paper and like the left half +of the screen going empty on screen. Two separate passes shipped that bug. + +**The hand is aimed at the control it operates, never at a coordinate.** Both +pointers name a target — `data-t` for the reader's, `data-shot` for the recorded +one — and the driver measures it once per resize, then re-expresses it as a +percentage of the stage each frame. The percentages are the output, not the +score: a path authored in them has to be re-tuned every time a panel moves and +is otherwise silently almost-right. The recorded pointer still carries authored +coordinates for the moments its tip is over nothing nameable — open canvas, the +travel between two controls. + +## What is real + +- **The panels** are the app's, by locale key: `PANELS` for the titles, + `CONTROLS` for every slider at this document's own setting, scaled and + suffixed the way `RightPanes.tsx` does it. Cursor size is `size * 10` over + 5–100 with one decimal and no unit — a hand-written panel gets that wrong in a + way that looks entirely plausible. +- **The padding slider** moves the composite through `PreviewCanvas.tsx`'s own + `clamp(1 - (padding/100) * 0.4, 0.4, 1)`, evaluated per frame. +- **The wallpapers** are the app's own — twelve of the eighteen it ships, in the + design's order — and the first one the take picks is the one `STAGE.wallpaper` + names. +- **The pills' scales and labels** are the document's: `effectiveZoomScale` for + the zooms, `toFixed(2)` and all, which is how a hand-typed "1.8×" gives itself + away. Their times are not. The document's own edit is two trims at the two ends + of a forty-second take — truthful, and almost nothing to look at — so the rail + stages nine objects over twenty-six seconds: three zooms, a speed ramp and five + trims. The annotation is the one drawn object; the document holds none, which + is exactly why that lane still shows its "Press A" hint. +- **The transcript** is staged for the same reason: 76 tokens against the + document's 106, and five of them strike — the three silences and the two + filler words. The document's own transcript, as `buildClipSection` builds it, + is in `generated.ts`; the pane does not read it. +- **The picture in the window** is a render of the page the recording was of. +- **The webcam bubble** is staged too, and is the one thing on screen the + document contradicts rather than merely omits: `cameraTrack.visible` is false, + so the bubble plays a clip of its own. + +`generated.ts` carries all of it and is emitted by `scripts/gen-recreation.mjs`. +Never edit it by hand; `--check` fails the Docs workflow when it drifts. + +That check compares its own output with the committed file byte for byte, so +the generator has to own the file's formatting outright — `biome.json` tells the +formatter to skip it, and the emitter writes TypeScript rather than JSON so that +what it produces is also what a reader wants to read. Anything that reformats it +afterwards makes the check report drift on a file nobody touched, which is +exactly what it did for as long as lint-staged was formatting it. + +## Verifying + +The in-app browser pane returns black frames for this page. Drive headless +Chrome over CDP instead, scroll to a scene time, and look at the result — every +defect in the list at the top of this file was visible in a screenshot and +invisible in the source. diff --git a/website/src/components/Recreation/driver.ts b/website/src/components/Recreation/driver.ts new file mode 100644 index 000000000..1be643f2a --- /dev/null +++ b/website/src/components/Recreation/driver.ts @@ -0,0 +1,759 @@ +/** + * Scroll in, custom properties out — plus one video seek and a handful of class + * changes. This is the only thing that runs per frame. + * + * Three rules keep it cheap. + * + * **One write target.** Everything lands on custom properties on `.stage`, so a + * frame is a few dozen string assignments and no layout. The rail's contents, + * the pills and the ruler all have static positions; only the rail is + * translated. + * + * **Geometry is read once per resize, never per frame.** The demonstration + * pointer aims at real controls — a swatch, a slider's 48% mark, a word in the + * transcript — so those have to be measured. They are measured on attach and on + * resize into a cache keyed by `data-t`, and the per-frame code only + * interpolates between cached numbers. A target that is display:none when the + * cache is built keeps its last known position, which is exactly right: the + * pointer is heading for a control the panel is about to show. + * + * **The video is seeked, never played.** `currentTime` is the footage clock. + * Seeks are issued latest-wins — one in flight, the newest pending value kept — + * which is the fastest cadence the decoder can hold without building a queue. + */ + +import { CURSORS } from "./generated"; +import { + BEATS, + CURSOR_CHOICE, + CUT_INDEX, + type Frame, + frameAt, + railScale, + SHOT_PATH, + type ShotKey, + strikeOf, + T_TOTAL, +} from "./scene"; + +export interface DriverRefs { + band: HTMLElement; + root: HTMLElement; + cam: HTMLVideoElement; + padValue: HTMLElement; + sizeValue: HTMLElement; + timeValue: HTMLElement; + cutsValue: HTMLElement; + flow: HTMLElement; +} + +export interface DriverClasses { + struck: string; +} + +export const SCENE_QUERIES = [ + "(min-width: 360px)", + "(prefers-reduced-motion: reduce)", + "(forced-colors: active)", +] as const; + +export function sceneEnabled(): boolean { + if (typeof window === "undefined") return false; + try { + const [wide, reduced, forced] = SCENE_QUERIES.map((q) => window.matchMedia(q).matches); + return wide && !reduced && !forced && CSS.supports("position", "sticky"); + } catch { + return false; + } +} + +/** Every custom property `apply` writes, so a driver that shuts down hands the + * element back to the stylesheet's resting values rather than freezing mid-ride. + * Attributes and classes are handed back by `release` below. */ +const WRITTEN = [ + "--t", + "--tf", + "--tl", + "--fit", + "--pad-pct", + "--frame-scale", + "--win-vis", + "--size-pct", + "--cur-size", + "--size-u", + "--zoom", + "--zoom-origin", + "--zoom-active", + "--shot-x", + "--shot-y", + "--shot-bounce", + "--shot-cursor", + "--shot-hx", + "--shot-hy", + "--page-y", + "--ui-x", + "--ui-y", + "--ui-bounce", + "--ui-on", + "--ui-cursor", + "--ui-hx", + "--ui-hy", + "--zooms-placed", + "--speed-placed", + "--panel", + "--intro", + "--palette", + "--wand", + "--comment", + "--k", +]; + +const fmtSec = (sec: number) => { + const s = Math.max(0, sec); + const m = Math.floor(s / 60); + const r = s - m * 60; + return `${m}:${Math.floor(r).toString().padStart(2, "0")}.${Math.floor((r % 1) * 10)}`; +}; + +/** Piecewise-linear read of `[[t, ...values]]`, clamped at both ends. */ +function kf(t: number, pts: number[][]): number[] { + if (t <= pts[0][0]) return pts[0].slice(1); + for (let i = 1; i < pts.length; i++) { + if (t <= pts[i][0]) { + const a = pts[i - 1]; + const b = pts[i]; + const k = (t - a[0]) / (b[0] - a[0]); + return a.slice(1).map((v, j) => v + (b[j + 1] - v) * k); + } + } + return pts[pts.length - 1].slice(1); +} + +/** Windows where the pointer is over something clickable, or over text. */ +const HOVERS = [ + [1.45, 3.05], + [3.4, 4.9], + [5.25, 6.6], + [7.55, 10.3], + [11.0, 12.63], + [12.7, 14.3], + [15.05, 16.6], + [17.5, 18.9], + [19.7, 20.4], + [20.95, 21.5], + [22.05, 22.36], +]; +const TEXTS = [ + [20.4, 20.95], + [21.5, 22.0], +]; +const inAny = (t: number, w: number[][]) => w.some(([a, b]) => t >= a && t < b); + +/** + * Hands the scene back to the stylesheet. + * + * Not only the custom properties: `apply` also selects the wallpaper, the + * cursor pack and the pointer art with attributes, writes each trim's opacity + * inline, and strikes words with a class. A driver that stops — the reader + * crossed a breakpoint, or turned reduced motion on mid-ride — has to give all + * of it back, or the still it hands over is a frozen frame of the ride rather + * than the closing one the stylesheet is written for. + * + * `data-bg` is restored rather than removed, because there is no resting value + * for it in the stylesheet: the wallpaper is chosen by attribute, and the still + * shows the one the take ends on. The markup renders the same expression. + */ +function release(refs: DriverRefs, cls: DriverClasses): void { + const { root, flow } = refs; + for (const name of WRITTEN) root.style.removeProperty(name); + delete root.dataset.beat; + delete root.dataset.cur; + delete root.dataset.curSel; + root.dataset.bg = String(frameAt(1).bg); + for (const el of Array.from(root.querySelectorAll("[data-trim]"))) { + el.style.removeProperty("opacity"); + } + for (const el of Array.from(flow.querySelectorAll(`.${cls.struck}`))) { + el.classList.remove(cls.struck); + } +} + +export function attachDriver(refs: DriverRefs, cls: DriverClasses): () => void { + if (!sceneEnabled()) { + release(refs, cls); + return () => { + // Nothing was attached, so there is nothing to detach. + }; + } + + const { band, root, cam, padValue, sizeValue, timeValue, cutsValue, flow } = refs; + let raf = 0; + + /* ── the target cache ─────────────────────────────────────────────────── */ + + /** + * The frame's numbers that place a box rather than paint one. + * + * Anything listed here has to be written before a beat is measured, or the + * pointer is aimed at where the target ISN'T. The test for membership is + * mechanical: grep the stylesheet, and if the variable appears in a `top`, + * a `transform`, a `height` or an `inset`, it belongs here. + */ + const GEOMETRY = [ + { css: "--tl", of: (f: Frame) => f.tl }, + { css: "--panel", of: (f: Frame) => f.panel }, + { css: "--palette", of: (f: Frame) => f.palette }, + ] as const; + + let targets = new Map(); + /** + * `claimed` makes the first beat that can see a target the one that owns it. + * + * The timeline beat deliberately keeps the cursor pane on screen so the panel + * does not empty while it fades (styles.module.css, ".floor's two beats hand + * the column to the palette"). That pane is therefore measurable twice: once + * in the cursor beat, where it plays, and again in the timeline beat, where + * the act has moved the whole column 151px. Last-write-wins handed the + * pointer the second one, and it spent the Size and Smoothing sliders that + * far above them. + */ + const measureVisible = (claimed?: Set) => { + const box = root.getBoundingClientRect(); + if (box.width <= 0) return; + for (const el of Array.from(root.querySelectorAll("[data-t]"))) { + // An element inside a hidden pane has no box; keep whatever it last + // measured, because the pointer is on its way to it. + if (el.offsetParent === null) continue; + const r = el.getBoundingClientRect(); + if (r.width <= 0) continue; + const name = el.dataset.t!; + if (claimed) { + // A target may declare the beat whose layout it should be measured + // in. The palette needs it: its buttons are visible in every beat, + // so "first beat that can see it" measures them in the opening act + // while the pointer only ever aims at them in the closing one. + const owner = el.dataset.tBeat; + if (owner && owner !== root.dataset.beat) continue; + if (claimed.has(name)) continue; + claimed.add(name); + } + // Anchors along a slider track are stored as the track's own geometry, + // so a percentage along it can be resolved without re-measuring. + targets.set(name, [ + ((r.left - box.left) / box.width) * 100, + ((r.top + r.height / 2 - box.top) / box.height) * 100, + ]); + targets.set(`${name}:w`, [(r.width / box.width) * 100, (r.height / box.height) * 100]); + } + }; + + /** + * Measure every target, including those inside a pane that is closed. + * + * Three of the four panes are `display: none` at any moment, and a closed + * pane's children have no box — so a single pass leaves the swatches, the + * sliders and the transcript's words at their fallback coordinates, and the + * pointer spends the whole ride a hundred pixels from everything it is + * meant to be clicking. That was the first defect this build shipped. + * + * Opening each pane in turn is also the only correct way to do it: the panes + * share one flow container, so showing them together would measure each one + * stacked below the others. + */ + /** + * Where the things the recorded pointer aims at actually are, in the frame's + * own per-cent — the space `--shot-x` is read in. + * + * Layout offsets and not rectangles: a rectangle comes back with every + * transform already applied, and three of them sit over this one — the + * zoomer's zoom, the frame's padding scale, the page's scroll. An offset + * ignores all three, so the box is measured once here and put back through + * them by `shotKey`, out of numbers the frame already carries. Nothing in + * the loop reads layout. + */ + type ShotBox = { + x: number; + y: number; + w: number; + h: number; + scrolls: boolean; + /** The recorded window's own box, when the target is inside it. */ + win: { x: number; y: number; w: number; h: number } | null; + }; + const shots = new Map(); + let shotSpace: { w: number; h: number; page: number } | null = null; + + const measureShots = () => { + const zoomer = root.querySelector("[data-shot-box]"); + if (!zoomer || zoomer.offsetWidth <= 0) return; + const page = root.querySelector("[data-shot-scroll]"); + shotSpace = { w: zoomer.offsetWidth, h: zoomer.offsetHeight, page: page?.offsetHeight ?? 0 }; + const winEl = root.querySelector("[data-shot-win]"); + let win: ShotBox["win"] = null; + for (const el of Array.from(root.querySelectorAll("[data-shot]"))) { + let x = 0; + let y = 0; + let n: HTMLElement | null = el; + while (n && n !== zoomer) { + if (n === winEl) win = { x, y, w: n.offsetWidth, h: n.offsetHeight }; + x += n.offsetLeft; + y += n.offsetTop; + n = n.offsetParent as HTMLElement | null; + } + // Not under the zoomer, or in a pane with no box: keep the last answer, + // the way the pointer's own targets do. + if (n !== zoomer) continue; + shots.set(el.dataset.shot!, { + x, + y, + w: el.offsetWidth, + h: el.offsetHeight, + scrolls: page?.contains(el) ?? false, + // Offsets are measured from the target, so the window's own box comes + // back relative to it; put it in the zoomer's frame. + win: win && { ...win, x: x - win.x, y: y - win.y }, + }); + win = null; + } + }; + + /** One keyframe of the recorded pointer's path. A named one is put back + * through the transforms between the thing and the pointer, in the order + * the browser applies them: the page scrolls inside the frame, the frame + * scales about the middle of the zoomer — which is also the middle of the + * frame, since it is inset by the same per-cent on both sides. */ + const shotKey = (k: ShotKey, f: Frame): [number, number] => { + if (typeof k[1] === "number") return [k[1], k[2]]; + const b = shots.get(k[1]); + if (!b || !shotSpace) return [50, 50]; + let x = b.x + k[2] * b.w; + let y = b.y + k[3] * b.h; + if (b.win) { + // The window scales as it opens, about a point of its own — so a target + // on it is somewhere else for as long as that lasts. Same shape as the + // stylesheet's `scale(0.82 + --win-vis * 0.18)`, and the same number + // only because both read `winVis` off the frame. + const s = 0.82 + f.winVis * 0.18; + x = b.win.x + 0.26 * b.win.w + (x - (b.win.x + 0.26 * b.win.w)) * s; + y = b.win.y + 0.42 * b.win.h + (y - (b.win.y + 0.42 * b.win.h)) * s; + } + x = (x / shotSpace.w) * 100; + y = (y / shotSpace.h) * 100; + if (b.scrolls) y += (f.pageY * shotSpace.page) / shotSpace.h; + return [50 + (x - 50) * f.frameScale, 50 + (y - 50) * f.frameScale]; + }; + + /** The path, interpolated after its keyframes resolve and not before: a + * named keyframe answers differently every frame. */ + const shotPoint = (f: Frame): [number, number] => { + const t = f.tf; + for (let i = 1; i < SHOT_PATH.length; i++) { + if (t <= SHOT_PATH[i][0]) { + const a = SHOT_PATH[i - 1]; + const b = SHOT_PATH[i]; + if (t <= a[0]) return shotKey(a, f); + const k = (t - a[0]) / (b[0] - a[0]); + const [ax, ay] = shotKey(a, f); + const [bx, by] = shotKey(b, f); + return [ax + (bx - ax) * k, ay + (by - ay) * k]; + } + } + return shotKey(SHOT_PATH[SHOT_PATH.length - 1], f); + }; + + /* The rail's scale, which is a function of the stage and not of the frame — + so it is written here, where resizes land, rather than every rAF. The + stylesheet's own `--k` is the resting value for a reader who never gets + the driver; this overrides it as soon as there is a width to divide. */ + const sizeRail = () => { + const w = root.getBoundingClientRect().width; + if (w > 0) root.style.setProperty("--k", railScale(w).toFixed(2)); + }; + + const measure = () => { + sizeRail(); + const had = root.dataset.beat; + const claimed = new Set(); + // Transitions off for the pass: see .stage[data-measuring]. + root.dataset.measuring = ""; + const kept = GEOMETRY.map((g) => [g.css, root.style.getPropertyValue(g.css)] as const); + for (const b of BEATS) { + root.dataset.beat = b.id; + // Opening the pane is not enough. Three of the frame's numbers place + // boxes rather than paint them, and a beat measured with the wrong ones + // is measured in the wrong place: --tl moves the panel and the + // composite by the 151px between the two acts, and --panel and + // --palette each hold their box at its entrance offset until they + // finish arriving. The pointer wore all three. + // + // frameAt takes PROGRESS, not seconds: the rAF calls it as + // frameAt(off / span). Handing it a midpoint in seconds asks for a + // frame past the end of the scene, which answers with the closing act + // for every beat, including the three that play in the opening one. + const f = frameAt((b.from + b.to) / 2 / T_TOTAL); + for (const g of GEOMETRY) root.style.setProperty(g.css, g.of(f).toFixed(3)); + measureVisible(claimed); + } + if (had === undefined) delete root.dataset.beat; + else root.dataset.beat = had; + for (const [css, was] of kept) { + if (was) root.style.setProperty(css, was); + else root.style.removeProperty(css); + } + /* Flush the restored state while transitions are still suppressed. + + Without this the browser's last computed style is the one from the end + of the loop — the transcript beat, because it is measured last — and + re-enabling transitions animates FROM there back to the real beat. On a + resize, which is when this runs, "Edit video like text" appeared at + full opacity and faded out over 0.4s. Suppressing the transitions made + that worse rather than better: it took the caption to 1 instantly + instead of starting a fade that would be interrupted. Reading a layout + property here makes the restored state the one transitions start from, + so there is nothing to animate. */ + void root.offsetHeight; + // After the restore, so the boxes are the ones the reader is looking at. + measureShots(); + delete root.dataset.measuring; + }; + + const at = (name: string, fx = 20, fy = 45): [number, number] => { + const p = targets.get(name); + const w = targets.get(`${name}:w`); + if (!p) return [fx, fy]; + // Centre by default; `along` handles the two sliders and the two words. + return [p[0] + (w ? w[0] / 2 : 0), p[1]]; + }; + const along = (name: string, frac: number, fx = 20, fy = 45): [number, number] => { + const p = targets.get(name); + const w = targets.get(`${name}:w`); + if (!p || !w) return [fx, fy]; + return [p[0] + w[0] * frac, p[1]]; + }; + + /** v4's pointer score, rebuilt each frame from the cache — the cache is what + * makes that free, and rebuilding is what lets a target that was hidden at + * attach time be aimed at correctly once its pane opens. */ + const path = (f: Frame): number[][] => { + /* The handle's own fraction, read off the frame that draws it, not a + number copied out of the design. The pointer is dragging these two + sliders; aiming it at a literal put it 10 to 14 per cent of the track + to the right of the thing it is supposed to be holding, because the + design's slider and ours do not carry the same value at the same + second. Now there is one number and the hand is on it. */ + const pad = () => along("padtrk", f.paddingPct / 100); + const sz = () => along("sztrk", f.cursorSizePct / 100); + const tok = (i: number) => at(`tok-${i}`); + const tokU = (i: number, ax: number) => along(`tok-${i}`, ax); + /* The five removable tokens, by the same list the strikes are keyed to. + These were written as literal indices — 12, 13, 30, 35, 39 — and the + moment the transcript was rewritten around them, two of them pointed + at ordinary words: the pointer swept the wrong word and struck nothing + while the cut landed somewhere else. CUT_INDEX is derived from the + tokens' own `cut` field, so this cannot drift again. */ + const [c0, c1, c2, c3, c4] = CUT_INDEX; + /* Same trap, same fix: the swatch the pointer presses and the pack the + frame selects were two literals that had to agree, and reordering the + picks silently left the hand on the wrong one. */ + const [, cur1, cur2] = CURSOR_CHOICE; + return [ + [0.4, 58, 66], + [1.45, ...at("th-1")], + [3.05, ...at("th-1")], + [3.4, ...at("th-2")], + [4.9, ...at("th-2")], + [5.25, ...at("th-3")], + [6.6, ...at("th-3")], + [7.55, ...pad()], + [7.95, ...pad()], + [9.0, ...pad()], + [10.3, ...pad()], + [11.0, ...at(`cur-${cur1}`)], + [11.9, ...at(`cur-${cur1}`)], + [12.15, ...at(`cur-${cur2}`)], + [12.63, ...at(`cur-${cur2}`)], + [12.7, ...sz()], + [13.2, ...sz()], + [14.3, ...sz()], + [15.05, ...at("wand")], + [16.6, ...at("wand")], + [17.5, ...at("comment")], + [18.9, ...at("comment")], + [19.75, ...tok(c0)], + [20.35, ...tok(c0)], + [20.5, ...tokU(c1, 1.02)], + [20.699, ...tokU(c1, 1.02)], + [20.7, ...tokU(c1, 0.02)], + [20.85, ...tokU(c1, 0.02)], + [21.0, ...tok(c2)], + [21.45, ...tok(c2)], + [21.6, ...tokU(c3, 1.02)], + [21.799, ...tokU(c3, 1.02)], + [21.8, ...tokU(c3, 0.02)], + [21.95, ...tokU(c3, 0.02)], + [22.1, ...tok(c4)], + [22.36, ...tok(c4)], + ]; + }; + + /* ── the webcam ───────────────────────────────────────────────────────── */ + + let camReady = false; + let camPending: number | undefined; + const camSrc = "/video/webcam.mp4"; + + // Set at attach, not in the markup and not in primeCam: in the markup every + // reader who never reaches the band pays for it, and in primeCam it would + // race the clip it exists to stand in for. Here it has the whole approach to + // the band to arrive — and on a phone, where the scene does run (the gate is + // 360px), it is still only fetched by a reader who scrolls into it. + cam.poster = "/img/walkthrough/webcam-poster.jpg"; + const primeCam = () => { + if (cam.getAttribute("src")) return; + cam.setAttribute("src", camSrc); + cam.preload = "auto"; + cam.load(); + }; + const onCamLoaded = () => { + camReady = true; + }; + const onCamSeeked = () => { + if (camPending !== undefined) { + const q = camPending; + camPending = undefined; + try { + cam.currentTime = q; + } catch { + // A seek past a not-yet-buffered range throws; the next frame retries. + } + } + }; + cam.addEventListener("loadeddata", onCamLoaded); + cam.addEventListener("seeked", onCamSeeked); + /* ── the swatch strip ─────────────────────────────────────────────────── */ + + /* The twelve wallpapers are `loading="lazy"` inside a pane that is + `display: none` until the style beat opens — which means the browser never + gets a chance to want them early, and all twelve are requested at the + moment they are first needed. Measured on a 1.5 Mbps link: the strip was + still blank half a second into the beat it belongs to. + + Warmed here rather than in the markup: this runs on the same in-band gate + that primes the clip, so it costs nothing until the reader is actually + arriving, and nothing at all below 360px, where the driver never attaches + and the still fallback stands in. */ + let stripPrimed = false; + const primeStrip = () => { + if (stripPrimed) return; + stripPrimed = true; + for (const img of Array.from(root.querySelectorAll("[data-strip] img"))) { + img.loading = "eager"; + img.decode?.().catch(() => { + // A decode that loses a race with teardown is not worth reporting. + }); + } + }; + + const seekCam = (tf: number) => { + if (!camReady || cam.readyState < 2) return; + const dur = Number.isFinite(cam.duration) && cam.duration > 0 ? cam.duration : 10; + const t = ((tf % dur) + dur) % dur; + if (cam.seeking) camPending = t; + else if (Math.abs((cam.currentTime || 0) - t) > 0.033) { + try { + cam.currentTime = t; + } catch { + // ignored — see above + } + } + }; + + /* ── the frame ────────────────────────────────────────────────────────── */ + + let lastBeat: string | null | undefined; + let lastPad = ""; + let lastSize = ""; + let lastTime = ""; + let lastCuts = ""; + let lastArt = ""; + let lastTheme = -1; + const struck = new Set(); + let trimEls: HTMLElement[] = []; + + const num = (n: string, v: number, dp = 4) => root.style.setProperty(n, v.toFixed(dp)); + + const apply = (f: Frame) => { + num("--t", f.t, 3); + num("--tf", f.tf, 3); + // Three decimals, not zero. This was written at `toFixed(0)` from when --tl + // was a switch; the score has eased it into a ramp since, and rounding to + // the integer threw the ramp away and snapped the floor, the panel and the + // composite from one act to the other in a single frame. + num("--tl", f.tl, 3); + if (String(f.bg) !== root.dataset.bg) root.dataset.bg = String(f.bg); + num("--fit", f.fit); + num("--pad-pct", f.paddingPct, 2); + num("--frame-scale", f.frameScale, 4); + num("--win-vis", f.winVis, 4); + num("--size-pct", f.cursorSizePct, 2); + num("--cur-size", f.cursorSize, 2); + num("--size-u", f.cursorSizeU, 4); + num("--zoom", f.zoom, 4); + root.style.setProperty("--zoom-origin", f.zoomOrigin); + num("--zoom-active", f.zoomActive, 0); + const [shotX, shotY] = shotPoint(f); + num("--shot-x", shotX, 2); + num("--shot-y", shotY, 2); + num("--shot-bounce", f.shotBounce, 4); + num("--page-y", f.pageY, 2); + num("--ui-bounce", f.uiBounce, 4); + num("--zooms-placed", f.zoomsPlaced, 0); + num("--speed-placed", f.speedPlaced, 0); + num("--panel", f.panel, 0); + // A switch, so no decimals to lose — unlike --tl, which is a ramp. + num("--intro", f.intro, 0); + num("--palette", f.palette, 0); + num("--wand", f.wand, 0); + num("--comment", f.comment, 0); + // Written onto the elements, not through a variable and a positional + // selector — see the note in the stylesheet. + if (!trimEls.length) trimEls = Array.from(root.querySelectorAll("[data-trim]")); + f.trims.forEach((c, i) => { + const el = trimEls[i]; + if (el) el.style.opacity = c.placed ? "1" : "0"; + }); + + // The pointer inside the recording wears the pack the picker selected. + if (f.cursorTheme !== lastTheme) { + lastTheme = f.cursorTheme; + root.dataset.curSel = String(f.cursorTheme); + const theme = CURSORS.themes[f.cursorTheme]; + root.style.setProperty("--shot-cursor", `url(${theme.src})`); + num("--shot-hx", theme.hotspotX * 100, 2); + num("--shot-hy", theme.hotspotY * 100, 2); + } + + // The reader's pointer: arrow, pointer over a control, caret over text. + // The hotspot is the app's own, which is why the tip lands on the target + // rather than near it. + const art = inAny(f.t, TEXTS) ? "text" : inAny(f.t, HOVERS) ? "pointer" : "arrow"; + if (art !== lastArt) { + lastArt = art; + const sprite = + art === "text" ? CURSORS.text : art === "pointer" ? CURSORS.pointer : CURSORS.themes[0]; + root.style.setProperty("--ui-cursor", `url(${sprite.src})`); + num("--ui-hx", sprite.hotspotX * 100, 2); + num("--ui-hy", sprite.hotspotY * 100, 2); + root.dataset.cur = art; + } + + const [ux, uy] = kf(Math.min(f.t, 22.36), path(f)); + num("--ui-x", ux, 2); + num("--ui-y", uy, 2); + num("--ui-on", f.t > 1.1 && f.t < 22.36 ? 1 : 0, 0); + + if (f.beat !== lastBeat) { + lastBeat = f.beat; + // Set, never unset. The beats tile the scene so `f.beat` is always + // defined, and this keeps a future gap from falling through to the + // stylesheet's undriven fallback — which is the closing frame, not an + // empty one. Teardown still clears it. + if (f.beat) root.dataset.beat = f.beat; + } + + const pad = `${Math.round(f.padding)}%`; + if (pad !== lastPad) padValue.textContent = lastPad = pad; + // One decimal and no suffix: RightPanes gives this one slider `decimals={1}` + // and no unit, unlike every other slider on the panel. + const size = f.cursorSize.toFixed(1); + if (size !== lastSize) sizeValue.textContent = lastSize = size; + const time = fmtSec(f.tf); + if (time !== lastTime) timeValue.textContent = lastTime = time; + const cuts = f.cutCount ? `−${f.cutCount} · −${f.saved.toFixed(1)}s` : ""; + if (cuts !== lastCuts) cutsValue.textContent = lastCuts = cuts; + + // Only the five removable entries can ever change, so the other forty + // nodes in the transcript are never touched. + for (const i of CUT_INDEX) { + const on = f.t >= strikeOf(i); + if (on === struck.has(i)) continue; + on ? struck.add(i) : struck.delete(i); + flow.querySelector(`[data-w="${i}"]`)?.classList.toggle(cls.struck, on); + } + + seekCam(f.tf); + }; + + /* ── the scroll ───────────────────────────────────────────────────────── */ + + const onScroll = () => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + const rect = band.getBoundingClientRect(); + const total = rect.height - window.innerHeight; + // The ride overflows the sticky on purpose: the last stretch plays while + // the section is already scrolling away, so the editor is not still + // sitting pinned and finished for a whole viewport. + const span = total + window.innerHeight * 1.04; + const off = Math.min(span, Math.max(0, -rect.top)); + if (off > 0 && off < span) { + primeCam(); + primeStrip(); + } + apply(frameAt(span > 0 ? off / span : 0)); + }); + }; + + // Coalesced into a frame, like the scroll. `measure()` opens all five beats, + // reads a rect for every `data-t` node and forces layout to do it; a window + // drag fires resize many times a second, and running that work per event was + // the one thing on this element's main thread that could take longer than + // the frame it was in. + let resizeRaf = 0; + const onResize = () => { + if (resizeRaf) return; + resizeRaf = requestAnimationFrame(() => { + resizeRaf = 0; + measure(); + onScroll(); + }); + }; + + let detached = false; + + measure(); + // Targets inside a closed pane cannot be measured until it opens, and the + // panes open on scroll — so re-measure once the fonts have settled, which is + // also when the transcript's words stop moving. + // + // Guarded: the driver re-attaches whenever a SCENE_QUERIES breakpoint is + // crossed, and by then `fonts.ready` is already resolved, so an unguarded + // `.then` runs a second full measuring pass — five panes opened and closed — + // for a driver that has since been thrown away. + document.fonts?.ready + .then(() => { + if (!detached) measure(); + }) + .catch(() => { + // A font that never resolves leaves the first measurement standing. + }); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onResize); + onScroll(); + + return () => { + detached = true; + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onResize); + // The video outlives the driver — it is the same element on re-attach — + // so listeners left on it accumulate one pair per breakpoint crossing, + // each holding a dead driver's closure alive. + cam.removeEventListener("loadeddata", onCamLoaded); + cam.removeEventListener("seeked", onCamSeeked); + if (raf) cancelAnimationFrame(raf); + if (resizeRaf) cancelAnimationFrame(resizeRaf); + }; +} + +export { T_TOTAL }; diff --git a/website/src/components/Recreation/fixture-slim.json b/website/src/components/Recreation/fixture-slim.json new file mode 100644 index 000000000..fd0fb3450 --- /dev/null +++ b/website/src/components/Recreation/fixture-slim.json @@ -0,0 +1,912 @@ +{ + "schemaVersion": 7, + "project": { + "id": "proj_97e2ec4f-78a1-4198-9743-05511c204daa", + "title": "Bellrock — docs walkthrough", + "createdAt": "2026-08-05T12:00:00.000Z", + "updatedAt": "2026-08-16T08:12:08.607Z", + "primaryAssetId": "asset_98800b62-9429-4a8a-9c12-fef7af70b160" + }, + "assets": [ + { + "id": "asset_98800b62-9429-4a8a-9c12-fef7af70b160", + "kind": "video", + "label": "Bellrock — docs walkthrough", + "originalPath": "/Users/example/Library/Application Support/openscreen/recordings/recording-1786032000000.mp4", + "durationSec": 40.033, + "sizeBytes": 8194081, + "video": { + "codec": "unknown", + "width": 1920, + "height": 1080, + "fps": 0 + }, + "cameraTrack": { + "sourcePath": "/Users/example/Library/Application Support/openscreen/recordings/recording-1786032000000-webcam.mp4", + "startMs": 0, + "offsetMs": 0, + "visible": false + } + } + ], + "transcript": { + "assetId": "asset_98800b62-9429-4a8a-9c12-fef7af70b160", + "language": "en", + "words": [ + { + "id": "word_1", + "segmentId": "seg_1", + "startSec": 2.19, + "endSec": 2.63, + "text": "Hi," + }, + { + "id": "word_2", + "segmentId": "seg_2", + "startSec": 2.63, + "endSec": 3.02, + "text": "quick" + }, + { + "id": "word_3", + "segmentId": "seg_3", + "startSec": 3.02, + "endSec": 3.08, + "text": "walk" + }, + { + "id": "word_4", + "segmentId": "seg_4", + "startSec": 3.08, + "endSec": 3.29, + "text": "through" + }, + { + "id": "word_5", + "segmentId": "seg_5", + "startSec": 3.29, + "endSec": 3.95, + "text": "the" + }, + { + "id": "word_6", + "segmentId": "seg_6", + "startSec": 3.95, + "endSec": 4.62, + "text": "documentation" + }, + { + "id": "word_7", + "segmentId": "seg_7", + "startSec": 4.62, + "endSec": 4.94, + "text": "site." + }, + { + "id": "word_8", + "segmentId": "seg_8", + "startSec": 4.94, + "endSec": 5.2, + "text": "The" + }, + { + "id": "word_9", + "segmentId": "seg_9", + "startSec": 5.2, + "endSec": 5.5200000000000005, + "text": "new" + }, + { + "id": "word_10", + "segmentId": "seg_10", + "startSec": 5.5200000000000005, + "endSec": 5.62, + "text": "release" + }, + { + "id": "word_11", + "segmentId": "seg_11", + "startSec": 5.62, + "endSec": 5.79, + "text": "went" + }, + { + "id": "word_12", + "segmentId": "seg_12", + "startSec": 5.79, + "endSec": 6.01, + "text": "out" + }, + { + "id": "word_13", + "segmentId": "seg_13", + "startSec": 6.01, + "endSec": 6.47, + "text": "this" + }, + { + "id": "word_14", + "segmentId": "seg_14", + "startSec": 6.47, + "endSec": 6.82, + "text": "morning." + }, + { + "id": "word_15", + "segmentId": "seg_15", + "startSec": 6.83, + "endSec": 7.2, + "text": "The" + }, + { + "id": "word_16", + "segmentId": "seg_16", + "startSec": 7.2, + "endSec": 7.37, + "text": "hero" + }, + { + "id": "word_17", + "segmentId": "seg_17", + "startSec": 7.37, + "endSec": 7.57, + "text": "does" + }, + { + "id": "word_18", + "segmentId": "seg_18", + "startSec": 7.57, + "endSec": 7.88, + "text": "the" + }, + { + "id": "word_19", + "segmentId": "seg_19", + "startSec": 7.88, + "endSec": 8.16, + "text": "work" + }, + { + "id": "word_20", + "segmentId": "seg_20", + "startSec": 8.16, + "endSec": 8.58, + "text": "now." + }, + { + "id": "word_21", + "segmentId": "seg_21", + "startSec": 8.58, + "endSec": 9.06, + "text": "One" + }, + { + "id": "word_22", + "segmentId": "seg_22", + "startSec": 9.06, + "endSec": 9.5, + "text": "line," + }, + { + "id": "word_23", + "segmentId": "seg_23", + "startSec": 9.5, + "endSec": 10.040000000000001, + "text": "one" + }, + { + "id": "word_24", + "segmentId": "seg_24", + "startSec": 10.040000000000001, + "endSec": 10.41, + "text": "promise," + }, + { + "id": "word_25", + "segmentId": "seg_25", + "startSec": 10.41, + "endSec": 10.6, + "text": "and" + }, + { + "id": "word_26", + "segmentId": "seg_26", + "startSec": 10.6, + "endSec": 10.950000000000001, + "text": "the" + }, + { + "id": "word_27", + "segmentId": "seg_27", + "startSec": 10.950000000000001, + "endSec": 11.21, + "text": "install" + }, + { + "id": "word_28", + "segmentId": "seg_28", + "startSec": 11.21, + "endSec": 11.61, + "text": "command" + }, + { + "id": "word_29", + "segmentId": "seg_29", + "startSec": 11.61, + "endSec": 11.97, + "text": "right" + }, + { + "id": "word_30", + "segmentId": "seg_30", + "startSec": 11.97, + "endSec": 12.290000000000001, + "text": "under" + }, + { + "id": "word_31", + "segmentId": "seg_31", + "startSec": 12.290000000000001, + "endSec": 12.68, + "text": "it." + }, + { + "id": "word_32", + "segmentId": "seg_32", + "startSec": 12.68, + "endSec": 13.25, + "text": "Scrolling" + }, + { + "id": "word_33", + "segmentId": "seg_33", + "startSec": 13.25, + "endSec": 13.63, + "text": "down," + }, + { + "id": "word_34", + "segmentId": "seg_34", + "startSec": 13.63, + "endSec": 14.02, + "text": "those" + }, + { + "id": "word_35", + "segmentId": "seg_35", + "startSec": 14.02, + "endSec": 14.4, + "text": "six" + }, + { + "id": "word_36", + "segmentId": "seg_36", + "startSec": 14.4, + "endSec": 14.450000000000001, + "text": "cards" + }, + { + "id": "word_37", + "segmentId": "seg_37", + "startSec": 14.450000000000001, + "endSec": 14.620000000000001, + "text": "are" + }, + { + "id": "word_38", + "segmentId": "seg_38", + "startSec": 14.620000000000001, + "endSec": 14.77, + "text": "the" + }, + { + "id": "word_39", + "segmentId": "seg_39", + "startSec": 14.77, + "endSec": 15.21, + "text": "whole" + }, + { + "id": "word_40", + "segmentId": "seg_40", + "startSec": 15.21, + "endSec": 15.530000000000001, + "text": "library" + }, + { + "id": "word_41", + "segmentId": "seg_41", + "startSec": 15.530000000000001, + "endSec": 15.75, + "text": "on" + }, + { + "id": "word_42", + "segmentId": "seg_42", + "startSec": 15.75, + "endSec": 16.13, + "text": "one" + }, + { + "id": "word_43", + "segmentId": "seg_43", + "startSec": 16.13, + "endSec": 16.62, + "text": "screen." + }, + { + "id": "word_44", + "segmentId": "seg_44", + "startSec": 16.82, + "endSec": 16.93, + "text": "No" + }, + { + "id": "word_45", + "segmentId": "seg_45", + "startSec": 16.93, + "endSec": 17.37, + "text": "page" + }, + { + "id": "word_46", + "segmentId": "seg_46", + "startSec": 17.37, + "endSec": 17.62, + "text": "gets" + }, + { + "id": "word_47", + "segmentId": "seg_47", + "startSec": 17.62, + "endSec": 17.7, + "text": "more" + }, + { + "id": "word_48", + "segmentId": "seg_48", + "startSec": 17.7, + "endSec": 17.96, + "text": "than" + }, + { + "id": "word_49", + "segmentId": "seg_49", + "startSec": 17.96, + "endSec": 18.18, + "text": "a" + }, + { + "id": "word_50", + "segmentId": "seg_50", + "startSec": 18.18, + "endSec": 18.93, + "text": "sentence." + }, + { + "id": "word_51", + "segmentId": "seg_51", + "startSec": 18.93, + "endSec": 19.07, + "text": "Over" + }, + { + "id": "word_52", + "segmentId": "seg_52", + "startSec": 19.07, + "endSec": 19.56, + "text": "on" + }, + { + "id": "word_53", + "segmentId": "seg_53", + "startSec": 19.56, + "endSec": 20.17, + "text": "status," + }, + { + "id": "word_54", + "segmentId": "seg_54", + "startSec": 20.17, + "endSec": 20.650000000000002, + "text": "every" + }, + { + "id": "word_55", + "segmentId": "seg_55", + "startSec": 20.650000000000002, + "endSec": 21.06, + "text": "component" + }, + { + "id": "word_56", + "segmentId": "seg_56", + "startSec": 21.06, + "endSec": 21.31, + "text": "with" + }, + { + "id": "word_57", + "segmentId": "seg_57", + "startSec": 21.31, + "endSec": 21.490000000000002, + "text": "30" + }, + { + "id": "word_58", + "segmentId": "seg_58", + "startSec": 21.490000000000002, + "endSec": 21.82, + "text": "days" + }, + { + "id": "word_59", + "segmentId": "seg_59", + "startSec": 21.82, + "endSec": 21.91, + "text": "of" + }, + { + "id": "word_60", + "segmentId": "seg_60", + "startSec": 21.91, + "endSec": 22.36, + "text": "probes" + }, + { + "id": "word_61", + "segmentId": "seg_61", + "startSec": 22.36, + "endSec": 22.740000000000002, + "text": "behind" + }, + { + "id": "word_62", + "segmentId": "seg_62", + "startSec": 22.740000000000002, + "endSec": 23.05, + "text": "it." + }, + { + "id": "word_63", + "segmentId": "seg_63", + "startSec": 23.19, + "endSec": 23.45, + "text": "One" + }, + { + "id": "word_64", + "segmentId": "seg_64", + "startSec": 23.45, + "endSec": 23.63, + "text": "amber" + }, + { + "id": "word_65", + "segmentId": "seg_65", + "startSec": 23.63, + "endSec": 23.830000000000002, + "text": "day" + }, + { + "id": "word_66", + "segmentId": "seg_66", + "startSec": 23.830000000000002, + "endSec": 24.060000000000002, + "text": "on" + }, + { + "id": "word_67", + "segmentId": "seg_67", + "startSec": 24.060000000000002, + "endSec": 24.27, + "text": "the" + }, + { + "id": "word_68", + "segmentId": "seg_68", + "startSec": 24.27, + "endSec": 24.94, + "text": "validator," + }, + { + "id": "word_69", + "segmentId": "seg_69", + "startSec": 24.94, + "endSec": 25.19, + "text": "and" + }, + { + "id": "word_70", + "segmentId": "seg_70", + "startSec": 25.19, + "endSec": 25.560000000000002, + "text": "it" + }, + { + "id": "word_71", + "segmentId": "seg_71", + "startSec": 25.560000000000002, + "endSec": 25.990000000000002, + "text": "recovered," + }, + { + "id": "word_72", + "segmentId": "seg_72", + "startSec": 25.990000000000002, + "endSec": 26.54, + "text": "and" + }, + { + "id": "word_73", + "segmentId": "seg_73", + "startSec": 26.54, + "endSec": 27.09, + "text": "releases." + }, + { + "id": "word_74", + "segmentId": "seg_74", + "startSec": 27.32, + "endSec": 27.400000000000002, + "text": "Three" + }, + { + "id": "word_75", + "segmentId": "seg_75", + "startSec": 27.400000000000002, + "endSec": 27.45, + "text": "of" + }, + { + "id": "word_76", + "segmentId": "seg_76", + "startSec": 27.45, + "endSec": 28.060000000000002, + "text": "them," + }, + { + "id": "word_77", + "segmentId": "seg_77", + "startSec": 28.060000000000002, + "endSec": 28.54, + "text": "newest" + }, + { + "id": "word_78", + "segmentId": "seg_78", + "startSec": 28.54, + "endSec": 28.95, + "text": "first," + }, + { + "id": "word_79", + "segmentId": "seg_79", + "startSec": 28.95, + "endSec": 29.240000000000002, + "text": "and" + }, + { + "id": "word_80", + "segmentId": "seg_80", + "startSec": 29.240000000000002, + "endSec": 29.53, + "text": "every" + }, + { + "id": "word_81", + "segmentId": "seg_81", + "startSec": 29.53, + "endSec": 29.88, + "text": "line" + }, + { + "id": "word_82", + "segmentId": "seg_82", + "startSec": 29.88, + "endSec": 30.09, + "text": "links" + }, + { + "id": "word_83", + "segmentId": "seg_83", + "startSec": 30.09, + "endSec": 30.32, + "text": "into" + }, + { + "id": "word_84", + "segmentId": "seg_84", + "startSec": 30.32, + "endSec": 30.560000000000002, + "text": "the" + }, + { + "id": "word_85", + "segmentId": "seg_85", + "startSec": 30.560000000000002, + "endSec": 30.62, + "text": "commit" + }, + { + "id": "word_86", + "segmentId": "seg_86", + "startSec": 30.62, + "endSec": 30.810000000000002, + "text": "that" + }, + { + "id": "word_87", + "segmentId": "seg_87", + "startSec": 30.810000000000002, + "endSec": 31.1, + "text": "did" + }, + { + "id": "word_88", + "segmentId": "seg_88", + "startSec": 31.1, + "endSec": 31.560000000000002, + "text": "it." + }, + { + "id": "word_89", + "segmentId": "seg_89", + "startSec": 31.39, + "endSec": 31.62, + "text": "Back" + }, + { + "id": "word_90", + "segmentId": "seg_90", + "startSec": 31.62, + "endSec": 31.76, + "text": "to" + }, + { + "id": "word_91", + "segmentId": "seg_91", + "startSec": 31.76, + "endSec": 32.160000000000004, + "text": "the" + }, + { + "id": "word_92", + "segmentId": "seg_92", + "startSec": 32.160000000000004, + "endSec": 32.46, + "text": "top," + }, + { + "id": "word_93", + "segmentId": "seg_93", + "startSec": 32.46, + "endSec": 32.730000000000004, + "text": "and" + }, + { + "id": "word_94", + "segmentId": "seg_94", + "startSec": 32.730000000000004, + "endSec": 32.86, + "text": "that" + }, + { + "id": "word_95", + "segmentId": "seg_95", + "startSec": 32.86, + "endSec": 32.9, + "text": "is" + }, + { + "id": "word_96", + "segmentId": "seg_96", + "startSec": 32.9, + "endSec": 33.07, + "text": "the" + }, + { + "id": "word_97", + "segmentId": "seg_97", + "startSec": 33.07, + "endSec": 33.55, + "text": "whole" + }, + { + "id": "word_98", + "segmentId": "seg_98", + "startSec": 33.55, + "endSec": 34.03, + "text": "flow." + }, + { + "id": "word_99", + "segmentId": "seg_99", + "startSec": 33.99, + "endSec": 34.19, + "text": "Tell" + }, + { + "id": "word_100", + "segmentId": "seg_100", + "startSec": 34.19, + "endSec": 34.5, + "text": "me" + }, + { + "id": "word_101", + "segmentId": "seg_101", + "startSec": 34.5, + "endSec": 34.53, + "text": "what" + }, + { + "id": "word_102", + "segmentId": "seg_102", + "startSec": 34.53, + "endSec": 35, + "text": "you" + }, + { + "id": "word_103", + "segmentId": "seg_103", + "startSec": 35, + "endSec": 35.12, + "text": "think." + } + ] + }, + "timeline": { + "clips": [ + { + "id": "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe", + "assetId": "asset_98800b62-9429-4a8a-9c12-fef7af70b160", + "sourceStartSec": 0, + "sourceEndSec": 40.033, + "timelineStartSec": 0, + "timelineEndSec": 40.033, + "wordRefs": [], + "origin": "user", + "reason": "Auto-created full-duration clip" + } + ], + "gaps": [], + "trimRanges": [ + { + "id": "trim_f52989cf-489c-47f5-a6c7-b95a7d71b399", + "assetId": "asset_98800b62-9429-4a8a-9c12-fef7af70b160", + "clipId": "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe", + "startSec": 0, + "endSec": 2.19, + "reason": "Lead-in silence before 'Hi' (2.19s, >1s)", + "origin": "agent" + }, + { + "id": "trim_3bad6006-721a-4dd4-a218-091fa734a63e", + "assetId": "asset_98800b62-9429-4a8a-9c12-fef7af70b160", + "clipId": "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe", + "startSec": 35.12, + "endSec": 40.033, + "reason": "Trailing dead air after 'think.' (~4.9s, >1s)", + "origin": "agent" + } + ], + "muteRanges": [], + "speedRanges": [], + "captionRanges": [] + }, + "annotations": [], + "zoomRanges": [ + { + "id": "zoom_403360f0-0784-4034-994a-a79eb4e5aa55", + "startMs": 4200, + "endMs": 12600, + "clipId": "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe", + "sourceStartSec": 4.2, + "sourceEndSec": 12.6, + "depth": 3, + "focus": { + "cx": 0.5, + "cy": 0.45 + }, + "focusMode": "auto", + "source": "auto" + }, + { + "id": "zoom_2d7bd6ce-02e1-4f7d-ad63-03ada76859e3", + "startMs": 17500, + "endMs": 24500, + "clipId": "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe", + "sourceStartSec": 17.5, + "sourceEndSec": 24.5, + "depth": 4, + "focus": { + "cx": 0.5, + "cy": 0.5 + }, + "focusMode": "manual", + "source": "manual" + }, + { + "id": "zoom_11caaa47-6be5-4afa-bf1d-f2e6650f2066", + "startMs": 33112, + "endMs": 35156, + "clipId": "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe", + "sourceStartSec": 33.112, + "sourceEndSec": 35.156, + "depth": 3, + "focus": { + "cx": 0.5, + "cy": 0.5 + }, + "focusMode": "manual" + } + ], + "legacyEditor": { + "aspectRatio": "16:9", + "wallpaper": "/wallpapers/wallpaper5.jpg", + "padding": 55, + "borderRadius": 40, + "shadowIntensity": 0.35, + "showBlur": true, + "motionBlurAmount": 0.3, + "cursorTheme": "default", + "cursorSize": 4.5, + "cursorSmoothing": 0.35, + "cursorMotionBlur": 0.25, + "autoFocusAll": true, + "webcamLayoutPreset": "no-webcam", + "cameraFullscreenRegions": [], + "captions": { + "enabled": false, + "language": null, + "fontSize": 92, + "fontFamily": "Inter", + "fontWeight": "bold", + "color": "#ffffff", + "backgroundEnabled": true, + "backgroundColor": "#000000", + "backgroundOpacity": 0.55, + "verticalPosition": "bottom", + "textAlign": "center", + "offsetY": 0, + "width": 80, + "minWordsPerLine": 2, + "maxWordsPerLine": 7 + } + }, + "vendoredWaveform": { + "note": "NOT schemaVersion 7. Decoded from the asset's audio track by scripts/gen-recreation.mjs --vendor; vendored because the recording is not in this repo.", + "source": "recording-1786032000000.mp4", + "sampleRateHz": 48000, + "blocks": 7996, + "barCount": 320, + "amps": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.3359, 0.4772, 0.3846, 0.0352, 0.0002, + 0.3183, 0.3104, 0.4636, 0.3432, 0.3675, 0.3943, 0.4602, 0.4691, 0.3334, 0.3633, 0.3568, + 0.4292, 0.3232, 0.3248, 0.3064, 0.4396, 0.2607, 0.0121, 0.0046, 0.3881, 0.3022, 0.363, 0.3382, + 0.2618, 0.3437, 0.4135, 0.4093, 0.3779, 0.2869, 0.4464, 0.3854, 0.2956, 0.0535, 0.0002, + 0.3364, 0.334, 0.378, 0.3637, 0.4191, 0.293, 0.3317, 0.3736, 0.322, 0.4531, 0.4049, 0.1985, + 0.0002, 0.4395, 0.4077, 0.3881, 0.3956, 0.3776, 0.153, 0.0002, 0.2659, 0.444, 0.277, 0.4435, + 0.3974, 0.3658, 0.1242, 0.012, 0.3088, 0.3603, 0.3318, 0.3428, 0.3203, 0.4435, 0.3733, 0.3258, + 0.3444, 0.2858, 0.4398, 0.3514, 0.3963, 0.4068, 0.4094, 0.0359, 0.0002, 0.1393, 0.3036, + 0.3957, 0.3233, 0.3509, 0.3922, 0.3092, 0.1093, 0.0002, 0.3638, 0.3151, 0.3183, 0.1957, + 0.2973, 0.4466, 0.332, 0.4787, 0.377, 0.4199, 0.4155, 0.4372, 0.3765, 0.4151, 0.3954, 0.3995, + 0.3614, 0.403, 0.3006, 0.3348, 0.4034, 0.3151, 0.1194, 0.0002, 0.3269, 0.3424, 0.3465, 0.335, + 0.1969, 0.3703, 0.2767, 0.4121, 0.361, 0.3819, 0.3489, 0.3791, 0.3165, 0.3318, 0.1726, 0.0777, + 0.0002, 0.4443, 0.4218, 0.4262, 0.3944, 0.2476, 0.3558, 0.3466, 0.2852, 0.1, 0.001, 0.3855, + 0.3402, 0.316, 0.3213, 0.2141, 0.3775, 0.3565, 0.3483, 0.3604, 0.4128, 0.4153, 0.3147, 0.4081, + 0.3227, 0.3721, 0.3959, 0.4224, 0.1345, 0.3506, 0.4227, 0.3481, 0.3703, 0.0094, 0.0002, + 0.3997, 0.3777, 0.3575, 0.3516, 0.3641, 0.4105, 0.4037, 0.3991, 0.4018, 0.3985, 0.3655, + 0.4257, 0.4349, 0.3224, 0.0179, 0.3201, 0.3565, 0.3255, 0.3662, 0.4131, 0.4463, 0.3778, 0.039, + 0.0002, 0.3357, 0.361, 0.3435, 0.3279, 0.3159, 0.2934, 0.0813, 0.0002, 0.0471, 0.3638, 0.3751, + 0.2922, 0.3542, 0.1252, 0.0002, 0.3232, 0.3486, 0.3382, 0.0384, 0.3779, 0.3611, 0.1513, + 0.0127, 0.0037, 0.3684, 0.3736, 0.344, 0.4182, 0.3928, 0.2852, 0.3441, 0.1907, 0.3761, 0.3167, + 0.3671, 0.3164, 0.3555, 0.3766, 0.3326, 0.3536, 0.3717, 0.0134, 0.0002, 0.3501, 0.3589, + 0.3274, 0.3518, 0.2186, 0.4487, 0.391, 0.0034, 0.0013, 0.359, 0.3848, 0.3331, 0.3536, 0.38, + 0.4243, 0.2919, 0.4025, 0.4279, 0.3104, 0.0197, 0.0002, 0.3919, 0.425, 0.335, 0.4427, 0.3229, + 0.3462, 0.3811, 0.3775, 0.1691, 0.0062, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + } +} diff --git a/website/src/components/Recreation/generated.ts b/website/src/components/Recreation/generated.ts new file mode 100644 index 000000000..c5802e33d --- /dev/null +++ b/website/src/components/Recreation/generated.ts @@ -0,0 +1,876 @@ +/** + * GENERATED FILE — do not edit. Run `node scripts/gen-recreation.mjs` instead. + * + * Every value below was read out of, or computed from, one of: + * · src/components/Recreation/fixture-slim.json (the project document, schemaVersion 7) + * · ../src/i18n/locales/en/{editor,settings,timeline}.json + * · ../src/lib/ai-edition/timeline/{format,zoom-scale,aggregated-transcript}.ts, imported and run + * · ../src/components/ai-edition/v4/V4Timeline.tsx, lifted as source text and evaluated + * · ../src/styles/design-tokens.css + * · static/img/walkthrough/04-agent-a.jpg and editor-1560.jpg (two strings; see PROVENANCE) + * + * PROVENANCE names the source of every string this file puts on screen. Nothing + * that could not be sourced was invented to fill a gap: the chat's context pill + * is absent for that reason, and so is the webcam bubble. + */ + +export interface RecreationWord { + i: number; + id: string; + kind: "word" | "silence"; + text: string; + startSec: number; + endSec: number; + kept: boolean; + trimId: string | null; +} + +export interface RecreationPill { + lane: string; + id: string; + label: string; + startSec: number; + endSec: number; + leftPct: number; + widthPct: number; + /** zoom lanes only — the stored ordinal the label was derived from. */ + depth?: number; + /** zoom lanes only. */ + focusMode?: string; + /** trim lanes only — "agent" for the two the agent placed. */ + origin?: string; +} + +export interface RecreationLane { + id: string; + hint: string | null; + pills: RecreationPill[]; +} + +export interface ProvenanceEntry { + shown: string; + source: string; +} + +/** Where this data came from, and what it is a recreation of. */ +export const META = { + schemaVersion: 7, + projectId: "proj_97e2ec4f-78a1-4198-9743-05511c204daa", + projectTitle: "Bellrock — docs walkthrough", + assetLabel: "Bellrock — docs walkthrough", + assetDurationSec: 40.033, + assetVideo: { + codec: "unknown", + width: 1920, + height: 1080, + fps: 0, + }, + cameraTrackVisible: false, + generator: "website/scripts/gen-recreation.mjs", +} as const; + +/** The transcript pane's header. */ +export const INSPECTOR = { + title: "Current transcription", + indexBadge: "1", + filename: "Bellrock — docs walkthrough", + clipRange: "Clip 1 · 0:00.0—0:40.0", + wordCount: 103, + silenceCount: 3, + silenceThresholdSec: 0.2, +} as const; + +/** + * The transcript flow, produced by the app's own `buildClipSection`: 103 + * words and 3 silence markers, each tagged kept or removed against the + * document's two agent trims. 2 entries are removed, all of them silences. + */ +export const WORDS: RecreationWord[] = [ + { i: 0, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:silence_1", kind: "silence", text: "[silence 2.2s]", startSec: 0, endSec: 2.19, kept: false, trimId: "trim_f52989cf-489c-47f5-a6c7-b95a7d71b399" }, + { i: 1, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_1", kind: "word", text: "Hi,", startSec: 2.19, endSec: 2.63, kept: true, trimId: null }, + { i: 2, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_2", kind: "word", text: "quick", startSec: 2.63, endSec: 3.02, kept: true, trimId: null }, + { i: 3, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_3", kind: "word", text: "walk", startSec: 3.02, endSec: 3.08, kept: true, trimId: null }, + { i: 4, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_4", kind: "word", text: "through", startSec: 3.08, endSec: 3.29, kept: true, trimId: null }, + { i: 5, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_5", kind: "word", text: "the", startSec: 3.29, endSec: 3.95, kept: true, trimId: null }, + { i: 6, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_6", kind: "word", text: "documentation", startSec: 3.95, endSec: 4.62, kept: true, trimId: null }, + { i: 7, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_7", kind: "word", text: "site.", startSec: 4.62, endSec: 4.94, kept: true, trimId: null }, + { i: 8, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_8", kind: "word", text: "The", startSec: 4.94, endSec: 5.2, kept: true, trimId: null }, + { i: 9, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_9", kind: "word", text: "new", startSec: 5.2, endSec: 5.5200000000000005, kept: true, trimId: null }, + { i: 10, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_10", kind: "word", text: "release", startSec: 5.5200000000000005, endSec: 5.62, kept: true, trimId: null }, + { i: 11, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_11", kind: "word", text: "went", startSec: 5.62, endSec: 5.79, kept: true, trimId: null }, + { i: 12, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_12", kind: "word", text: "out", startSec: 5.79, endSec: 6.01, kept: true, trimId: null }, + { i: 13, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_13", kind: "word", text: "this", startSec: 6.01, endSec: 6.47, kept: true, trimId: null }, + { i: 14, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_14", kind: "word", text: "morning.", startSec: 6.47, endSec: 6.82, kept: true, trimId: null }, + { i: 15, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_15", kind: "word", text: "The", startSec: 6.83, endSec: 7.2, kept: true, trimId: null }, + { i: 16, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_16", kind: "word", text: "hero", startSec: 7.2, endSec: 7.37, kept: true, trimId: null }, + { i: 17, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_17", kind: "word", text: "does", startSec: 7.37, endSec: 7.57, kept: true, trimId: null }, + { i: 18, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_18", kind: "word", text: "the", startSec: 7.57, endSec: 7.88, kept: true, trimId: null }, + { i: 19, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_19", kind: "word", text: "work", startSec: 7.88, endSec: 8.16, kept: true, trimId: null }, + { i: 20, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_20", kind: "word", text: "now.", startSec: 8.16, endSec: 8.58, kept: true, trimId: null }, + { i: 21, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_21", kind: "word", text: "One", startSec: 8.58, endSec: 9.06, kept: true, trimId: null }, + { i: 22, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_22", kind: "word", text: "line,", startSec: 9.06, endSec: 9.5, kept: true, trimId: null }, + { i: 23, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_23", kind: "word", text: "one", startSec: 9.5, endSec: 10.040000000000001, kept: true, trimId: null }, + { i: 24, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_24", kind: "word", text: "promise,", startSec: 10.040000000000001, endSec: 10.41, kept: true, trimId: null }, + { i: 25, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_25", kind: "word", text: "and", startSec: 10.41, endSec: 10.6, kept: true, trimId: null }, + { i: 26, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_26", kind: "word", text: "the", startSec: 10.6, endSec: 10.950000000000001, kept: true, trimId: null }, + { i: 27, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_27", kind: "word", text: "install", startSec: 10.950000000000001, endSec: 11.21, kept: true, trimId: null }, + { i: 28, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_28", kind: "word", text: "command", startSec: 11.21, endSec: 11.61, kept: true, trimId: null }, + { i: 29, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_29", kind: "word", text: "right", startSec: 11.61, endSec: 11.97, kept: true, trimId: null }, + { i: 30, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_30", kind: "word", text: "under", startSec: 11.97, endSec: 12.290000000000001, kept: true, trimId: null }, + { i: 31, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_31", kind: "word", text: "it.", startSec: 12.290000000000001, endSec: 12.68, kept: true, trimId: null }, + { i: 32, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_32", kind: "word", text: "Scrolling", startSec: 12.68, endSec: 13.25, kept: true, trimId: null }, + { i: 33, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_33", kind: "word", text: "down,", startSec: 13.25, endSec: 13.63, kept: true, trimId: null }, + { i: 34, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_34", kind: "word", text: "those", startSec: 13.63, endSec: 14.02, kept: true, trimId: null }, + { i: 35, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_35", kind: "word", text: "six", startSec: 14.02, endSec: 14.4, kept: true, trimId: null }, + { i: 36, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_36", kind: "word", text: "cards", startSec: 14.4, endSec: 14.450000000000001, kept: true, trimId: null }, + { i: 37, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_37", kind: "word", text: "are", startSec: 14.450000000000001, endSec: 14.620000000000001, kept: true, trimId: null }, + { i: 38, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_38", kind: "word", text: "the", startSec: 14.620000000000001, endSec: 14.77, kept: true, trimId: null }, + { i: 39, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_39", kind: "word", text: "whole", startSec: 14.77, endSec: 15.21, kept: true, trimId: null }, + { i: 40, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_40", kind: "word", text: "library", startSec: 15.21, endSec: 15.530000000000001, kept: true, trimId: null }, + { i: 41, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_41", kind: "word", text: "on", startSec: 15.530000000000001, endSec: 15.75, kept: true, trimId: null }, + { i: 42, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_42", kind: "word", text: "one", startSec: 15.75, endSec: 16.13, kept: true, trimId: null }, + { i: 43, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_43", kind: "word", text: "screen.", startSec: 16.13, endSec: 16.62, kept: true, trimId: null }, + { i: 44, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_44", kind: "word", text: "No", startSec: 16.82, endSec: 16.93, kept: true, trimId: null }, + { i: 45, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_45", kind: "word", text: "page", startSec: 16.93, endSec: 17.37, kept: true, trimId: null }, + { i: 46, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_46", kind: "word", text: "gets", startSec: 17.37, endSec: 17.62, kept: true, trimId: null }, + { i: 47, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_47", kind: "word", text: "more", startSec: 17.62, endSec: 17.7, kept: true, trimId: null }, + { i: 48, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_48", kind: "word", text: "than", startSec: 17.7, endSec: 17.96, kept: true, trimId: null }, + { i: 49, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_49", kind: "word", text: "a", startSec: 17.96, endSec: 18.18, kept: true, trimId: null }, + { i: 50, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_50", kind: "word", text: "sentence.", startSec: 18.18, endSec: 18.93, kept: true, trimId: null }, + { i: 51, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_51", kind: "word", text: "Over", startSec: 18.93, endSec: 19.07, kept: true, trimId: null }, + { i: 52, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_52", kind: "word", text: "on", startSec: 19.07, endSec: 19.56, kept: true, trimId: null }, + { i: 53, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_53", kind: "word", text: "status,", startSec: 19.56, endSec: 20.17, kept: true, trimId: null }, + { i: 54, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_54", kind: "word", text: "every", startSec: 20.17, endSec: 20.650000000000002, kept: true, trimId: null }, + { i: 55, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_55", kind: "word", text: "component", startSec: 20.650000000000002, endSec: 21.06, kept: true, trimId: null }, + { i: 56, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_56", kind: "word", text: "with", startSec: 21.06, endSec: 21.31, kept: true, trimId: null }, + { i: 57, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_57", kind: "word", text: "30", startSec: 21.31, endSec: 21.490000000000002, kept: true, trimId: null }, + { i: 58, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_58", kind: "word", text: "days", startSec: 21.490000000000002, endSec: 21.82, kept: true, trimId: null }, + { i: 59, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_59", kind: "word", text: "of", startSec: 21.82, endSec: 21.91, kept: true, trimId: null }, + { i: 60, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_60", kind: "word", text: "probes", startSec: 21.91, endSec: 22.36, kept: true, trimId: null }, + { i: 61, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_61", kind: "word", text: "behind", startSec: 22.36, endSec: 22.740000000000002, kept: true, trimId: null }, + { i: 62, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_62", kind: "word", text: "it.", startSec: 22.740000000000002, endSec: 23.05, kept: true, trimId: null }, + { i: 63, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_63", kind: "word", text: "One", startSec: 23.19, endSec: 23.45, kept: true, trimId: null }, + { i: 64, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_64", kind: "word", text: "amber", startSec: 23.45, endSec: 23.63, kept: true, trimId: null }, + { i: 65, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_65", kind: "word", text: "day", startSec: 23.63, endSec: 23.830000000000002, kept: true, trimId: null }, + { i: 66, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_66", kind: "word", text: "on", startSec: 23.830000000000002, endSec: 24.060000000000002, kept: true, trimId: null }, + { i: 67, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_67", kind: "word", text: "the", startSec: 24.060000000000002, endSec: 24.27, kept: true, trimId: null }, + { i: 68, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_68", kind: "word", text: "validator,", startSec: 24.27, endSec: 24.94, kept: true, trimId: null }, + { i: 69, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_69", kind: "word", text: "and", startSec: 24.94, endSec: 25.19, kept: true, trimId: null }, + { i: 70, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_70", kind: "word", text: "it", startSec: 25.19, endSec: 25.560000000000002, kept: true, trimId: null }, + { i: 71, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_71", kind: "word", text: "recovered,", startSec: 25.560000000000002, endSec: 25.990000000000002, kept: true, trimId: null }, + { i: 72, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_72", kind: "word", text: "and", startSec: 25.990000000000002, endSec: 26.54, kept: true, trimId: null }, + { i: 73, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_73", kind: "word", text: "releases.", startSec: 26.54, endSec: 27.09, kept: true, trimId: null }, + { i: 74, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:silence_2", kind: "silence", text: "[silence 0.2s]", startSec: 27.09, endSec: 27.32, kept: true, trimId: null }, + { i: 75, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_74", kind: "word", text: "Three", startSec: 27.32, endSec: 27.400000000000002, kept: true, trimId: null }, + { i: 76, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_75", kind: "word", text: "of", startSec: 27.400000000000002, endSec: 27.45, kept: true, trimId: null }, + { i: 77, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_76", kind: "word", text: "them,", startSec: 27.45, endSec: 28.060000000000002, kept: true, trimId: null }, + { i: 78, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_77", kind: "word", text: "newest", startSec: 28.060000000000002, endSec: 28.54, kept: true, trimId: null }, + { i: 79, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_78", kind: "word", text: "first,", startSec: 28.54, endSec: 28.95, kept: true, trimId: null }, + { i: 80, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_79", kind: "word", text: "and", startSec: 28.95, endSec: 29.240000000000002, kept: true, trimId: null }, + { i: 81, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_80", kind: "word", text: "every", startSec: 29.240000000000002, endSec: 29.53, kept: true, trimId: null }, + { i: 82, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_81", kind: "word", text: "line", startSec: 29.53, endSec: 29.88, kept: true, trimId: null }, + { i: 83, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_82", kind: "word", text: "links", startSec: 29.88, endSec: 30.09, kept: true, trimId: null }, + { i: 84, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_83", kind: "word", text: "into", startSec: 30.09, endSec: 30.32, kept: true, trimId: null }, + { i: 85, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_84", kind: "word", text: "the", startSec: 30.32, endSec: 30.560000000000002, kept: true, trimId: null }, + { i: 86, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_85", kind: "word", text: "commit", startSec: 30.560000000000002, endSec: 30.62, kept: true, trimId: null }, + { i: 87, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_86", kind: "word", text: "that", startSec: 30.62, endSec: 30.810000000000002, kept: true, trimId: null }, + { i: 88, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_87", kind: "word", text: "did", startSec: 30.810000000000002, endSec: 31.1, kept: true, trimId: null }, + { i: 89, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_88", kind: "word", text: "it.", startSec: 31.1, endSec: 31.560000000000002, kept: true, trimId: null }, + { i: 90, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_89", kind: "word", text: "Back", startSec: 31.39, endSec: 31.62, kept: true, trimId: null }, + { i: 91, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_90", kind: "word", text: "to", startSec: 31.62, endSec: 31.76, kept: true, trimId: null }, + { i: 92, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_91", kind: "word", text: "the", startSec: 31.76, endSec: 32.160000000000004, kept: true, trimId: null }, + { i: 93, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_92", kind: "word", text: "top,", startSec: 32.160000000000004, endSec: 32.46, kept: true, trimId: null }, + { i: 94, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_93", kind: "word", text: "and", startSec: 32.46, endSec: 32.730000000000004, kept: true, trimId: null }, + { i: 95, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_94", kind: "word", text: "that", startSec: 32.730000000000004, endSec: 32.86, kept: true, trimId: null }, + { i: 96, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_95", kind: "word", text: "is", startSec: 32.86, endSec: 32.9, kept: true, trimId: null }, + { i: 97, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_96", kind: "word", text: "the", startSec: 32.9, endSec: 33.07, kept: true, trimId: null }, + { i: 98, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_97", kind: "word", text: "whole", startSec: 33.07, endSec: 33.55, kept: true, trimId: null }, + { i: 99, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_98", kind: "word", text: "flow.", startSec: 33.55, endSec: 34.03, kept: true, trimId: null }, + { i: 100, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_99", kind: "word", text: "Tell", startSec: 33.99, endSec: 34.19, kept: true, trimId: null }, + { i: 101, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_100", kind: "word", text: "me", startSec: 34.19, endSec: 34.5, kept: true, trimId: null }, + { i: 102, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_101", kind: "word", text: "what", startSec: 34.5, endSec: 34.53, kept: true, trimId: null }, + { i: 103, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_102", kind: "word", text: "you", startSec: 34.53, endSec: 35, kept: true, trimId: null }, + { i: 104, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:word_103", kind: "word", text: "think.", startSec: 35, endSec: 35.12, kept: true, trimId: null }, + { i: 105, id: "clip_3aa049d2-cf6c-406e-99fa-4a9754fea5fe:silence_3", kind: "silence", text: "[silence 4.9s]", startSec: 35.12, endSec: 40.033, kept: false, trimId: "trim_3bad6006-721a-4dd4-a218-091fa734a63e" }, +]; + +/** The five lanes in the app's order. A lane carries its shortcut hint if and + * only if the document holds no regions for it. */ +export const LANES: RecreationLane[] = [ + { id: "annotation", hint: "Press A to add annotation", pills: [] }, + { id: "speed", hint: "Press S to add speed", pills: [] }, + { id: "trim", hint: null, pills: [{ lane: "trim", id: "trim_f52989cf-489c-47f5-a6c7-b95a7d71b399", label: "0:02.2", startSec: 0, endSec: 2.19, leftPct: 0, widthPct: 5.4705, origin: "agent" }, { lane: "trim", id: "trim_3bad6006-721a-4dd4-a218-091fa734a63e", label: "0:04.9", startSec: 35.12, endSec: 40.033, leftPct: 87.7276, widthPct: 12.2724, origin: "agent" }] }, + { id: "zoom", hint: null, pills: [{ lane: "zoom", id: "zoom_403360f0-0784-4034-994a-a79eb4e5aa55", label: "1.80×", startSec: 4.2, endSec: 12.6, leftPct: 10.4913, widthPct: 20.9827, depth: 3, focusMode: "auto" }, { lane: "zoom", id: "zoom_2d7bd6ce-02e1-4f7d-ad63-03ada76859e3", label: "2.20×", startSec: 17.5, endSec: 24.5, leftPct: 43.7139, widthPct: 17.4856, depth: 4, focusMode: "manual" }, { lane: "zoom", id: "zoom_11caaa47-6be5-4afa-bf1d-f2e6650f2066", label: "1.80×", startSec: 33.112, endSec: 35.156, leftPct: 82.7118, widthPct: 5.1058, depth: 3, focusMode: "manual" }] }, + { id: "cameraFullscreen", hint: "Press C to add a Full Camera segment", pills: [] }, +]; + +/** Every pill on the floor, flattened. */ +export const PILLS: RecreationPill[] = [ + { lane: "trim", id: "trim_f52989cf-489c-47f5-a6c7-b95a7d71b399", label: "0:02.2", startSec: 0, endSec: 2.19, leftPct: 0, widthPct: 5.4705, origin: "agent" }, + { lane: "trim", id: "trim_3bad6006-721a-4dd4-a218-091fa734a63e", label: "0:04.9", startSec: 35.12, endSec: 40.033, leftPct: 87.7276, widthPct: 12.2724, origin: "agent" }, + { lane: "zoom", id: "zoom_403360f0-0784-4034-994a-a79eb4e5aa55", label: "1.80×", startSec: 4.2, endSec: 12.6, leftPct: 10.4913, widthPct: 20.9827, depth: 3, focusMode: "auto" }, + { lane: "zoom", id: "zoom_2d7bd6ce-02e1-4f7d-ad63-03ada76859e3", label: "2.20×", startSec: 17.5, endSec: 24.5, leftPct: 43.7139, widthPct: 17.4856, depth: 4, focusMode: "manual" }, + { lane: "zoom", id: "zoom_11caaa47-6be5-4afa-bf1d-f2e6650f2066", label: "1.80×", startSec: 33.112, endSec: 35.156, leftPct: 82.7118, widthPct: 5.1058, depth: 3, focusMode: "manual" }, +]; + +/** The ruler, re-derived per breakpoint the way the app re-derives it per zoom. */ +export const RULER = { + minLabelGapPx: 76, + minorPerMajor: 5, + tickStepsSec: [ + 0.05, + 0.1, + 0.25, + 0.5, + 1, + 2, + 5, + 10, + 15, + 30, + 60, + 120, + 300, + 600, + 900, + 1800, + 3600, + ], + wideBreakpointPx: 3043, + variants: [ + { + worldPx: 1920, + pxPerSec: 47.9604, + stepSec: 2, + minorSec: 0.4, + minorPct: 0.999176, + labels: [ + { sec: 0, leftPct: 0, text: "0:00" }, + { sec: 2, leftPct: 4.9959, text: "0:02" }, + { sec: 4, leftPct: 9.9918, text: "0:04" }, + { sec: 6, leftPct: 14.9876, text: "0:06" }, + { sec: 8, leftPct: 19.9835, text: "0:08" }, + { sec: 10, leftPct: 24.9794, text: "0:10" }, + { sec: 12, leftPct: 29.9753, text: "0:12" }, + { sec: 14, leftPct: 34.9711, text: "0:14" }, + { sec: 16, leftPct: 39.967, text: "0:16" }, + { sec: 18, leftPct: 44.9629, text: "0:18" }, + { sec: 20, leftPct: 49.9588, text: "0:20" }, + { sec: 22, leftPct: 54.9547, text: "0:22" }, + { sec: 24, leftPct: 59.9505, text: "0:24" }, + { sec: 26, leftPct: 64.9464, text: "0:26" }, + { sec: 28, leftPct: 69.9423, text: "0:28" }, + { sec: 30, leftPct: 74.9382, text: "0:30" }, + { sec: 32, leftPct: 79.9341, text: "0:32" }, + { sec: 34, leftPct: 84.9299, text: "0:34" }, + { sec: 36, leftPct: 89.9258, text: "0:36" }, + { sec: 38, leftPct: 94.9217, text: "0:38" }, + { sec: 40, leftPct: 99.9176, text: "0:40" }, +], + }, + { + worldPx: 3440, + pxPerSec: 85.9291, + stepSec: 1, + minorSec: 0.2, + minorPct: 0.499588, + labels: [ + { sec: 0, leftPct: 0, text: "0:00" }, + { sec: 1, leftPct: 2.4979, text: "0:01" }, + { sec: 2, leftPct: 4.9959, text: "0:02" }, + { sec: 3, leftPct: 7.4938, text: "0:03" }, + { sec: 4, leftPct: 9.9918, text: "0:04" }, + { sec: 5, leftPct: 12.4897, text: "0:05" }, + { sec: 6, leftPct: 14.9876, text: "0:06" }, + { sec: 7, leftPct: 17.4856, text: "0:07" }, + { sec: 8, leftPct: 19.9835, text: "0:08" }, + { sec: 9, leftPct: 22.4815, text: "0:09" }, + { sec: 10, leftPct: 24.9794, text: "0:10" }, + { sec: 11, leftPct: 27.4773, text: "0:11" }, + { sec: 12, leftPct: 29.9753, text: "0:12" }, + { sec: 13, leftPct: 32.4732, text: "0:13" }, + { sec: 14, leftPct: 34.9711, text: "0:14" }, + { sec: 15, leftPct: 37.4691, text: "0:15" }, + { sec: 16, leftPct: 39.967, text: "0:16" }, + { sec: 17, leftPct: 42.465, text: "0:17" }, + { sec: 18, leftPct: 44.9629, text: "0:18" }, + { sec: 19, leftPct: 47.4608, text: "0:19" }, + { sec: 20, leftPct: 49.9588, text: "0:20" }, + { sec: 21, leftPct: 52.4567, text: "0:21" }, + { sec: 22, leftPct: 54.9547, text: "0:22" }, + { sec: 23, leftPct: 57.4526, text: "0:23" }, + { sec: 24, leftPct: 59.9505, text: "0:24" }, + { sec: 25, leftPct: 62.4485, text: "0:25" }, + { sec: 26, leftPct: 64.9464, text: "0:26" }, + { sec: 27, leftPct: 67.4444, text: "0:27" }, + { sec: 28, leftPct: 69.9423, text: "0:28" }, + { sec: 29, leftPct: 72.4402, text: "0:29" }, + { sec: 30, leftPct: 74.9382, text: "0:30" }, + { sec: 31, leftPct: 77.4361, text: "0:31" }, + { sec: 32, leftPct: 79.9341, text: "0:32" }, + { sec: 33, leftPct: 82.432, text: "0:33" }, + { sec: 34, leftPct: 84.9299, text: "0:34" }, + { sec: 35, leftPct: 87.4279, text: "0:35" }, + { sec: 36, leftPct: 89.9258, text: "0:36" }, + { sec: 37, leftPct: 92.4238, text: "0:37" }, + { sec: 38, leftPct: 94.9217, text: "0:38" }, + { sec: 39, leftPct: 97.4196, text: "0:39" }, + { sec: 40, leftPct: 99.9176, text: "0:40" }, +], + }, + ], +} as const; + +/** 320 bars in five opacity buckets, five paths. */ +export const WAVEFORM = { + viewBox: "0 0 320 100", + barCount: 320, + strokeWidthPx: 2, + gapPx: 1, + note: "preserveAspectRatio=none; stroke-width 2 with vector-effect:non-scaling-stroke reproduces .tlWave span {max-width:2px}", + paths: [ + { + opacity: 0.5, + bars: 100, + d: "M0.5 46V54M1.5 46V54M2.5 46V54M3.5 46V54M4.5 46V54M5.5 46V54M6.5 46V54M7.5 46V54M8.5 46V54M9.5 46V54M10.5 46V54M11.5 46V54M12.5 46V54M13.5 46V54M14.5 46V54M15.5 46V54M19.5 46V54M20.5 46V54M38.5 46V54M39.5 46V54M53.5 46V54M54.5 46V54M67.5 46V54M73.5 42.5V57.5M74.5 46V54M81.5 44V56M82.5 46V54M98.5 46V54M99.5 46V54M100.5 43V57M107.5 44.5V55.5M108.5 46V54M131.5 44V56M132.5 46V54M147.5 41.5V58.5M148.5 46V54M149.5 46V54M158.5 45V55M159.5 46V54M177.5 43.5V56.5M182.5 46V54M183.5 46V54M198.5 46V54M206.5 46V54M207.5 46V54M214.5 46V54M215.5 46V54M216.5 46V54M221.5 43.5V56.5M222.5 46V54M226.5 46V54M229.5 42.5V57.5M230.5 46V54M231.5 46V54M249.5 46V54M250.5 46V54M258.5 46V54M259.5 46V54M270.5 46V54M271.5 46V54M280.5 41.5V58.5M281.5 46V54M282.5 46V54M283.5 46V54M284.5 46V54M285.5 46V54M286.5 46V54M287.5 46V54M288.5 46V54M289.5 46V54M290.5 46V54M291.5 46V54M292.5 46V54M293.5 46V54M294.5 46V54M295.5 46V54M296.5 46V54M297.5 46V54M298.5 46V54M299.5 46V54M300.5 46V54M301.5 46V54M302.5 46V54M303.5 46V54M304.5 46V54M305.5 46V54M306.5 46V54M307.5 46V54M308.5 46V54M309.5 46V54M310.5 46V54M311.5 46V54M312.5 46V54M313.5 46V54M314.5 46V54M315.5 46V54M316.5 46V54M317.5 46V54M318.5 46V54M319.5 46V54", + }, + { + opacity: 0.6, + bars: 153, + d: "M16.5 33V67M18.5 31V69M21.5 34V66M22.5 34.5V65.5M24.5 33V67M25.5 31.5V68.5M29.5 33.5V66.5M30.5 32V68M31.5 32V68M33.5 34V66M34.5 34V66M35.5 34.5V65.5M37.5 37V63M40.5 30.5V69.5M41.5 35V65M42.5 32V68M43.5 33V67M44.5 37V63M45.5 33V67M48.5 31V69M49.5 35.5V64.5M51.5 30.5V69.5M52.5 35V65M55.5 33V67M56.5 33.5V66.5M57.5 31V69M58.5 32V68M60.5 35.5V64.5M61.5 33.5V66.5M62.5 31.5V68.5M63.5 34V66M66.5 40V60M70.5 30.5V69.5M72.5 31V69M75.5 36.5V63.5M77.5 36V64M80.5 31.5V68.5M83.5 34.5V65.5M84.5 32V68M85.5 33.5V66.5M86.5 33V67M87.5 34V66M89.5 31.5V68.5M90.5 33.5V66.5M91.5 33V67M92.5 35.5V64.5M94.5 32.5V67.5M101.5 35V65M103.5 34V66M104.5 32.5V67.5M106.5 34.5V65.5M109.5 32V68M110.5 34V66M111.5 34V66M112.5 40V60M113.5 35V65M115.5 33.5V66.5M117.5 31V69M121.5 31V69M125.5 32V68M127.5 35V65M128.5 33.5V66.5M130.5 34V66M133.5 33.5V66.5M134.5 33V67M135.5 32.5V67.5M136.5 33V67M137.5 40V60M138.5 31.5V68.5M139.5 36V64M141.5 32V68M142.5 31V69M143.5 32.5V67.5M144.5 31V69M145.5 34V66M146.5 33.5V66.5M154.5 37.5V62.5M155.5 32V68M156.5 32.5V67.5M157.5 35.5V64.5M160.5 30.5V69.5M161.5 33V67M162.5 34V66M163.5 34V66M164.5 39.5V60.5M165.5 31V69M166.5 32V68M167.5 32.5V67.5M168.5 32V68M171.5 34.5V65.5M173.5 34V66M174.5 31.5V68.5M178.5 32.5V67.5M180.5 32.5V67.5M181.5 31.5V68.5M185.5 31V69M186.5 32V68M187.5 32.5V67.5M188.5 32V68M194.5 31.5V68.5M197.5 34V66M199.5 34V66M200.5 32V68M201.5 33.5V66.5M202.5 31.5V68.5M205.5 31V69M208.5 33V67M209.5 32V68M210.5 33V67M211.5 33.5V66.5M212.5 34V66M213.5 35.5V64.5M217.5 32V68M218.5 31V69M219.5 35.5V64.5M220.5 32.5V67.5M223.5 34V66M224.5 32.5V67.5M225.5 33V67M227.5 31V69M228.5 32V68M232.5 31.5V68.5M233.5 31.5V68.5M234.5 33V67M237.5 35.5V64.5M238.5 33V67M239.5 40.5V59.5M240.5 31V69M241.5 34V66M242.5 31.5V68.5M243.5 34V66M244.5 32V68M245.5 31V69M246.5 33.5V66.5M247.5 32.5V67.5M248.5 31.5V68.5M251.5 32.5V67.5M252.5 32V68M253.5 33.5V66.5M254.5 32.5V67.5M255.5 39V61M260.5 32V68M261.5 31V69M262.5 33.5V66.5M263.5 32.5V67.5M264.5 31V69M266.5 35.5V64.5M269.5 34.5V65.5M274.5 33V67M276.5 34V66M277.5 32.5V67.5M278.5 31V69M279.5 31V69", + }, + { + opacity: 0.7, + bars: 67, + d: "M17.5 26V74M23.5 27V73M26.5 30.5V69.5M27.5 27V73M28.5 26.5V73.5M32.5 28.5V71.5M36.5 28V72M46.5 29.5V70.5M47.5 29.5V70.5M50.5 27.5V72.5M59.5 29V71M64.5 27.5V72.5M65.5 30V70M68.5 28V72M69.5 29.5V70.5M71.5 30V70M76.5 28V72M78.5 28V72M79.5 30V70M88.5 28V72M93.5 28V72M95.5 30V70M96.5 29.5V70.5M97.5 29.5V70.5M102.5 30V70M105.5 30.5V69.5M114.5 27.5V72.5M116.5 26V74M118.5 29V71M119.5 29V71M120.5 28V72M122.5 29V71M123.5 30V70M124.5 30V70M126.5 30V70M129.5 30V70M140.5 29.5V70.5M150.5 28V72M151.5 29V71M152.5 28.5V71.5M153.5 30.5V69.5M169.5 29.5V70.5M170.5 29V71M172.5 29.5V70.5M175.5 30V70M176.5 29V71M179.5 29V71M184.5 30V70M189.5 29.5V70.5M190.5 30V70M191.5 30V70M192.5 30V70M193.5 30V70M195.5 28.5V71.5M196.5 28.5V71.5M203.5 29.5V70.5M204.5 27.5V72.5M235.5 29V71M236.5 30.5V69.5M256.5 27.5V72.5M257.5 30.5V69.5M265.5 29V71M267.5 30V70M268.5 28.5V71.5M272.5 30.5V69.5M273.5 28.5V71.5M275.5 28V72", + }, + { + opacity: 0.8, + bars: 0, + d: "", + }, + { + opacity: 0.9, + bars: 0, + d: "", + }, + ], +} as const; + +/** The conversation. */ +export const CHAT = { + conversationTitle: "Conversation 1", + emptyState: "No messages yet. Ask the agent to cut silences, tighten pauses, or add captions.", + authorUser: "You", + authorAssistant: "OpenScreen", + userPrompt: "Cut the dead time in this recording: long pauses, silences, and idle stretches where nothing is being said or done. Keep the pacing tight and natural, and do not cut anything a viewer needs. Apply the edits directly to the timeline.", + agentReply: [ + { + kind: "p", + runs: [ + { + text: "Here's what I found and cut:", + strong: false, + }, + ], + }, + { + kind: "p", + runs: [ + { + text: "Finding:", + strong: true, + }, + { + text: " The speech itself is nearly gapless — every pause between spoken words is well under a second, so there were no internal silences to remove. The only silences over 1s were at the two ends:", + strong: false, + }, + ], + }, + { + kind: "p", + runs: [ + { + text: "Cuts applied (both on your single placed clip, which is untouched otherwise):", + strong: true, + }, + ], + }, + { + kind: "li", + runs: [ + { + text: "0–2.19s", + strong: true, + }, + { + text: " — the 2.2s dead lead-in before \"Hi,\" (cut)", + strong: false, + }, + ], + }, + { + kind: "li", + runs: [ + { + text: "35.12–40.03s", + strong: true, + }, + { + text: " — the ~4.9s dead tail after \"think.\" (cut)", + strong: false, + }, + ], + }, + { + kind: "p", + runs: [ + { + text: "The video now runs from \"Hi,\" straight through to \"think.\" — about ", + strong: false, + }, + { + text: "7 seconds shorter", + strong: true, + }, + { + text: " (40.0s → 33.0s of playable footage).", + strong: false, + }, + ], + }, + { + kind: "p", + runs: [ + { + text: "Your zooms (incl. the manual 1.8×/2.2× punches) stay anchored to the same moments in the narration.", + strong: false, + }, + ], + }, + { + kind: "p", + runs: [ + { + text: "One note: the cut starts the video right at the first word with no lead-in. If you'd rather keep a half-second of breathing room before \"Hi,\" say the word and I'll restore a short lead.", + strong: false, + }, + ], + }, + ], + agentReplyText: "Here's what I found and cut:\n\n**Finding:** The speech itself is nearly gapless — every pause between spoken words is well under a second, so there were no internal silences to remove. The only silences over 1s were at the two ends:\n\n**Cuts applied (both on your single placed clip, which is untouched otherwise):**\n\n- **0–2.19s** — the 2.2s dead lead-in before \"Hi,\" (cut)\n- **35.12–40.03s** — the ~4.9s dead tail after \"think.\" (cut)\n\nThe video now runs from \"Hi,\" straight through to \"think.\" — about **7 seconds shorter** (40.0s → 33.0s of playable footage).\n\nYour zooms (incl. the manual 1.8×/2.2× punches) stay anchored to the same moments in the narration.\n\nOne note: the cut starts the video right at the first word with no lead-in. If you'd rather keep a half-second of breathing room before \"Hi,\" say the word and I'll restore a short lead.", + applied: "applied: added 2 trims", + composerPlaceholder: "Describe the edit you want.", + modelChip: "deepseek-v4-flash", +} as const; + +/** The preview frame's geometry, and the loop that plays inside it. */ +export const STAGE = { + aspectRatio: "16:9", + wallpaper: "/wallpapers/wallpaper5.jpg", + padding: 55, + paddingFit: 0.78, + borderRadius: 40, + frame: { + width: 1071, + height: 603, + }, + contentBox: { + width: 835, + height: 470, + }, + screen: { + width: 835, + height: 470, + }, + webcam: null, + webcamReason: "assets[0].cameraTrack.visible is false and legacyEditor.webcamLayoutPreset is \"no-webcam\" — there is no bubble to draw", +} as const; +export const LOOP = { + startSec: 17.5, + contentEndSec: 24.3, + contentDurationSec: 6.8, + durationSec: 7, + dissolveSec: 0.6, + holdSec: 0.2, + zoomScale: 2.2, + crop: { + w: 872, + h: 490, + x: 524, + y: 295, + }, + src: "/video/canvas-loop.mp4", + srcSmall: "/video/canvas-loop-sm.mp4", + poster: "/img/walkthrough/canvas-poster.jpg", + width: 836, + height: 470, + widthSmall: 640, + heightSmall: 360, + timeMapping: "startSec + min(currentTime, contentDurationSec)", +} as const; + +/** The three facet panels the scroll opens before the transcript, with every + * control's label as the app's locale files spell it. */ +export const PANELS = { + background: { + title: "Background", + tabs: [ + "Image", + "Color", + "Gradient", + ], + uploadCustom: "Upload Custom", + wallpaperCount: 18, + swatchLabels: [ + "Background 1", + "Background 2", + "Background 3", + "Background 4", + "Background 5", + "Background 6", + "Background 7", + "Background 8", + "Background 9", + "Background 10", + "Background 11", + "Background 12", + "Background 13", + "Background 14", + "Background 15", + "Background 16", + "Background 17", + "Background 18", + ], + }, + effects: { + title: "Video Effects", + padding: "Padding", + blurBg: "Blur BG", + motionBlur: "Motion Blur", + shadow: "Shadow", + roundness: "Roundness", + }, + cursor: { + title: "Cursor", + show: "Show Cursor", + clipToBounds: "Clip to Canvas", + theme: "Cursor Style", + size: "Size", + smoothing: "Smoothing", + }, +} as const; + +/** The padding formula's coefficients, so the slider moves the composite by the + * app's own arithmetic rather than by a number that looks about right. */ +export const EFFECTS = { + paddingDefault: 55, + borderRadiusDefault: 40, + paddingFitFactor: 0.4, + paddingFitMin: 0.4, +} as const; + +/** Every slider and toggle on those panels, at this document's settings, scaled + * and suffixed the way RightPanes.tsx scales and suffixes it. */ +export const CONTROLS = { + padding: { + label: "Padding", + value: 55, + min: 0, + max: 100, + suffix: "%", + display: "55%", + }, + blurBg: { + label: "Blur BG", + on: true, + }, + motionBlur: { + label: "Motion Blur", + value: 30, + min: 0, + max: 100, + suffix: "%", + display: "30%", + }, + shadow: { + label: "Shadow", + value: 35, + min: 0, + max: 100, + suffix: "%", + display: "35%", + }, + roundness: { + label: "Roundness", + value: 40, + min: 0, + max: 64, + suffix: "px", + display: "40px", + }, + cursorShow: { + label: "Show Cursor", + on: true, + }, + clipToBounds: { + label: "Clip to Canvas", + on: false, + }, + cursorTheme: { + label: "Cursor Style", + value: "default", + }, + cursorSize: { + label: "Size", + value: 45, + min: 5, + max: 100, + suffix: "", + display: "45.0", + }, + smoothing: { + label: "Smoothing", + value: 35, + min: 0, + max: 100, + suffix: "%", + display: "35%", + }, +} as const; + +/** The cursor packs the picker shows, each with the application's own hotspot, + * normalised to a fraction of the sprite. */ +export const CURSORS = { + themeCount: 18, + themes: [ + { + id: "default", + name: "Default", + hotspotX: 0.119, + hotspotY: 0.0874, + src: "/img/cursors/00-arrow.png", + }, + { + id: "pink-glossy-arrow-and-hand-3d", + name: "Pink Glossy Arrow & Hand 3D", + hotspotX: 0.0469, + hotspotY: 0.0469, + src: "/img/cursors/01-arrow.png", + }, + { + id: "spring-gradient", + name: "Spring Gradient", + hotspotX: 0.0469, + hotspotY: 0.0156, + src: "/img/cursors/02-arrow.png", + }, + { + id: "black-and-rainbow-stroke-gradient-animated", + name: "Black & Rainbow Stroke Gradient Animated", + hotspotX: 0.05, + hotspotY: 0.03, + src: "/img/cursors/03-arrow.png", + }, + { + id: "among-us-sus-knife-and-red-animated", + name: "Among Us Sus Knife & Red Animated", + hotspotX: 0.1531, + hotspotY: 0.1016, + src: "/img/cursors/04-arrow.png", + }, + { + id: "hollow-knight-and-game-arrow", + name: "Hollow Knight & Game Arrow", + hotspotX: 0.0156, + hotspotY: 0.0156, + src: "/img/cursors/05-arrow.png", + }, + { + id: "mickey-mouse-black-hand-inflated-glove", + name: "Mickey Mouse Black Hand Inflated Glove", + hotspotX: 0.0781, + hotspotY: 0.0156, + src: "/img/cursors/06-arrow.png", + }, + { + id: "sanrio-kuromi-skull-arrow", + name: "Sanrio Kuromi Skull Arrow", + hotspotX: 0.0469, + hotspotY: 0.0156, + src: "/img/cursors/07-arrow.png", + }, + { + id: "old-roblox", + name: "Old Roblox", + hotspotX: 0.0781, + hotspotY: 0.0469, + src: "/img/cursors/08-arrow.png", + }, + { + id: "pokemon-neon-gengar", + name: "Pokemon Neon Gengar", + hotspotX: 0.0313, + hotspotY: 0.0156, + src: "/img/cursors/09-arrow.png", + }, + ], + pointer: { + name: "Default", + hotspotX: 0.3893, + hotspotY: 0.0032, + src: "/img/cursors/mac-pointer.png", + }, + text: { + name: "Default", + hotspotX: 0.4375, + hotspotY: 0.5333, + src: "/img/cursors/mac-text.png", + }, +} as const; + +export const TRANSPORT = { + restCurrent: "0:17.5", + endCurrent: "0:24.3", + total: "0:40.0", + restLeftPct: 43.7139, + endLeftPct: 60.6999, +} as const; +export const TOOLBAR = { + aspectRatio: "16:9", + panKbd: "Shift+Scroll", + panLabel: "Pan", + zoomKbd: "Ctrl+Scroll", + zoomLabel: "Zoom", + clipLabel: "Bellrock — docs walkthrough", +} as const; + +/** The app's dark tokens, read out of design-tokens.css. */ +export const TOKENS = { + surface: "#14181f", + surface1: "#14181f", + surface2: "#1c2029", + surface3: "#252a35", + border: "#252a35", + borderHi: "#3a4150", + fg: "#e6e9ef", + fg2: "#f3f5f8", + muted: "#8b95a3", + meta: "#8b95a3", + metaInApp: "#5b6470", + accent: "#10b981", + danger: "#f87171", + playhead: "#6c55ff", +} as const; + +/** One entry per string on screen. */ +export const PROVENANCE: ProvenanceEntry[] = [ + { shown: "Current transcription", source: "src/i18n/locales/en/settings.json → transcript.title" }, + { shown: "Clip 1 · 0:00.0—0:40.0", source: "computed: settings.json transcript.clipLabel with index 1, joined to formatMs(0) and formatMs(40033) from src/lib/ai-edition/timeline/format.ts" }, + { shown: "Bellrock — docs walkthrough", source: "fixture assets[0].label" }, + { shown: "[silence 2.2s]", source: "computed: settings.json transcript.silence over the 2.190s gap 0–2.19 that buildClipSection inserted at SILENCE_THRESHOLD_SEC 0.2; inside trimRange trim_f52989cf-489c-47f5-a6c7-b95a7d71b399" }, + { shown: "[silence 0.2s]", source: "computed: settings.json transcript.silence over the 0.230s gap 27.09–27.32 that buildClipSection inserted at SILENCE_THRESHOLD_SEC 0.2; outside any trim" }, + { shown: "[silence 4.9s]", source: "computed: settings.json transcript.silence over the 4.913s gap 35.12–40.033 that buildClipSection inserted at SILENCE_THRESHOLD_SEC 0.2; inside trimRange trim_3bad6006-721a-4dd4-a218-091fa734a63e" }, + { shown: "Hi, quick walk through the documentation site. The new release went out this morning. The hero does the work now. One line, one promise, and the install command right under it. Scrolling down, those six cards are the whole library on one screen. No page gets more than a sentence. Over on status, every component with 30 days of probes behind it. One amber day on the validator, and it recovered, and releases. Three of them, newest first, and every line links into the commit that did it. Back to the top, and that is the whole flow. Tell me what you think.", source: "fixture transcript.words, rejoined in start order; every entry is rendered as its own span" }, + { shown: "Conversation 1", source: "computed: editor.json chat.untitledConversation + the session index, as LeftPanel.tsx:1433 renders it" }, + { shown: "No messages yet. Ask the agent to cut silences, tighten pauses, or add captions.", source: "editor.json chat.emptyState" }, + { shown: "You", source: "editor.json chat.authorUser" }, + { shown: "OpenScreen", source: "editor.json chat.authorAssistant" }, + { shown: "Cut the dead time in this recording: long pauses, silences, and idle stretches where nothing is being said or done. Keep the pacing tight and natural, and do not cut anything a viewer needs. Apply the edits directly to the timeline.", source: "src/components/ai-edition/v4/V4Timeline.tsx AI_ENHANCE_PROMPT, lifted verbatim as source text" }, + { shown: "Here's what I found and cut:\n\n**Finding:** The speech itself is nearly gapless — every pause between spoken words is well under a second, so there were no internal silences to remove. The only silences over 1s were at the two ends:\n\n**Cuts applied (both on your single placed clip, which is untouched otherwise):**\n\n- **0–2.19s** — the 2.2s dead lead-in before \"Hi,\" (cut)\n- **35.12–40.03s** — the ~4.9s dead tail after \"think.\" (cut)\n\nThe video now runs from \"Hi,\" straight through to \"think.\" — about **7 seconds shorter** (40.0s → 33.0s of playable footage).\n\nYour zooms (incl. the manual 1.8×/2.2× punches) stay anchored to the same moments in the narration.\n\nOne note: the cut starts the video right at the first word with no lead-in. If you'd rather keep a half-second of breathing room before \"Hi,\" say the word and I'll restore a short lead.", source: "photograph: static/img/walkthrough/04-agent-a.jpg — hand-transcribed; the conversation is in no store on disk and this photograph is its only existence. THE WEAKEST PROVENANCE ON THE PAGE. Every timecode, duration, quoted word and cut total in it is regenerated from timeline.trimRanges and transcript.words, not transcribed. Two figures are transcribed as the model wrote them and disagree with this file's arithmetic on purpose: \"33.0s\" (recomputed: 32.93s) and \"1.8×/2.2×\" (the pills read 1.80×/2.20×). The page is quoting a message, not drawing a readout." }, + { shown: "applied: added 2 trims", source: "computed: editor.json chat.appliedPrefix + the bulk-add tool's own wording (electron/ai-edition/agent-tools.ts: added ${n} ${noun}s) with n = 2, the trimRanges whose origin is \"agent\"" }, + { shown: "Describe the edit you want.", source: "editor.json chat.composerPlaceholder" }, + { shown: "deepseek-v4-flash", source: "photograph: static/img/walkthrough/editor-1560.jpg — what was on screen when the plate was shot. NOT in provider-registry.ts; it was typed into an OpenAI-compatible provider config. The one visible string whose referent cannot be verified in the source, kept because the photographs beside it already ship it." }, + { shown: "1.80×", source: "computed: effectiveZoomScale(zoomRanges depth 3).toFixed(2) + \"×\" from src/lib/ai-edition/timeline/zoom-scale.ts" }, + { shown: "2.20×", source: "computed: effectiveZoomScale(zoomRanges depth 4).toFixed(2) + \"×\" from src/lib/ai-edition/timeline/zoom-scale.ts" }, + { shown: "1.80×", source: "computed: effectiveZoomScale(zoomRanges depth 3).toFixed(2) + \"×\" from src/lib/ai-edition/timeline/zoom-scale.ts" }, + { shown: "0:02.2", source: "computed: formatSec(2.19 − 0) over trimRange trim_f52989cf-489c-47f5-a6c7-b95a7d71b399" }, + { shown: "0:04.9", source: "computed: formatSec(40.033 − 35.12) over trimRange trim_3bad6006-721a-4dd4-a218-091fa734a63e" }, + { shown: "Press A to add annotation", source: "timeline.json hints.pressAnnotation — rendered because the document holds no annotation regions" }, + { shown: "Press S to add speed", source: "timeline.json hints.pressSpeed — rendered because the document holds no speed regions" }, + { shown: "Press C to add a Full Camera segment", source: "timeline.json hints.pressCameraFullscreen — rendered because the document holds no cameraFullscreen regions" }, + { shown: "0:00 0:02 0:04 0:06 0:08 0:10 0:12 0:14 0:16 0:18 0:20 0:22 0:24 0:26 0:28 0:30 0:32 0:34 0:36 0:38 0:40", source: "computed: fmtTick lifted from V4Timeline.tsx over a 2s step, itself derived as the first TICK_STEPS_SEC entry clearing MIN_LABEL_GAP_PX 76 at 47.9604 px/s" }, + { shown: "0:40.0", source: "computed: formatSec(assets[0].durationSec = 40.033)" }, + { shown: "0:17.5", source: "computed: formatSec(zoomRanges[1].sourceStartSec = 17.5) — where the loop starts" }, + { shown: "16:9", source: "fixture legacyEditor.aspectRatio" }, + { shown: "Shift+Scroll Pan", source: "computed: timeline.json labels.pan, with the key hint V4Timeline.tsx:1474 writes beside it" }, + { shown: "Ctrl+Scroll Zoom", source: "computed: timeline.json labels.zoom, with the key hint V4Timeline.tsx:1477 writes beside it" }, + { shown: "Bellrock — docs walkthrough", source: "fixture assets[0].label, as the clip card's own label" }, + { shown: "Background", source: "src/i18n/locales/en/settings.json → background.title" }, + { shown: "Image", source: "src/i18n/locales/en/settings.json → background.image" }, + { shown: "Color", source: "src/i18n/locales/en/settings.json → background.color" }, + { shown: "Gradient", source: "src/i18n/locales/en/settings.json → background.gradient" }, + { shown: "Upload Custom", source: "src/i18n/locales/en/settings.json → background.uploadCustom" }, + { shown: "Background 1", source: "computed: settings.json background.imageLabel over the 18 wallpapers WALLPAPER_COUNT declares in src/lib/wallpaper.ts" }, + { shown: "Video Effects", source: "src/i18n/locales/en/settings.json → effects.title" }, + { shown: "Padding", source: "src/i18n/locales/en/settings.json → effects.padding" }, + { shown: "Blur BG", source: "src/i18n/locales/en/settings.json → effects.blurBg" }, + { shown: "Motion Blur", source: "src/i18n/locales/en/settings.json → effects.motionBlur" }, + { shown: "Shadow", source: "src/i18n/locales/en/settings.json → effects.shadow" }, + { shown: "Roundness", source: "src/i18n/locales/en/settings.json → effects.roundness" }, + { shown: "Cursor", source: "src/i18n/locales/en/settings.json → cursor.title" }, + { shown: "Show Cursor", source: "src/i18n/locales/en/settings.json → cursor.show" }, + { shown: "Clip to Canvas", source: "src/i18n/locales/en/settings.json → cursor.clipToBounds" }, + { shown: "Cursor Style", source: "src/i18n/locales/en/settings.json → cursor.theme" }, + { shown: "Size", source: "src/i18n/locales/en/settings.json → cursor.size" }, + { shown: "Smoothing", source: "src/i18n/locales/en/settings.json → cursor.smoothing" }, +]; + +/** What check-recreation.mjs asserts against. */ +export const MARKUP = { + pills: PILLS, + hints: LANES.filter((lane) => lane.hint !== null).map((lane) => lane.id), + words: WORDS, + contrastPairs: [ + [ + "#e6e9ef", + "#14181f", + "transcript word on the inspector", + ], + [ + "#8b95a3", + "#14181f", + "clip range on the inspector", + ], + [ + "#8b95a3", + "#14181f", + "lane hint on the floor", + ], + [ + "#8b95a3", + "#14181f", + "ruler label on the floor", + ], + [ + "#10b981", + "#14181f", + "zoom pill label", + ], + [ + "#f87171", + "#14181f", + "trim pill label and struck silence", + ], + [ + "#e6e9ef", + "#1c2029", + "chat message body", + ], + [ + "#8b95a3", + "#14181f", + "chat author label", + ], + [ + "#10b981", + "#14181f", + "applied line", + ], + [ + "#8b95a3", + "#14181f", + "composer placeholder", + ], +] as [string, string, string][], +} as const; diff --git a/website/src/components/Recreation/index.tsx b/website/src/components/Recreation/index.tsx new file mode 100644 index 000000000..308b3c0d0 --- /dev/null +++ b/website/src/components/Recreation/index.tsx @@ -0,0 +1,711 @@ +/** + * The OpenScreen editor, redrawn in live DOM and walked through by the scroll. + * + * Five beats in two acts. Act one — background, effects, cursor — is the + * composite and one inspector panel, and draws no timeline: those three + * settings are legible in a still frame and the picture would rather have the + * room. Act two brings the floor in once and keeps it, for the edits that only + * mean anything against a timeline. + * + * ── DERIVED, AND STAGED ────────────────────────────────────────────────────── + * + * The chrome is the application's. `PANELS` carries the panel titles by locale + * key, `CONTROLS` every slider at the vendored document's own setting — scaled + * and suffixed the way `RightPanes.tsx` does it, which is where a hand-written + * panel goes plausibly wrong — and `CURSORS` the ten packs the picker shows, + * each with the hotspot the renderer actually uses. + * + * The session is staged: the transcript, the trims, the zooms and the speed + * ramp are a composed demonstration, not a recording. `generated.ts` carries the + * provenance of every string it draws. + * + * ── ONE CLOCK, TWO TIMEBASES ───────────────────────────────────────────────── + * + * The scroll is the only input. `scene.ts` turns scroll position into a `Frame` + * carrying both the scene clock and the footage clock — which differ, because a + * speed ramp is in the middle of the take. `driver.ts` writes that frame to + * custom properties on one element and seeks one video. React renders once. + * + * ── ACCESSIBILITY ──────────────────────────────────────────────────────────── + * + * No focusable node. The app's controls are real buttons and sliders; recreated + * as controls they become tab stops announcing actions this page will never + * perform. Every swatch, slider and pill here is a span. + * + * What is exposed is the five claims and the transcript's words as real text. + * The drawn application — wallpaper, window, pointers, ruler, waveform, + * playhead, webcam — is `aria-hidden`: it is a picture of software, and a + * screen reader that walked it would recite a hundred nodes of chrome. + */ + +import { + Clock, + Crosshair, + MessageSquare, + SplitSquareHorizontal, + Wand2, + ZoomIn, +} from "lucide-react"; +import { useEffect, useRef } from "react"; + +import { attachDriver, SCENE_QUERIES } from "./driver"; +import { CONTROLS, CURSORS, PANELS } from "./generated"; +import { + BEATS, + CLIPS, + CUT_INDEX, + FLOOR_H, + frameAt, + LANES, + PLAYHEAD, + SPEED, + shotCursorSrc, + TOKENS, + trims, + WALLPAPER_COUNT_SHOWN, + ZOOMS, +} from "./scene"; +import styles from "./styles.module.css"; + +/* ── geometry helpers ─────────────────────────────────────────────────────── */ + +/** Rail positions are static; only the rail itself is translated. */ +/* Seconds, resolved against the rail's scale at paint time rather than baked in + here. --k moves with the stage's width, and the rail's own transform already + reads it, so children placed this way stay nailed to the ruler at any width. + Inline styles, so postcss never sees this calc. */ +const x = (sec: number) => `calc(${sec.toFixed(3)} * var(--k) * 1px)`; + +/** The frame the scene comes to rest on, and the one a reader with no driver is + * handed. Everything below that needs a resting value reads it. */ +const REST = frameAt(1); + +/** The twelve wallpapers the picker shows, in the design's order. */ +const WALLPAPERS = [2, 5, 8, 11, 1, 4, 6, 7, 9, 10, 12, 13]; +/** The four the background beat steps through, as full-size canvas layers. */ +const CANVAS_BG = [1, 2, 3, 4]; + +/** The ruler's half-second ticks and its labelled seconds. */ +const RULER = Array.from({ length: 103 }, (_, i) => (i - 18) / 2).filter((t) => t <= 42); + +const fmt = (sec: number) => { + const s = Math.max(0, sec); + return `${Math.floor(s / 60)}:${(Math.floor(s) % 60).toString().padStart(2, "0")}`; +}; + +/* ── small pieces ─────────────────────────────────────────────────────────── */ + +function Slider({ label, display, pct }: { label: string; display: string; pct: number }) { + return ( + + + {label} + {display} + + + + + + + ); +} + +function Toggle({ label, on }: { label: string; on: boolean }) { + return ( + + {label} + + + ); +} + +const pctOf = (c: { value: number; min: number; max: number }) => + ((c.value - c.min) / (c.max - c.min)) * 100; + +/* ── the component ────────────────────────────────────────────────────────── */ + +export default function Recreation() { + const band = useRef(null); + const root = useRef(null); + const cam = useRef(null); + const padValue = useRef(null); + const sizeValue = useRef(null); + const timeValue = useRef(null); + const cutsValue = useRef(null); + const flow = useRef(null); + + useEffect(() => { + const refs = { + band: band.current, + root: root.current, + cam: cam.current, + padValue: padValue.current, + sizeValue: sizeValue.current, + timeValue: timeValue.current, + cutsValue: cutsValue.current, + flow: flow.current, + }; + if (Object.values(refs).some((el) => el === null)) return; + const classes = { struck: styles.struck }; + + // The gate is re-checked, not decided once: a reader who opens the page + // narrower than 360px and widens it past that gets the stage from CSS the + // instant the query flips, and a driver that had given up at mount would + // leave it frozen at its resting frame with the scroll doing nothing. + let detach = attachDriver(refs as Parameters[0], classes); + const queries = SCENE_QUERIES.map((q) => window.matchMedia(q)); + const recheck = () => { + detach(); + detach = attachDriver(refs as Parameters[0], classes); + }; + for (const q of queries) q.addEventListener("change", recheck); + return () => { + for (const q of queries) q.removeEventListener("change", recheck); + detach(); + }; + }, []); + + const placed = trims(0); + + return ( +
+ {/* Three resting values that cannot live in the stylesheet, all read off + the closing frame rather than typed: + + the two cursor sprites, because a url() inside a CSS module is + inlined by webpack as a data URI and these two came to a quarter of + the site's only render-blocking resource — paid for on every docs + page, none of which has a stage on it; + + the wallpaper, because the selection is an attribute rather than a + custom property, so there is nowhere in the sheet to declare it. + `release()` restores it from the same expression. + + The driver overwrites all three per frame. */} +
+ {/* ═══ THE LEFT COLUMN ═══ The caption and the inspector, in one flow + so the pair can be balanced against the picture as a unit — nothing + can align two boxes that are positioned absolutely and + independently, which is what they were. + + The panel lives here rather than inside `.scene` for that reason + alone, and carries its own aria-hidden: it is a drawing, while the + caption above it is the section's real copy and has to stay in the + accessibility tree. */} +
+ {/* ═══ THE CAPTIONS ═══ Above the gate they share one box and take + turns; below it they stack. */} +
+ {BEATS.map((b) => ( +
+

{b.kicker}

+

{b.title}

+

{b.sub}

+
+ ))} +
+ + {/* ═══ THE INSPECTOR ═══ */} + + + {/* ═══ THE TOOL PALETTE ═══ six tools, the app's own bar */} +
+ + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ ); +} diff --git a/website/src/components/Recreation/scene.ts b/website/src/components/Recreation/scene.ts new file mode 100644 index 000000000..d09b43b56 --- /dev/null +++ b/website/src/components/Recreation/scene.ts @@ -0,0 +1,637 @@ +/** + * The score, ported from the design's v4 cut. + * + * Time here is *scene seconds* — a clock that exists only on this page and runs + * 0 → `T_TOTAL` as the reader scrolls the band. Every constant below is v4's, + * kept to the number: the beat boundaries, the instants each click lands, where + * each pill sits, and the two clocks' relationship. + * + * Nothing here touches the DOM and nothing reads a clock. The driver calls + * `frameAt(p)` and applies the result, which is what lets the whole score be + * checked by a test that never opens a browser. + * + * ── WHAT IS DERIVED AND WHAT IS STAGED ─────────────────────────────────────── + * + * The editor's *chrome* is the application's: panel titles, every slider's + * range, scaling and suffix, the wallpapers, and the cursor packs with their + * real hotspots all come out of `generated.ts`, which the generator emits by + * reading the app's locale files and source. + * + * The *session* is staged. The transcript below, the five trims, the three + * zooms and the speed ramp are the design's scenario, not the vendored + * project file — they are a demonstration of what the editor does, composed to + * read in twenty-six seconds. An earlier cut drove this from the project + * document; the document's own edit is two trims at the two ends of a + * forty-second take, which is truthful and shows almost nothing. The page says + * which is which rather than implying the whole thing is a recording. + */ + +import { CONTROLS, CURSORS, EFFECTS } from "./generated"; + +export const T_TOTAL = 26.0; + +/* ── the five beats ───────────────────────────────────────────────────────── */ + +/* The five beats tile [0, T_TOTAL] with no gaps, and that is a correctness + * property, not tidiness. A frame that falls in a gap has no beat, the driver + * has nothing to write to `data-beat`, and the stylesheet's `:not([data-beat])` + * rule takes over — which is the RESTING frame, built for readers who will + * never get the driver at all: the timeline in, the transcript panel open, the + * scene at its closing values. + * + * So a gap does not show "nothing". It shows the end of the story. This opened + * at [0, 0.6), which is the state of the stage on every approach to the band, + * and the reader met the transcript panel before the background panel. There + * were two more at [14.5, 15.0) and [19.5, 19.6), invisible only because the + * panel happens to be faded out across both. */ +export const BEATS = [ + { + id: "style", + from: 0, + to: 7.2, + kicker: "Style", + title: "Swap the background", + sub: "Image, color or gradient behind your recording — no re-shoot.", + }, + { + id: "effects", + from: 7.2, + to: 10.4, + kicker: "Effects", + title: "Frame it your way", + sub: "Padding, motion blur, shadow, roundness — every effect composites live.", + }, + { + id: "cursor", + from: 10.4, + to: 15.0, + kicker: "Cursor", + title: "A cursor worth watching", + sub: "Size, smoothing, motion blur, click bounce — every move reads on screen.", + }, + { + id: "timeline", + from: 15.0, + to: 19.6, + kicker: "Timeline", + title: "One click, one pill", + sub: "Zooms, speed ramps, trims, comments — each edit lands as a pill on the timeline.", + }, + { + id: "transcript", + from: 19.6, + to: 26.0, + kicker: "Transcript", + title: "Edit video like text", + sub: "Delete a word or a silence; the cut lands on the timeline. Nothing destructive.", + }, +] as const; + +export type BeatId = (typeof BEATS)[number]["id"]; + +/** The floor arrives here — inside the cursor beat, so the acts change under a + * caption that is still up rather than across an empty stage. */ +const TL_IN = 14.65; +/** When the interface arrives over the picture — the inspector and the captions + * together. They used to disagree: the caption turned on with its beat, which + * now opens the scene at 0, while the panel waited for this threshold, so the + * title faded up a fifth of a second of scroll before the panel it belongs to. + * One number, read by both. */ +const UI_IN = 0.35; + +/** + * The inspector is up for every beat except the one the palette owns — the + * timeline beat, read off the beat itself rather than copied. + * + * The copy said 14.45 where the beat starts at 15.0, and a caption belongs to + * the panel it describes: for those 0.55s "A cursor worth watching" stood over + * a toolbar with no cursor panel under it. One boundary, written once. + */ +const TL_BEAT = BEATS.find((b) => b.id === "timeline")!; +const PANEL_OFF = [TL_BEAT.from, TL_BEAT.to] as const; + +/* ── the transcript ───────────────────────────────────────────────────────── */ + +export type Token = { + text: string; + silence?: boolean; + cut?: number; + label?: string; + /** Tail words, revealed by viewport height. Only legal AFTER the last cut — + * see the assertion below — so hiding one can never move a struck word. */ + tier?: 2 | 3; +}; + +/** The staged take. Five entries are removable — three silences and two filler + * words — and each has an explicit strike time in `STRIKE_T`. */ +export const TOKENS: Token[] = [ + ...["Hey,", "so", "this", "is", "Fern", "—", "I", "want", "to", "walk", "you", "through"].map( + (text) => ({ text }), + ), + { text: "[silence 0.5s]", silence: true, cut: 0.5, label: "0.5s" }, + { text: "Um,", cut: 0.4, label: "0.4s" }, + ...["pairing", "a", "soil", "sensor."].map((text) => ({ text })), + ...["This", "is", "the", "garden", "—", "every", "bed", "reports", "its", "own", "moisture."].map( + (text) => ({ text }), + ), + { text: "[silence 0.6s]", silence: true, cut: 0.6, label: "0.6s" }, + ...["Now", "the", "new", "probe,"].map((text) => ({ text })), + { text: "um,", cut: 0.3, label: "0.3s" }, + ...["down", "in", "bed", "three."].map((text) => ({ text })), + { text: "[silence 0.5s]", silence: true, cut: 0.5, label: "0.5s" }, + + /* Everything from here on is tail: it sits after the last cut, so hiding any + of it cannot move a struck word or a pointer target. Tier 2 and 3 are + revealed by viewport height, which is what the transcript window's own + height is derived from. */ + ...["It", "shows", "up", "the", "second", "it", "handshakes."].map((text) => ({ text })), + ...[ + "No", + "app", + "store,", + "no", + "account", + "—", + "the", + "probe", + "talks", + "to", + "the", + "laptop.", + ].map((text) => ({ text, tier: 2 as const })), + ...[ + "Whole", + "thing", + "took", + "forty", + "seconds,", + "and", + "I", + "still", + "have", + "to", + "cut", + "the", + "dead", + "air", + "out", + "of", + "it.", + ].map((text) => ({ text, tier: 3 as const })), +]; + +/** Indices into `TOKENS` of everything removable, in order. */ +export const CUT_INDEX = TOKENS.reduce((out, t, i) => { + if (t.cut !== undefined) out.push(i); + return out; +}, []); + +/** When each removable entry is struck. Explicit rather than swept: the pointer + * has to arrive at that word on that frame, and a sweep cannot be aimed. */ +const STRIKE_T = [20.05, 20.72, 21.25, 21.8, 22.3]; + +/* The one coupling in here that cannot be derived: STRIKE_T is positional, so + * its k-th entry belongs to the k-th token carrying a `cut`. Everything else + * about the transcript is re-derived from TOKENS — the indices, the pointer's + * targets, the cut counter — so words can be added and removed freely. Change + * the NUMBER or ORDER of cut-bearing tokens, though, and every strike after the + * change lands on the wrong word at the wrong time, silently. Hence the check: + * it costs one comparison at module load and turns that into a build failure. */ +/* Tail words must never sit before a strike: text flows forward, so hiding a + * word after the last cut cannot move anything above it — and hiding one before + * a cut would move every struck word and every pointer target under it. */ +{ + const lastCut = CUT_INDEX[CUT_INDEX.length - 1] ?? -1; + const stray = TOKENS.findIndex((t, i) => t.tier !== undefined && i < lastCut); + if (stray >= 0) { + throw new Error( + `recreation: tail token "${TOKENS[stray]?.text}" at ${stray} sits before the last cut at ` + + `${lastCut} — hiding it would move a struck word.`, + ); + } +} + +if (CUT_INDEX.length !== STRIKE_T.length) { + throw new Error( + `recreation: ${CUT_INDEX.length} removable tokens but ${STRIKE_T.length} strike times — ` + + "every strike after the mismatch would land on the wrong word.", + ); +} + +export const strikeOf = (i: number) => { + const k = CUT_INDEX.indexOf(i); + return k < 0 ? Number.POSITIVE_INFINITY : (STRIKE_T[k] ?? Number.POSITIVE_INFINITY); +}; + +/* ── the floor ────────────────────────────────────────────────────────────── */ + +/** Pixels per second of footage. Every object on the rail is placed with it and + * the rail is translated by it, so the whole timeline is one transform. */ +export const K = 90; +/** + * How much of the take the rail shows, and why it is not a constant. + * + * K is pixels per second of footage. Held fixed, the window it opens is the + * stage's width divided by it — sixteen seconds at 1440, and four at the width + * of a phone. Four seconds is not a timeline; it is one pill at a time drifting + * past, and it makes any portrait layout pointless before it is drawn. + * + * So the reference is the SPAN, not the scale: sixteen seconds, which is what + * 1440px at K already gives, and the scale follows from the width. Capped at K + * so nothing wider than the reference zooms in — a large display keeps showing + * more of the take rather than the same amount, larger. + */ +export const RAIL_SECONDS = 1440 / K; +export const railScale = (stageWidth: number) => Math.min(K, stageWidth / RAIL_SECONDS); +/** The playhead's x, as a fraction of the stage. */ +export const PLAYHEAD = 0.38; + +/** The lane plan, in px from the top of the rail. */ +export const LANES = { + label: 20, + tick: 22, + annotation: 40, + speed: 66, + cut: 92, + zoom: 118, + laneH: 22, + hint: 142, + track: 150, + trackH: 50, +} as const; + +export const FLOOR_H = 210; + +/** The two clips on the track. */ +/* The join is 0.13s — about twelve pixels at 90px/s. The design leaves 0.4s, + which is 36px of floor showing between two takes that are meant to read as + consecutive on one track. */ +export const CLIPS = [ + { from: -9, to: 13.1, selected: true }, + { from: 13.23, to: 42, selected: false }, +] as const; + +/** Three zooms, each with the scene time the wizard places it. + * + * Two decimals, always: the app labels a zoom pill `scale.toFixed(2) + "×"` + * (V4Timeline.tsx), so "1.5×" and "2×" are strings it never renders — the same + * class of tell as a hand-typed cursor-size value. */ +export const ZOOMS = [ + { from: 12.6, to: 13.7, label: "1.50×", scale: 1.5, placedAt: 16.15, origin: "52% 58%" }, + { from: 16.25, to: 17.15, label: "1.80×", scale: 1.8, placedAt: 16.35, origin: "52% 58%" }, + { from: 17.6, to: 18.45, label: "2.00×", scale: 2, placedAt: 16.55, origin: "36% 30%" }, +] as const; + +/** The speed ramp, and the window over which the footage clock runs 2.2x. */ +export const SPEED = { from: 18.6, to: 20.6, placedAt: 18.25, rate: 2.2, label: "2.2×" } as const; + +/** Where each trim lands. Close enough to the playhead to be on screen at the + * moment its own word is struck — a cut that appears off screen is a cut the + * reader has to take on trust. */ +const CUT_POS = [16.4, 17.3, 22.35, 23.15, 23.9]; +/** The zoom window trims are pushed clear of, so nothing overlaps. */ +const ZOOM_GUARD = { from: 20.9, to: 21.9 }; + +export type Placed = { label: string; from: number; to: number; placed: boolean }; + +/** The trim lane, resolved: sorted, spaced, and kept out from under the zoom. */ +export function trims(t: number): Placed[] { + const raw = CUT_INDEX.map((tokenIndex, k) => ({ + label: TOKENS[tokenIndex].label ?? "", + at: CUT_POS[k] ?? 25.7, + dur: TOKENS[tokenIndex].cut ?? 0.5, + placed: t >= strikeOf(tokenIndex), + })).sort((a, b) => a.at - b.at); + + const out: Placed[] = []; + let lastEnd = Number.NEGATIVE_INFINITY; + for (const c of raw) { + let from = Math.max(c.at, lastEnd + 0.25); + let to = from + c.dur; + if (to > ZOOM_GUARD.from - 0.35 && from < ZOOM_GUARD.to + 0.35) { + from = Math.max(ZOOM_GUARD.to + 0.4, lastEnd + 0.25); + to = from + c.dur; + } + lastEnd = to; + out.push({ label: c.label, from, to, placed: c.placed }); + } + return out; +} + +/* ── the footage ──────────────────────────────────────────────────────────── */ + +/** + * The footage clock. + * + * It is not the scene clock. While the playhead crosses the speed region the + * recording runs at 2.2x, and after it the footage is permanently 2.4s ahead — + * which is exactly what a speed ramp does to everything downstream of it, and + * the reason the transport's remaining time is not the scene time. + */ +export const footageTime = (t: number) => + t < SPEED.from + ? t + : t < SPEED.to + ? SPEED.from + (t - SPEED.from) * SPEED.rate + : t + (SPEED.to - SPEED.from) * (SPEED.rate - 1); + +/** Clicks in the recording, for the recorded pointer's bounce. */ +const FOOTAGE_CLICKS = [8.2, 11.6, 15.1, 16.5, 17.9, 21.9, 23.8]; +/** Clicks the reader makes on the editor, for the demonstration pointer's. */ +const UI_CLICKS = [2.0, 3.95, 5.8, 7.95, 11.45, 12.55, 12.7, 16.08, 18.25, 20.05, 21.25, 22.3]; + +/** + * Click bounce, at parity with the renderer's own `cursor.rs`: the press is a + * half-sine down to 50% over the first 38% of the window, and the release a + * sine back up through 132%. Without it a pointer that never flinches reads as + * a sprite being dragged rather than something clicking. + */ +function bounce(t: number, win: number, clicks: number[]): number { + let last = -1; + for (const c of clicks) { + if (c <= t) last = c; + else break; + } + if (last < 0) return 1; + const e = (t - last) / win; + if (e >= 1) return 1; + return e < 0.38 + ? 1 - Math.sin((e / 0.38) * Math.PI) * 0.5 + : 1 + Math.sin(((e - 0.38) / 0.62) * Math.PI) * 0.32; +} + +/** + * The recorded pointer's path, on the footage clock. + * + * A keyframe is either a place in the frame — percentages, so no pixel is baked + * in — or the NAME of something in it, matched against `[data-shot]` and + * resolved by the driver against what that thing actually measures. + * + * The names are the point. Coordinates copied from the design describe the + * design's frame, not ours: our padding comes off the vendored document, so the + * recorded window sits somewhere else, and the pointer parked outside the very + * window it had just clicked. A name cannot drift, because whatever moves the + * window moves the answer with it. + * + * The wandering between clicks stays in coordinates. It is not aimed at + * anything, so there is nothing for it to name. + */ +export type ShotKey = + | readonly [at: number, x: number, y: number] + | readonly [at: number, on: string, fx: number, fy: number]; + +export const SHOT_PATH: readonly ShotKey[] = [ + [0.8, 38, 60], + [3.5, 52, 55], + [6.4, 46, 50], + /* The page's own call to action, which is what the take opens by pressing. + Named through the scroll: the page is at -6% here and at -62% when the + pointer comes back to this same spot at 21.9, so one coordinate cannot + mean the same thing twice. */ + [7.4, "cta", 0.5, 0.5], + [8.9, "cta", 0.5, 0.5], + [10.5, 52, 58], + [11.2, 46, 64], + [12.3, 46, 64], + [13.8, 50, 60], + [14.6, 44, 62], + [15.5, 44, 62], + /* The button it is about to press. */ + [15.9, "app-go", 0.5, 0.5], + [16.9, "app-go", 0.5, 0.5], + /* On the window's own title bar, a fifth of the way along it — where a hand + goes to move a window. Named, so the padding can move the window without + taking the pointer off it. */ + [17.4, "app-bar", 0.2, 0.5], + [18.3, "app-bar", 0.2, 0.5], + [19.2, 35, 68], + [20.2, 58, 58], + [21.3, 34.5, 49], + [22.1, 34.5, 49], + [23.0, 52, 44], + [23.6, 20, 49], + [24.1, 20, 49], + [25.2, 46, 54], + [26.8, 56, 48], + [28.4, 48, 56], +]; + +const PAGE_PATH: number[][] = [ + [0, 0], + [5.5, 0], + [6.6, -6], + [8.4, -6], + [9.6, -26], + [12.8, -26], + [13.6, -40], + [18.8, -40], + [20.3, -62], + [21.3, -62], + [22.6, -30], + [24.6, -8], +]; + +/* ── interpolation ────────────────────────────────────────────────────────── */ + +function kf(t: number, points: readonly number[][]): number[] { + if (t <= points[0][0]) return points[0].slice(1); + for (let i = 1; i < points.length; i++) { + if (t <= points[i][0]) { + const a = points[i - 1]; + const b = points[i]; + const k = (t - a[0]) / (b[0] - a[0]); + return a.slice(1).map((v, j) => v + (b[j + 1] - v) * k); + } + } + return points[points.length - 1].slice(1); +} + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); + +/** Smoothstep. Used only where a value is held at both ends — a ramp that + * starts and stops is the one place linear reads as mechanical. */ +const ease = (v: number) => { + const k = clamp01(v); + return k * k * (3 - 2 * k); +}; + +/* ── sub-beat moments ─────────────────────────────────────────────────────── */ + +/** The three background picks, and the two cursor picks. */ +const BG_PICKS = [2.0, 3.95, 5.8]; +const CURSOR_PICKS = [11.45, 12.55]; +/** Which pack each pick selects, as an index into `CURSORS.themes`: the default + * arrow, then the pink one, then the green. + * + * The order is the page's, not the take's. Green is the site's accent, and the + * pack chosen last is the one the recorded pointer wears for the eleven seconds + * of timeline and transcript that follow — so the take ends on it and the pink + * is spent early, while the panel it is being picked in is still the subject. + * + * Exported because the pointer has to be on the swatch whose pack the frame + * selects, and the path in `driver.ts` reads this rather than repeating it. */ +export const CURSOR_CHOICE = [0, 1, 2]; + +export const WALLPAPER_COUNT_SHOWN = 12; + +/* ── the frame ────────────────────────────────────────────────────────────── */ + +export interface Frame { + t: number; + beat: BeatId | null; + /** 0 before the floor arrives, 1 after. */ + tl: number; + /** The footage clock, which the speed ramp pushes ahead of `t`. */ + tf: number; + /** Which wallpaper is selected, 0-3. */ + bg: number; + /** `paddingFit`, by the app's formula, and what the slider reads. */ + fit: number; + padding: number; + paddingPct: number; + /** Uniform scale on the recorded window — v4 scales the frame rather than + * insetting it, so the page's internal layout never reflows. */ + frameScale: number; + /** The recorded app window's entrance, 0-1. */ + winVis: number; + cursorSize: number; + cursorSizePct: number; + /** The size drag as 0-1, which is what the recorded cursor scales on. */ + cursorSizeU: number; + /** Index into `CURSORS.themes` of the selected pack. */ + cursorTheme: number; + zoom: number; + zoomOrigin: string; + zoomLabel: string; + zoomActive: number; + shotBounce: number; + uiBounce: number; + pageY: number; + /** How many of the three zooms the wizard has placed, and whether the speed + * region is down. */ + zoomsPlaced: number; + speedPlaced: number; + trims: Placed[]; + /** Index of the entry the pointer is striking, or -1. */ + strikeIndex: number; + panel: number; + /** The captions' gate, so they arrive with the inspector rather than before + * it. A switch, not a ramp — the fade is the CSS transition under it. */ + intro: number; + palette: number; + wand: number; + comment: number; + /** Transport readouts. */ + cutCount: number; + saved: number; +} + +export function frameAt(p: number): Frame { + const t = clamp01(p) * T_TOTAL; + const tf = footageTime(t); + + const beat = BEATS.find((b) => t >= b.from && t < b.to)?.id ?? null; + // A ramp, not a switch. + // + // This used to be 0/1 with a 0.75s CSS transition doing the easing, and that + // is a scroll-driven value being animated by a clock that is not the scroll: + // the floor chased a target it only reached long after `--tl` had settled, so + // it sat ~16px below its own `bottom: 0` and cut the bottom off the clips. + // Easing the value here instead means every rule that reads `--tl` — the + // floor, the panel's top and height, the composite's box — moves together and + // lands exactly. + // + // 1.5s, not 0.55s. The ramp is what the reader sees as the timeline sliding + // up from the bottom, and 0.55s of a 26s scene is 2% of the band — about a + // wheel notch, which arrives as a jump rather than as an entrance. At 1.5s + // it takes just under 6% of the band, and because every rule that reads + // --tl moves on it, the panel and the composite resize over the same + // stretch instead of snapping ahead of the floor. + const tl = ease(clamp01((t - TL_IN) / 1.5)); + + const bg = BG_PICKS.reduce((n, at) => (t >= at ? n + 1 : n), 0); + + // Padding starts wide — the backgrounds have to be visible for the beat that + // is about them — and comes down once, slowly, during the Effects beat. + const padU = 1 - clamp01((t - 7.95) / 1.05); + const padding = Math.round(30 + padU * 55); + const fit = Math.min( + 1, + Math.max(EFFECTS.paddingFitMin, 1 - (padding / 100) * EFFECTS.paddingFitFactor), + ); + + const sizeU = clamp01((t - 12.7) / 0.5); + const cursorSize = 40 + sizeU * 23.2; + + const cursorTheme = CURSOR_CHOICE[CURSOR_PICKS.reduce((n, at) => (t >= at ? n + 1 : n), 0)] ?? 0; + + const live = ZOOMS.slice(1).find((z) => t >= z.from && t <= z.to); + const zoomsPlaced = ZOOMS.filter((z) => t >= z.placedAt).length; + + const [pageY] = kf(tf, PAGE_PATH); + + const placedTrims = trims(t); + const cutCount = placedTrims.filter((c) => c.placed).length; + const saved = CUT_INDEX.reduce( + (sum, i) => sum + (t >= strikeOf(i) ? (TOKENS[i].cut ?? 0) : 0), + 0, + ); + + const strikeIndex = CUT_INDEX.find((i) => t >= strikeOf(i) - 0.5 && t < strikeOf(i) + 0.35) ?? -1; + + const panelOn = t >= UI_IN && !(t >= PANEL_OFF[0] && t < PANEL_OFF[1]); + /* The complement of the panel's window, not a second pair of numbers. + Written separately they were 14.75 to 19.45 against the panel's 14.45 to + 19.60, which leaves 0.30s at the start and 0.15s at the end where neither + is up and the caption stands alone over an empty column. They are + alternatives: one hands over to the other, and the 0.45s fades overlap. */ + const paletteOn = t >= PANEL_OFF[0] && t < PANEL_OFF[1]; + + return { + t, + beat, + tl, + tf, + bg, + fit, + padding, + paddingPct: + ((padding - CONTROLS.padding.min) / (CONTROLS.padding.max - CONTROLS.padding.min)) * 100, + frameScale: 1 - padU * 0.36, + /* The window opens and shuts on the footage clock, so it rides the speed + ramp with everything else in the recording. Here and not in the + stylesheet, because the pointer has to aim at a window this scales: it + is the third transform between a named target and the frame, after the + page's scroll and the padding. The `* 8` IS the ease — nothing may + transition on top of it. */ + winVis: Math.min(clamp01((tf - 15.25) * 8), clamp01((17.95 - tf) * 8)), + cursorSize, + cursorSizeU: sizeU, + cursorSizePct: + ((cursorSize - CONTROLS.cursorSize.min) / + (CONTROLS.cursorSize.max - CONTROLS.cursorSize.min)) * + 100, + cursorTheme, + zoom: live ? live.scale : 1, + zoomOrigin: live ? live.origin : "52% 58%", + zoomLabel: live ? live.label : ZOOMS[1].label, + zoomActive: live ? 1 : 0, + shotBounce: bounce(tf, 0.32, FOOTAGE_CLICKS), + uiBounce: bounce(t, 0.42, UI_CLICKS), + pageY, + zoomsPlaced, + speedPlaced: t >= SPEED.placedAt ? 1 : 0, + trims: placedTrims, + strikeIndex, + panel: panelOn ? 1 : 0, + intro: t >= UI_IN ? 1 : 0, + palette: paletteOn ? 1 : 0, + wand: t >= 15.0 && t < 17.0 && t >= 15.0 + (17.0 - 15.0) * 0.54 ? 1 : 0, + comment: t >= 17.3 && t < 19.2 && t >= 17.3 + (19.2 - 17.3) * 0.5 ? 1 : 0, + cutCount, + saved, + }; +} + +/** The pack the recorded pointer is currently drawn with. */ +export const shotCursorSrc = (f: Frame) => CURSORS.themes[f.cursorTheme].src; diff --git a/website/src/components/Recreation/styles.module.css b/website/src/components/Recreation/styles.module.css new file mode 100644 index 000000000..538548e8b --- /dev/null +++ b/website/src/components/Recreation/styles.module.css @@ -0,0 +1,1911 @@ +/** + * The editor, recreated — tokens, v4's grid, and everything the driver's + * variables land on. + * + * ── THE GRID ───────────────────────────────────────────────────────────────── + * + * v4 computes its layout in JavaScript from a measured stage. The same + * arithmetic is done here in `calc`, which removes the measurement, the resize + * handler and the re-render that came with it: a nav band, a 336px inspector, a + * 116px caption block, and the composite contained in what is left, centred in + * the space between the nav and the floor. The panel's height is locked to the + * composite's, which is the alignment the design is built on. + * + * Lengths here are `vw`/`vh`/px and never `%`. That is not style: a custom + * property is substituted as text, so a `%` inside `--video-w` would resolve + * against the *height* when that variable is used in a height context, and the + * composite would silently mis-size on one axis only. `vw` overstates the stage + * by the scrollbar on classic-scrollbar platforms, which is safe because the + * stage clips — it cannot produce a horizontal scrollbar the way a `100vw` + * child of the document would. + * + * ── ONE CLOCK ──────────────────────────────────────────────────────────────── + * + * No scroll-timeline and no state-driving `@keyframes`. `driver.ts` turns + * scroll position into custom properties on `.stage` and every rule below reads + * one. The `var()` fallbacks spelled out on `.stage` ARE the resting state — the + * closing frame — so a reader with no JavaScript, or a reduced-motion + * preference, gets a still, complete editor rather than something stranded at + * opacity 0. + * + * Two things in that frame are not custom properties and so cannot be declared + * here: the selected wallpaper, which is an attribute the markup renders and the + * driver overwrites, and the transcript's struck words, which are a class and + * are therefore the one part of the closing frame the still cannot show. + */ + +/* ═══ TOKENS ═══════════════════════════════════════════════════════════════ + The app's dark values, prefixed `--ost-`. They deliberately do NOT alias the + site's `--os-*` and do not follow its light/dark toggle: of 22 tokens + checked, all twelve neutrals differ and `--playhead` has no `--os-*` + counterpart at all, which is how an unaudited alias sheet ships the playhead + in mint. + + ONE declared divergence: `--ost-meta` is #8b95a3 (the app's `--muted`), not + its `--meta` #5b6470. Against `--surface-1` those measure 5.87:1 and 2.97:1, + and the ruler's labels are real DOM, which SC 1.4.3's Incidental exemption + does not cover. */ + +.band { + --ost-bg: #0a0d12; + --ost-surface: #14181f; + --ost-surface-2: #1c2029; + --ost-border: #252a35; + --ost-border-soft: #1c2029; + --ost-border-hi: #3a4150; + --ost-fg: #e6e9ef; + --ost-fg-2: #f3f5f8; + --ost-muted: #8b95a3; + --ost-meta: #8b95a3; + --ost-faint: #6b7480; + --ost-accent: #10b981; + --ost-accent-soft: rgba(16, 185, 129, 0.14); + --ost-accent-ring: rgba(16, 185, 129, 0.32); + --ost-danger: #f87171; + --ost-danger-soft: rgba(248, 113, 113, 0.14); + --ost-speed: #fbbf24; + --ost-speed-soft: rgba(245, 158, 11, 0.13); + --ost-speed-border: #b45309; + --ost-playhead: #6c55ff; + --ost-playhead-glow: rgba(108, 85, 255, 0.68); + --ost-elev-pop: 0 24px 70px -28px rgba(0, 0, 0, 0.75); + --ost-font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; + --ease: cubic-bezier(0.2, 0, 0, 1); + + /* Pixels per second of footage — the rail's only scale. */ + --k: 90; + /* The grid's constants, and they answer to something. + --nav reads Docusaurus's own navbar height instead of copying it: the copy + said 72px where the bar measures 60, and that 12px was exactly the + top-versus-bottom asymmetry in BOTH layouts. --cap-h is what the captions + actually measure — 95px at every one of the five beats — plus the air that + separates them from the pane below. One number, because everything that + places the pair subtracts it: the pane's ceiling, and portrait's picture + and its top. Widen the air here and the group keeps its height, so it + stays centred on the picture. */ + --nav: var(--ifm-navbar-height, 60px); + /* The inspector's width, and it gives before the picture does. Fixed at 336 + it took a third of a 1011px window, which starved the picture of width — + and a 16:9 picture starved of width leaves height unused, which is the + void that opens under it on a narrow, tall screen. It stops at 280, which + is what the sliders and the four-column swatch grid need. */ + --pnl-w: clamp(280px, 30vw, 336px); + --cap-h: 118px; + --tl-h: 210px; + /* One gutter for both layouts, and a formula rather than a breakpoint: it + gives way on a short window, which is where the tallest pane runs out of + room. The two layouts used to disagree — 28px in one, a hardcoded 12 in + the other. */ + --gut: clamp(14px, 3vh, 28px); + + position: relative; + color: var(--ost-fg); +} + +/* ═══ THE BAND ═════════════════════════════════════════════════════════════ + Nine viewports. The ride is paced against whatever this turns out to be, plus + one more viewport of overflow, so changing it re-paces the scene and breaks + nothing. */ +.band { + height: 900vh; +} + +.stage { + /* THE RESTING STATE — the closing frame, not a half-built one. */ + --t: 26; + --tf: 28.4; + --tl: 1; + /* The resting frame has the interface up, so a reader who never gets a + driver — no JS, reduced motion — sees the captions rather than an + invalid declaration falling back to whatever opacity happens to be. */ + --intro: 1; + --fit: 0.88; + --pad-pct: 30; + --frame-scale: 1; + --size-pct: 66; + --cur-size: 63.2; + --size-u: 1; + --zoom: 1; + --zoom-origin: 52% 58%; + --zoom-active: 0; + /* --shot-cursor and --ui-cursor are set inline on this element in + index.tsx, not here. Webpack inlines a small url() referenced from a + stylesheet as a data URI, and these two sprites came to 9,139 gzipped + bytes — a quarter of the site's only render-blocking resource, carried + by every docs page, none of which has a .stage on it. The driver writes + both properties to this same element's inline style anyway. */ + --shot-x: 48; + --shot-y: 56; + --shot-bounce: 1; + --shot-hx: 4.69; + --shot-hy: 4.69; + --page-y: -8; + --ui-x: 20; + --ui-y: 45; + --ui-bounce: 1; + --ui-on: 0; + --ui-hx: 11.9; + --ui-hy: 8.74; + --zooms-placed: 3; + --speed-placed: 1; + --panel: 1; + --palette: 0; + --wand: 0; + --comment: 0; + /* The trims, for a reader with no driver. Each pill's own opacity is written + inline once there is one — the order they are placed in is not the order + they sit in, so no count can express it, which is what --zooms-placed can + do and this cannot. The one thing the still cannot show is the transcript's + five struck words: striking is a class, and nothing applies it without the + driver. */ + --trims-shown: 1; + + /* ── the grid, once ── */ + --c2x: calc(4.5vw + var(--pnl-w) + 26px); + --c2w: calc(96vw - var(--c2x)); + + /* ── ONE BOX, BOTH LAYOUTS ── + The floor is the only thing that separates the two-part layout from the + three-part one, so it is the only thing --tl interpolates: the box's + bottom bound. Everything else falls out of the box. + + This replaces twenty derived variables that described the two layouts + separately and had to be kept in step by hand — which is how they came to + use different gutters, and how the picture came to be sized against a + caption band the width of the screen for a caption that only ever occupies + the left column. */ + --box-top: calc(var(--nav) + var(--gut)); + --box-bot: calc(100vh - var(--gut) - var(--tl) * var(--tl-h)); + --box-h: calc(var(--box-bot) - var(--box-top)); + /* What the column has left for a pane once the caption has taken its share. */ + --pane-h: calc(var(--box-h) - var(--cap-h)); + + /* The picture is 16:9, as wide as the box allows, and centred in it. */ + --v-w: min(var(--c2w), var(--box-h) * 1.77778); + --v-h: calc(var(--v-w) * 0.5625); + --v-l: calc(var(--c2x) + (var(--c2w) - var(--v-w)) / 2); + --v-t: calc(var(--box-top) + (var(--box-h) - var(--v-h)) / 2); + + position: sticky; + top: 0; + height: 100vh; + overflow: hidden; + font-family: var(--ifm-font-family-base); + isolation: isolate; +} + +.scene { + position: absolute; + inset: 0; +} + +/* ═══ THE CAPTIONS ═════════════════════════════════════════════════════════ */ + +/* The left column: the caption and the inspector, balanced against the picture + as one thing. They were two absolutely positioned boxes with no relationship, + so the pair could only ever be aligned by accident — the caption sat at the + picture's top and the panel hung below it, and on a laptop the pair finished + 120px past the picture's bottom. In one flow the group has a height, and a + height is all it takes to centre it on the same line the picture is centred + on. */ +.column { + position: absolute; + left: 4.5vw; + width: var(--pnl-w); + top: calc(var(--v-t) + var(--v-h) / 2); + transform: translateY(-50%); + z-index: 11; + pointer-events: none; +} + +.captions { + position: relative; + width: calc(var(--pnl-w) - 8px); + /* Its children are absolute and take turns, so the box has no height of its + own — and the flow above needs one. */ + height: var(--cap-h); + /* Gated with the inspector, on the inspector's own threshold and the + inspector's own timing, so the title does not announce a panel that is not + there yet. The per-beat cross-fade inside carries on independently. */ + opacity: var(--intro); + transition: opacity 0.45s var(--ease); +} + +.cap { + position: absolute; + left: 0; + top: 0; + width: 100%; + opacity: 0; + transform: translateY(8px); + transition: + opacity 0.2s var(--ease), + transform 0.2s var(--ease); +} + +.stage[data-beat="style"] .cap[data-cap="style"], +.stage[data-beat="effects"] .cap[data-cap="effects"], +.stage[data-beat="cursor"] .cap[data-cap="cursor"], +.stage[data-beat="timeline"] .cap[data-cap="timeline"], +.stage[data-beat="transcript"] .cap[data-cap="transcript"] { + opacity: 1; + transform: none; + transition: + opacity 0.4s var(--ease) 0.16s, + transform 0.4s var(--ease) 0.16s; +} + +.capKicker { + margin: 0; + font-family: var(--ost-font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ost-accent); +} + +.capTitle { + margin: 6px 0 0; + font-size: clamp(19px, 1.6vw, 25px); + font-weight: 750; + letter-spacing: -0.025em; + line-height: 1.1; + color: var(--os-fg-emphasis); +} + +.capSub { + margin: 7px 0 0; + max-width: 440px; + font-size: 13.5px; + line-height: 1.5; + color: var(--os-fg-2); +} + +/* Measurement only, and never painted: the driver sets it, reads every target's + box, and clears it inside one task. + + The panel and the palette both hold their box at an entrance offset through a + transitioned transform. A rect read in the same tick as the variable changes + comes back with the value the transition is LEAVING, not the one it is headed + for — so the palette measured 16px high for the whole timeline beat and the + panel 12px, and the pointer wore both. */ +.stage[data-measuring], +.stage[data-measuring] * { + transition: none !important; +} + +/* ═══ THE INSPECTOR ════════════════════════════════════════════════════════ */ + +.panel { + /* Under the caption, in the column's flow, free to be shorter than its slot. + + The ceiling is the room the column has between the gutters once the + caption has taken its share — and NOT the footage's height, which is what + was originally asked for and which turns out to clip. On a 1289x904 laptop + the picture is 460px tall and the cursor pane needs 470, so that ceiling + cut the Smoothing slider off the bottom of the demo. A panel ten pixels + taller than the picture is a smaller price than a control missing from it, + and now that the pair is centred on the picture rather than hanging past + it, the difference reads as symmetry instead of overflow. */ + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + height: auto; + /* Multiplied by --panel, so the pane collapses out of the column's flow as + it fades rather than holding its height while invisible. It used to keep + it: during the timeline beat, where the palette owns the column and is + absolute, the column was sized for a panel nobody could see and the + visible group — caption plus palette — sat 124px above the picture it is + supposed to be balanced against. The transition matches the opacity's, so + the two swap in one movement. */ + max-height: calc(var(--panel) * var(--pane-h)); + transition: + opacity 0.45s var(--ease), + max-height 0.45s var(--ease), + transform 0.45s var(--ease); + overflow: hidden; + z-index: 12; + border: 1px solid var(--ost-border); + border-radius: 16px; + background: color-mix(in srgb, var(--ost-surface) 92%, transparent); + box-shadow: var(--ost-elev-pop), inset 0 1px 0 rgba(255, 255, 255, 0.06); + pointer-events: none; + opacity: var(--panel); + transform: translateY(calc((1 - var(--panel)) * 12px)); +} + +.panelHead { + display: grid; + align-items: center; + padding: 13px 15px 11px; + border-bottom: 1px solid var(--ost-border-soft); +} + +.panelTitle { + grid-area: 1 / 1; + margin: 0; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ost-fg); + opacity: 0; + transition: opacity 0.25s var(--ease); +} + +.panelBody { + padding: 13px 15px 15px; + /* A column, so the pane that can fill takes what the head leaves instead of + guessing at it. */ + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.pane { + display: none; +} + +.stage[data-beat="style"] .pane[data-pane="style"], +.stage[data-beat="effects"] .pane[data-pane="effects"], +.stage[data-beat="cursor"] .pane[data-pane="cursor"], +.stage[data-beat="transcript"] .pane[data-pane="transcript"], +.stage:not([data-beat]) .pane[data-pane="transcript"] { + display: block; +} + +.stage[data-beat="style"] .panelTitle[data-pane="style"], +.stage[data-beat="effects"] .panelTitle[data-pane="effects"], +.stage[data-beat="cursor"] .panelTitle[data-pane="cursor"], +.stage[data-beat="transcript"] .panelTitle[data-pane="transcript"], +.stage:not([data-beat]) .panelTitle[data-pane="transcript"] { + opacity: 1; +} + +/* The floor's two beats hand the column to the palette, so the panel keeps its + last pane rather than emptying. */ +.stage[data-beat="timeline"] .pane[data-pane="cursor"], +.stage[data-beat="timeline"] .panelTitle[data-pane="cursor"] { + display: block; + opacity: 1; +} + +/* ── background pane ── */ + +.tabs { + display: flex; + gap: 4px; + padding: 4px; + margin-bottom: 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--ost-surface-2) 60%, transparent); +} + +.tab, +.tabOn { + flex: 1; + text-align: center; + padding: 6px 0; + border-radius: 7px; + font-size: 12px; + font-weight: 500; + color: var(--ost-muted); +} + +.tabOn { + background: var(--ost-accent); + color: #04110b; + font-weight: 600; +} + +.upload { + display: inline-flex; + padding: 8px 14px; + margin-bottom: 12px; + border: 1.5px dashed var(--ost-border-hi); + border-radius: 10px; + font-size: 12px; + font-weight: 600; + color: var(--ost-fg); +} + +.swatches { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.swatch { + position: relative; + aspect-ratio: 1; + border-radius: 10px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + transition: + box-shadow 0.25s var(--ease), + transform 0.25s var(--ease); +} + +.swatch img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 10px; +} + +/* Selection is a `data-bg` on the stage matched against the swatch's own index. + An arithmetic version — opacity from the swatch's `--i` against a `--bg` the + driver would write — reads better and is a trap: postcss-calc cannot fold + min()/max() over custom properties and drops the whole declaration, in the + production build only. */ +.swatch::after { + content: ""; + position: absolute; + inset: -3px; + border-radius: 13px; + border: 2px solid var(--ost-accent); + opacity: 0; + transition: opacity 0.25s var(--ease); +} + +.stage[data-bg="0"] .swatch[data-i="0"]::after, +.stage[data-bg="1"] .swatch[data-i="1"]::after, +.stage[data-bg="2"] .swatch[data-i="2"]::after, +.stage[data-bg="3"] .swatch[data-i="3"]::after { + opacity: 1; +} + +/* ── shared controls ── */ + +.control { + display: block; + margin-bottom: 9px; + padding: 10px 13px 14px; + border: 1px solid var(--ost-border); + border-radius: 11px; + background: color-mix(in srgb, var(--ost-surface-2) 40%, transparent); +} + +.controlRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 13px; +} + +.controlLive { + border-color: var(--ost-accent); + border-width: 1.5px; +} + +.controlHead { + display: flex; + justify-content: space-between; + font-size: 12.5px; +} + +.controlLabel { + font-weight: 500; + color: var(--ost-fg); +} + +.controlValue { + font-family: var(--ost-font-mono); + font-weight: 600; + color: var(--ost-accent); + font-variant-numeric: tabular-nums; +} + +.track { + position: relative; + display: block; + margin-top: 11px; + height: 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); +} + +.trackFill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 999px; + background: var(--ost-accent); +} + +.knob { + position: absolute; + top: 50%; + width: 15px; + height: 15px; + border-radius: 50%; + background: #eafff6; + border: 2.5px solid var(--ost-accent); + transform: translate(-50%, -50%); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); +} + +.trackFillPad { + width: calc(var(--pad-pct) * 1%); +} + +.knobPad { + left: calc(var(--pad-pct) * 1%); + box-shadow: 0 0 0 4px var(--ost-accent-ring); +} + +.trackFillSize { + width: calc(var(--size-pct) * 1%); +} + +.knobSize { + left: calc(var(--size-pct) * 1%); + box-shadow: 0 0 0 4px var(--ost-accent-ring); +} + +.switch { + position: relative; + width: 34px; + height: 20px; + flex-shrink: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); +} + +.switch::after { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #98a2b3; +} + +.switchOn { + background: var(--ost-accent); +} + +.switchOn::after { + left: auto; + right: 2px; + background: #fff; +} + +/* ── cursor pane ── */ + +.cursorStyles { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 7px; + margin: 8px 0 11px; +} + +/* The real packs, drawn from their own sprites. */ +.cursorStyle { + position: relative; + aspect-ratio: 1; + border-radius: 9px; + background-color: color-mix(in srgb, var(--ost-surface-2) 55%, transparent); + background-repeat: no-repeat; + background-position: center; + background-size: auto 56%; + box-shadow: inset 0 0 0 1px var(--ost-border-soft); +} + +.cursorStyle::after { + content: ""; + position: absolute; + inset: -2.5px; + border-radius: 11px; + border: 2px solid var(--ost-accent); + opacity: 0; + transition: opacity 0.25s var(--ease); +} + +.stage[data-cur-sel="0"] .cursorStyle[data-i="0"]::after, +.stage[data-cur-sel="1"] .cursorStyle[data-i="1"]::after, +.stage[data-cur-sel="2"] .cursorStyle[data-i="2"]::after { + opacity: 1; +} + +/* ── transcript pane ── */ + +/* The transcript is the one pane that can fill its panel, so it is a column + too and its mask takes what is left. Both used to be a subtraction — the + panel's height less 120px on the desktop and less 134 in portrait — for a + head and paddings that measure about 73. The difference was dead space above + the floor, and the number had to be re-guessed every time the chrome moved. */ +.stage[data-beat="transcript"] .pane[data-pane="transcript"] { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.flowMask { + display: block; + position: relative; + flex: 1; + min-height: 0; + /* Off the box, like everything else. This was sized against the panel's old + SLOT variable, which tracked 100vh with no floor: at 1280x640 it left the + window 98px while the fifth strike lands at 117, so on a short screen the + last cut of the demo played below the fade where nobody could see it. */ + overflow: hidden; + -webkit-mask-image: linear-gradient(180deg, #000 88%, transparent); + mask-image: linear-gradient(180deg, #000 88%, transparent); +} + +.flow { + margin: 0; + font-size: 12.5px; + line-height: 1.9; + color: var(--ost-fg); +} + +/* The tail, bound to viewport height because that is what the window's own + max-height is derived from: 214px of transcript at 1280x640 against 417px at + 1440x900. Hidden by default and revealed upward, so the short screen is the + one that is correct by construction rather than the one left to overflow. */ +.word[data-tier] { + display: none; +} + +@media (min-height: 820px) { + .word[data-tier="2"] { + display: inline; + } +} + +@media (min-height: 960px) { + .word[data-tier="3"] { + display: inline; + } +} + +.word, +.sil { + transition: + color 0.2s var(--ease), + border-color 0.25s var(--ease), + background-color 0.25s var(--ease); +} + +.sil { + display: inline-flex; + align-items: center; + margin: 0 4px 2px 0; + padding: 0 6px; + border: 1px dashed var(--ost-border-hi); + border-radius: 999px; + font-family: var(--ost-font-mono); + font-size: 10.5px; + line-height: 1.6; + color: var(--ost-muted); +} + +.struck { + color: var(--ost-danger); + font-weight: 600; + text-decoration: line-through; + opacity: 0.9; +} + +.sil.struck { + border-style: solid; + border-color: var(--ost-danger); + background: var(--ost-danger-soft); +} + +/* ═══ THE TOOL PALETTE ═════════════════════════════════════════════════════ + Centred in the left column, between the caption and the floor. */ + +.palette { + /* In the column's flow, like the inspector, and collapsing the same way: the + two are alternatives, so whichever is hidden must give its height back or + the column is sized for something nobody sees. Absolute, it never counted + at all and the visible pair sat 79px below the picture; in flow with the + panel holding its height, 124px above it. In flow with both collapsing, + the column is exactly the caption plus whichever pane is up. */ + position: relative; + margin: 0 auto; + width: max-content; + z-index: 12; + display: grid; + grid-template-columns: repeat(3, 66px); + gap: 8px; + border-radius: 22px; + background: color-mix(in srgb, var(--ost-surface) 90%, transparent); + box-shadow: var(--ost-elev-pop), inset 0 1px 0 rgba(255, 255, 255, 0.06); + /* The padding and the border collapse with the box, not just its content: + max-height alone left 20px of column standing for a palette that is not + there, and that is half a caption's worth of drift on the four beats where + the inspector is the one showing. 220px clears two rows of 66 plus gaps. */ + box-sizing: border-box; + padding: calc(var(--palette) * 9px); + border: calc(var(--palette) * 1px) solid var(--ost-border); + max-height: calc(var(--palette) * 220px); + overflow: hidden; + transform: translateY(calc((1 - var(--palette)) * -16px)); + opacity: var(--palette); + pointer-events: none; + transition: + opacity 0.45s var(--ease), + max-height 0.45s var(--ease), + transform 0.45s var(--ease); +} + +.tool { + width: 66px; + height: 66px; + display: grid; + place-items: center; + color: var(--ost-muted); + border-radius: 17px; + background: color-mix(in srgb, var(--ost-surface-2) 70%, transparent); + transition: box-shadow 0.2s var(--ease); +} + +.tool svg { + display: block; +} + +.toolWand { + box-shadow: 0 0 0 calc(var(--wand) * 3px) var(--ost-accent-ring); + background: color-mix(in srgb, var(--ost-accent-soft) calc(var(--wand) * 100%), color-mix(in srgb, var(--ost-surface-2) 70%, transparent)); +} + +.toolComment { + box-shadow: 0 0 0 calc(var(--comment) * 3px) var(--ost-accent-ring); + background: color-mix(in srgb, var(--ost-accent-soft) calc(var(--comment) * 100%), color-mix(in srgb, var(--ost-surface-2) 70%, transparent)); +} + +/* ═══ THE COMPOSITE ════════════════════════════════════════════════════════ */ + +.card { + position: absolute; + left: var(--v-l); + top: var(--v-t); + width: var(--v-w); + aspect-ratio: 16 / 9; + /* 18px, matching .cardClip, which is inset:0 on this same box — so the + 1px ring below and the clipped content follow one arc. The design has + 16px here against an 18px clip, and the two arcs part company across the + quadrant: a dark crescent opens between the ring and the picture, widest + at 45 degrees. Reproduced at 6x before changing it. */ + border-radius: 18px; + box-shadow: + 0 0 0 1px rgba(16, 185, 129, 0.5), + 0 0 44px -8px rgba(16, 185, 129, 0.3), + 0 34px 70px -30px rgba(0, 0, 0, 0.65); + z-index: 5; +} + +.cardClip { + position: absolute; + inset: 0; + border-radius: 18px; + overflow: hidden; + background: #0d1014; +} + +.zoomer { + position: absolute; + inset: 0; + container-type: size; + overflow: hidden; + transform: scale(var(--zoom)); + transform-origin: var(--zoom-origin); + transition: transform 0.7s var(--ease); +} + +/* Four wallpapers, cross-fading on `data-bg`. Only the one whose index matches + is opaque, so the picker's selection and the canvas cannot disagree. The + attribute is always present — the markup renders the closing frame's pick and + the driver overwrites it per frame — so this needs no undriven fallback. */ +.bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.55s var(--ease); +} + +/* The first one is the floor of the stack, so something is always painted while + the others cross-fade over it. It is first in the markup as well as here: a + floor drawn later would paint over the picture it is meant to sit under. */ +.bg[data-i="0"] { + opacity: 1; +} + +.stage[data-bg="1"] .bg[data-i="1"], +.stage[data-bg="2"] .bg[data-i="2"], +.stage[data-bg="3"] .bg[data-i="3"] { + opacity: 1; +} + +/* The recorded window. Scaled, not inset — the page inside must not reflow + while the padding slider moves. */ +.frame { + position: absolute; + left: 11%; + right: 11%; + top: 8.5%; + bottom: 8.5%; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 1.1cqw; + background: #fbfcfd; + box-shadow: 0 4cqh 8cqh rgba(0, 0, 0, 0.5); + transform: scale(var(--frame-scale)); + transform-origin: 50% 50%; + font-family: var(--ifm-font-family-base); +} + +.chrome { + display: flex; + align-items: center; + gap: 1.2cqw; + flex-shrink: 0; + height: 6.8cqh; + padding: 0 1.4cqw; + background: #f1f3f6; + border-bottom: 1px solid #e3e7ec; +} + +.lights { + display: flex; + gap: 0.7cqw; +} + +.lights span { + width: 1.1cqw; + height: 1.1cqw; + border-radius: 50%; + background: #ff5f57; +} + +.lights span:nth-child(2) { + background: #febc2e; +} + +.lights span:nth-child(3) { + background: #28c840; +} + +.omnibox { + margin: 0 auto; + display: inline-flex; + align-items: center; + height: 4.4cqh; + padding: 0 1.8cqw; + border-radius: 0.8cqw; + background: #fff; + border: 1px solid #e3e7ec; + color: #5f6b7a; + font-size: 1.2cqw; +} + +.viewport { + position: relative; + flex: 1; + overflow: hidden; +} + +.page { + position: absolute; + left: 0; + right: 0; + top: 0; + transform: translateY(calc(var(--page-y) * 1%)); + will-change: transform; +} + +.pageNav { + display: flex; + align-items: center; + gap: 2cqw; + padding: 2.2cqw 3cqw 1cqw; +} + +.pageMark { + width: 1.5cqw; + height: 1.5cqw; + border-radius: 0.45cqw; + background: #10b981; +} + +.pageBrand { + font-size: 1.5cqw; + font-weight: 700; + color: #101828; +} + +.pageLinks { + display: flex; + gap: 1.8cqw; + margin-left: 2cqw; + font-size: 1.2cqw; + color: #667085; +} + +.pageSignIn { + margin-left: auto; + display: inline-flex; + align-items: center; + height: 4.6cqh; + padding: 0 1.6cqw; + border-radius: 0.7cqw; + background: #101828; + color: #fff; + font-size: 1.15cqw; + font-weight: 600; +} + +.pageHero { + padding: 2.6cqw 3cqw 0; +} + +.pageHero h3 { + margin: 0; + font-size: 3.8cqw; + font-weight: 750; + letter-spacing: -0.025em; + line-height: 1.08; + color: #101828; + max-width: 52cqw; +} + +.pageHero p { + margin: 1.4cqw 0 0; + font-size: 1.5cqw; + line-height: 1.5; + color: #667085; + max-width: 44cqw; +} + +.pageBtns { + display: flex; + gap: 1.4cqw; + margin-top: 2cqw; +} + +.pageCta, +.pageGhost { + display: inline-flex; + align-items: center; + height: 5.6cqh; + padding: 0 2.2cqw; + border-radius: 0.8cqw; + font-size: 1.35cqw; +} + +.pageCta { + background: #10b981; + color: #fff; + font-weight: 650; +} + +.pageGhost { + border: 1px solid #e3e7ec; + color: #344054; + font-weight: 550; + background: #fff; +} + +.pageShot { + position: relative; + margin: 2.6cqw 3cqw 0; + height: 30cqh; + border-radius: 1cqw; + overflow: hidden; + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 45%, #6ee7b7 100%); +} + +.pageShot img { + position: absolute; + left: 10%; + top: 14%; + width: 80%; + border-radius: 0.9cqw 0.9cqw 0 0; + box-shadow: 0 2cqh 6cqh rgba(4, 60, 40, 0.28); + border: 1px solid rgba(255, 255, 255, 0.6); +} + +.pageChip { + position: absolute; + left: 13%; + top: 20%; + display: inline-flex; + align-items: center; + padding: 0.5cqw 1.1cqw; + border-radius: 999px; + background: #065f46; + color: #fff; + font-size: 1.05cqw; + font-weight: 600; +} + +/* The recorded page is a light document whatever the site's theme is doing — + it is a picture of somebody else's website — so every colour below is a + literal, exactly as the design has it. */ + +.pageProof { + display: flex; + align-items: center; + gap: 3.2cqw; + padding: 2.4cqw 3cqw 0.4cqw; + font-size: 1.15cqw; + color: #667085; +} + +.pageProof strong { + color: #101828; +} + +.pageFaces { + display: flex; + align-items: center; +} + +.pageFaces span { + width: 2.7cqw; + height: 2.7cqw; + border-radius: 50%; + border: 0.22cqw solid #fff; + display: grid; + place-items: center; + font-size: 0.95cqw; + font-weight: 700; + color: #fff; +} + +.pageFaces span + span { + margin-left: -0.9cqw; +} + +.pageFaces span[data-face="a"] { + background: #0e7a5f; +} + +.pageFaces span[data-face="b"] { + background: #065f46; +} + +.pageFaces span[data-face="c"] { + background: #9a3412; +} + +.pageCards { + display: flex; + gap: 2cqw; + padding: 2cqw 3cqw 3cqw; +} + +.pageCard { + flex: 1; + border: 1px solid #eceff3; + border-radius: 1cqw; + overflow: hidden; +} + +.pageCardArt { + height: 11cqh; + display: flex; +} + +.pageCardBody { + padding: 1.3cqw; +} + +.pageCardBody div:first-child { + font-size: 1.3cqw; + font-weight: 650; + color: #101828; +} + +.pageCardBody div + div { + margin-top: 0.6cqw; + font-size: 1.1cqw; + color: #667085; +} + +/* ── the three thumbnails ── */ + +.artDots { + flex-direction: column; + justify-content: center; + gap: 0.8cqw; + padding: 0 1.6cqw; + background: linear-gradient(135deg, #ecfdf5, #d1fae5); +} + +.artDots > span { + display: flex; + gap: 0.6cqw; +} + +.artDots > span > span { + width: 1cqw; + height: 1cqw; + border-radius: 50%; + background: rgba(6, 95, 70, 0.25); +} + +.artDots > span > span[data-on] { + background: #10b981; +} + +.artBars { + align-items: flex-end; + gap: 0.7cqw; + padding: 1.2cqw 1.6cqw; + background: linear-gradient(135deg, #eff6ff, #dbeafe); +} + +.artBars > span { + flex: 1; + border-radius: 0.3cqw 0.3cqw 0 0; + background: #3b82f6; +} + +.artLines { + flex-direction: column; + justify-content: center; + gap: 0.9cqw; + padding: 0 1.6cqw; + background: linear-gradient(135deg, #fefce8, #fef9c3); +} + +.artLines > span { + height: 0.8cqw; + border-radius: 0.4cqw; + background: #a16207; +} + +/* The app the recording opens, half-way through the take. */ +.appWin { + position: absolute; + left: 32%; + top: 24%; + width: 36%; + height: 52%; + z-index: 4; + border-radius: 1cqw; + background: #fbfcfd; + box-shadow: 0 4cqh 10cqh rgba(8, 12, 18, 0.45), 0 0 0 1px rgba(15, 23, 42, 0.08); + transform-origin: 26% 42%; + /* Open between footage 15.25 and 17.95, on the footage clock so it rides the + speed ramp with everything else in the recording. The ramp itself lives in + the score (scene.ts, `winVis`), because the recorded pointer has to aim at + a window this scales and cannot read a stylesheet. Zero without the driver + — which is what a reader with no JS sees anyway, the scene frozen at its + opening frame with the window not yet open. */ + --vis: var(--win-vis, 0); + opacity: var(--vis); + transform: scale(calc(0.82 + var(--vis) * 0.18)); + /* No transition. --vis is min() of two clamp() ramps off --tf, which the + driver rewrites every rAF, so the `* 8` in those clamps IS the ease; + a transition on top of it eases an eased value and lands late. Same bug + as --tl on .floor and the panel, twice post-mortemed. */ +} + +.appSetup, +.appDone { + display: flex; + flex-direction: column; + gap: 1.6cqh; + padding: 2.4cqh 1.8cqw; +} + +.appDone { + align-items: center; + justify-content: center; + opacity: clamp(0, (var(--tf) - 16.55) * 8, 1); +} + +.appSetup { + opacity: clamp(0, (16.55 - var(--tf)) * 8, 1); +} + +.appDone, +.appSetup { + position: absolute; + /* Below the bar, which is a sibling and always up. */ + inset: 5.2cqh 0 0; +} + +/* ── the window's chrome, and its two states ── */ + +.appBar { + position: absolute; + inset: 0 0 auto; + display: flex; + align-items: center; + gap: 0.8cqw; + padding: 0 1.3cqw; + height: 5.2cqh; + background: #f1f3f6; + border-bottom: 1px solid #e3e7ec; + border-radius: 1cqw 1cqw 0 0; +} + +.appLights { + display: flex; + gap: 0.6cqw; +} + +.appLights span { + width: 1cqw; + height: 1cqw; + border-radius: 50%; +} + +.appLights span[data-l="r"] { + background: #ff5f57; +} + +.appLights span[data-l="y"] { + background: #febc2e; +} + +.appLights span[data-l="g"] { + background: #28c840; +} + +.appTitle { + margin: 0 auto; + font-size: 1.15cqw; + font-weight: 600; + color: #344054; + letter-spacing: -0.01em; +} + +/* Balances the lights so the title sits on the window's centre line. */ +.appPad { + width: 3cqw; +} + +.appHead { + font-size: 1.7cqw; + font-weight: 700; + color: #101828; + letter-spacing: -0.02em; +} + +.appRow { + height: 5.8cqh; + border-radius: 0.7cqw; + border: 1px solid #d0d5dd; + background: #fff; + display: flex; + align-items: center; + gap: 1cqw; + padding: 0 1.3cqw; + font-size: 1.25cqw; + color: #101828; +} + +/* The one still looking: dashed, and greyed down to match. */ +.appRowIdle { + border-style: dashed; + background: none; + color: #98a2b3; +} + +.appDot { + width: 1.1cqw; + height: 1.1cqw; + border-radius: 50%; + background: #10b981; + flex-shrink: 0; +} + +.appRowIdle .appDot { + background: #d0d5dd; +} + +.appMeta { + margin-left: auto; + font-family: var(--ost-font-mono); + font-size: 1cqw; + color: #667085; +} + +.appBtn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 5.6cqh; + border-radius: 0.7cqw; + background: #10b981; + color: #fff; + font-size: 1.3cqw; + font-weight: 650; +} + +.appTick { + width: 6cqw; + height: 6cqw; + border-radius: 50%; + background: #d1fae5; + display: grid; + place-items: center; +} + +.appTick svg { + width: 3cqw; + height: 3cqw; +} + +.appOk { + font-size: 1.7cqw; + font-weight: 700; + color: #101828; +} + +.appSub { + font-size: 1.2cqw; + color: #667085; +} + +/* The pointer inside the recording, wearing the pack the picker selected, at + the size the slider set, bouncing on the recording's own clicks. */ +.shotCursor { + position: absolute; + left: 0; + top: 0; + z-index: 5; + /* A percentage of the composite, not `cqw`: postcss-calc cannot fold a + container unit multiplied by a custom property. */ + width: calc(3.4% + var(--size-u) * 6%); + aspect-ratio: 58 / 73; + background: var(--shot-cursor) left top / contain no-repeat; + /* The tip is what points, so the tip is what goes to the coordinate — the + leftmost translate pulls the sprite back by its own hotspot, in percentages + of its own box, which is the only way to say it here: this sprite's size + follows the cursor-size slider, so the fixed pixel margin .uiCursor uses + would only be right at one setting. Leftmost so it is applied outside the + click bounce and does not wobble with it. */ + transform: translate(calc(var(--shot-hx) * -1%), calc(var(--shot-hy) * -1%)) + translate(calc(var(--shot-x) * 1cqw), calc(var(--shot-y) * 1cqh)) + scale(var(--shot-bounce)); + transform-origin: calc(var(--shot-hx) * 1%) calc(var(--shot-hy) * 1%); + filter: drop-shadow(0 0.3cqh 0.5cqh rgba(0, 0, 0, 0.4)); + will-change: transform; +} + +.zoomBadge { + position: absolute; + top: 13px; + right: 30px; + z-index: 6; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 9px; + border-radius: 8px; + background: rgba(8, 10, 13, 0.55); + border: 1px solid rgba(16, 185, 129, 0.75); + color: #34d399; + font-family: var(--ost-font-mono); + font-size: 10px; + font-weight: 600; + opacity: var(--zoom-active); + transform: translateY(calc((1 - var(--zoom-active)) * -6px)); + transition: + opacity 0.4s var(--ease), + transform 0.4s var(--ease); +} + +/* 16/10.5 with a 13px radius — a rounded rectangle, not a bubble. */ +.webcam { + position: absolute; + right: 24px; + bottom: 64px; + width: 18%; + min-width: 96px; + aspect-ratio: 16 / 10.5; + border-radius: 13px; + overflow: hidden; + border: 3px solid rgba(255, 255, 255, 0.35); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.5); + z-index: 6; +} + +/* A fixed scale, and no transform animation at all. + * + * The motion in this bubble is the recording playing: the scroll is the + * transport, and `currentTime` is the footage clock. An earlier cut also panned + * and rotated the element on that same clock, which put two kinds of movement + * on screen at once and made the one that matters — that this is footage being + * scrubbed — read as a decorative Ken Burns on a still. + * + * 1.15 is not free: a 16/9 source in a 16/10.5 frame already loses ~7% off each + * side, and this takes it to ~13%, which is what keeps the generator's + * watermark at x≈95% out of the picture. */ +.webcamVideo { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transform: scale(1.15); +} + +/* ═══ THE FLOOR ════════════════════════════════════════════════════════════ */ + +.floor { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: var(--tl-h); + z-index: 8; + background: color-mix(in srgb, var(--ost-surface) 97%, transparent); + border-top: 1px solid var(--ost-border); + contain: layout paint; + /* No transition: `--tl` is eased in the score, and easing a scroll-driven + value twice makes the floor chase a target it reaches late — which is how + it came to sit 16px below its own `bottom: 0`. */ + transform: translateY(calc((1 - var(--tl)) * (var(--tl-h) + 20px))); +} + +.floorClip { + position: absolute; + inset: 0; + overflow: hidden; +} + +/* Static children, one transform. */ +.rail { + position: absolute; + inset: 0; + left: 38%; + transform: translate3d(calc(var(--t) * var(--k) * -1px), 0, 0); + will-change: transform; +} + +.ruler { + position: absolute; + inset: 0; +} + +.tick, +.tickMajor { + position: absolute; + top: 22px; + width: 1.5px; + height: 9px; + background: var(--ost-border); +} + +.tickMajor { + background: var(--ost-border-hi); + top: 20px; + height: 0; + width: auto; + transform: translateX(-50%); + font-family: var(--ost-font-mono); + font-size: 10.5px; + color: var(--ost-muted); + white-space: nowrap; +} + +/* 150 + 50 leaves 10px under the clips inside a 210px floor. At the design's + 154 + 54 they finish 2px from the bottom edge, which does not read as a + track sitting on a floor — it reads as a track the floor has cut off. */ +.clip { + position: absolute; + top: 150px; + height: 50px; + border-radius: 10px; + background: color-mix(in srgb, var(--ost-surface-2) 72%, transparent); + border: 1px solid var(--ost-border-hi); + box-shadow: inset 0 1px 8px rgba(255, 255, 255, 0.07); + box-sizing: border-box; +} + +.clipWave { + position: absolute; + left: 13px; + right: 13px; + top: 28px; + height: 9px; + background: repeating-linear-gradient(90deg, var(--ost-accent) 0 2px, transparent 2px 7px); + opacity: 0.75; +} + +.pillZoom, +.pillSpeed, +.pillTrim { + position: absolute; + height: 22px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border-radius: 7px; + overflow: hidden; + font-family: var(--ost-font-mono); + font-size: 10.5px; + font-weight: 600; + white-space: nowrap; + transition: + opacity 0.35s var(--ease), + transform 0.35s var(--ease); +} + +.pillZoom { + top: 118px; + background: var(--ost-accent-soft); + border: 1.5px solid var(--ost-accent); + color: var(--ost-accent); + --on: clamp(0, calc(var(--zooms-placed) - var(--i)), 1); + opacity: var(--on); + transform: translateY(calc((1 - var(--on)) * -8px)); +} + +.pillSpeed { + top: 66px; + background: var(--ost-speed-soft); + border: 1.5px solid var(--ost-speed-border); + color: var(--ost-speed); + opacity: var(--speed-placed); + transform: translateY(calc((1 - var(--speed-placed)) * -8px)); +} + +.pillTrim { + top: 92px; + background: var(--ost-danger-soft); + border: 1px solid var(--ost-danger); + color: var(--ost-danger); +} + +/* Each trim's opacity is written straight onto the element by the driver, over + this. `:nth-of-type` looked tidier and is wrong: it counts siblings of the same + *element* — and the rail is full of spans for ticks and other pills — so the + five rules matched five arbitrary nodes and every trim showed from the first + frame. */ +.pillTrim { + opacity: var(--trims-shown); +} + +.playhead { + position: absolute; + top: 2px; + bottom: 0; + width: 2.5px; + transform: translateX(-50%); + background: linear-gradient(180deg, var(--ost-playhead) 30%, rgba(108, 85, 255, 0.35)); + box-shadow: 0 0 12px var(--ost-playhead-glow); + z-index: 9; +} + +.playheadHead { + position: absolute; + left: 50%; + top: 0; + width: 15px; + height: 20px; + transform: translateX(-50%); + background: var(--ost-playhead); + border-radius: 4px; + clip-path: polygon(0 0, 100% 0, 100% 68%, 50% 100%, 0 68%); +} + +.transport { + position: absolute; + left: 16px; + top: 12px; + z-index: 10; + display: flex; + align-items: center; + gap: 10px; + font-family: var(--ost-font-mono); + font-size: 11px; + color: var(--ost-fg); + font-variant-numeric: tabular-nums; +} + +.cuts { + color: var(--ost-danger); + font-weight: 600; +} + +/* ═══ THE READER'S POINTER ═════════════════════════════════════════════════ + Positioned in vw/vh, which the driver's percentages of the stage match. */ + +.uiCursor { + position: absolute; + left: 0; + top: 0; + width: 28px; + height: 36px; + z-index: 14; + background: var(--ui-cursor) left top / auto 30px no-repeat; + transform: translate(calc(var(--ui-x) * 1vw), calc(var(--ui-y) * 1vh)) scale(var(--ui-bounce)); + transform-origin: calc(var(--ui-hx) * 1%) calc(var(--ui-hy) * 1%); + margin: calc(var(--ui-hy) * -0.3px) 0 0 calc(var(--ui-hx) * -0.28px); + filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.5)); + opacity: var(--ui-on); + will-change: transform; + transition: opacity 0.3s var(--ease); +} + +.stage[data-cur="text"] .uiCursor { + background-position: center center; +} + +/* ═══ FALLBACKS ════════════════════════════════════════════════════════════ */ + +/* ═══ PORTRAIT ═════════════════════════════════════════════════════════════ + The same scene, stacked instead of side by side. + + Nothing below repositions an element. The desktop layout is already driven by + a handful of grid variables — --v-l/--v-t/--v-w place the composite, --pnl-* + the inspector, --cap-h the caption, --tl-h the floor — so portrait only has + to answer them differently and every rule downstream follows. + + One real difference in the choreography: on desktop the act change shrinks + the video to make room for the floor, because they share the height. Here + they do not — the floor takes the bottom, the composite keeps its size, and + what gives way is the inspector's ceiling. So --v-* resolve to the same value + in both phases and the picture holds still while the timeline arrives under + it, which is the calmer reading of the same beat. */ +@media (max-width: 900px) { + .stage { + /* The same box, answered for a stack rather than for two columns. The + caption takes the top of it and the floor the bottom, so the picture's + share is what is left between them — and --tl still moves nothing but + the bottom bound. */ + --gut-h: 18px; + /* Back to the caption's own height: the air the landscape slot carries is + there to hold the inspector off the title, and here the picture is what + follows the title. Fourteen pixels of it come straight off the picture, + which on a short screen is the thing with none to spare. */ + --cap-h: 96px; + --pnl-w: calc(100vw - 2 * var(--gut-h)); + /* What the pane below the picture needs at its smallest: the palette is + 160 tall and the transcript wants a few lines under its header. A + formula and not a constant, for the same reason as --gut: on a 667-tall + phone the floor takes 210 of the box and 190 held here left the picture + 57px of height — a sliver — for the two beats the floor is up. It gives + way before the picture does, and stops at the palette's own 160 plus a + little air on each side — a toolbar touching the picture above it and + the floor below it reads as a mistake. */ + --pane-min: clamp(176px, 24vh, 190px); + + /* Full width, but never more height than the box has left once the + caption, the pane and the floor have taken theirs. Taking the width + unconditionally is what broke a 574px window: the picture grew to 302 + tall in a box with 512, the pane was left 114 for a palette of 160 — + so it lay over the picture — and the transcript's mask came out at + -20px, which is why that panel was empty. */ + --v-w: min( + calc(100vw - 2 * var(--gut-h)), + calc((var(--box-h) - var(--cap-h) - var(--pane-min)) * 1.77778) + ); + --v-h: calc(var(--v-w) * 0.5625); + --v-l: calc((100vw - var(--v-w)) / 2); + --v-t: calc(var(--box-top) + var(--cap-h)); + } + + /* The pair is not a pair here — the picture sits between them — so the + column steps out of the way and its two children place themselves, which + is what `display: contents` is for. */ + .column { + display: contents; + } + + .captions { + position: absolute; + left: var(--gut-h); + top: var(--box-top); + width: calc(100vw - 2 * var(--gut-h)); + height: var(--cap-h); + z-index: 11; + } + + .capTitle { + font-size: 21px; + } + + /* Under the picture, filling what the box has left once the picture and the + floor have taken their share. */ + .panel { + position: absolute; + left: var(--gut-h); + top: calc(var(--v-t) + var(--v-h) + 14px); + width: calc(100vw - 2 * var(--gut-h)); + max-height: calc(var(--box-bot) - var(--v-t) - var(--v-h) - 14px); + transform: translateY(calc((1 - var(--panel)) * 12px)); + } + + /* Centred between the picture and the floor, which is what the stack has + instead of a column to hang from — so it takes back the vertical -50% the + desktop rule drops in order to anchor under the caption. */ + .palette { + position: absolute; + left: 50%; + top: calc((var(--v-t) + var(--v-h) + var(--box-bot)) / 2); + margin: 0; + max-height: none; + transform: translateX(-50%) translateY(-50%) + translateY(calc((1 - var(--palette)) * -16px)); + } + + /* At this scale a 0.5s trim is twelve pixels wide, and a label clipped to + "0" is noise pretending to be information. The trims keep their colour and + their position and give up their text; the zooms and the speed ramp span + seconds, so they keep theirs. */ + .pillTrim { + font-size: 0; + padding: 0; + border-radius: 5px; + } + + .pillZoom, + .pillSpeed { + font-size: 9.5px; + padding: 0 5px; + gap: 3px; + } + +} + +/* Under 360px there is no honest layout left: the composite would be narrower + than the inspector's own controls. The still fallback stands in. */ +@media (max-width: 359px), (prefers-reduced-motion: reduce), (forced-colors: active) { + .band { + height: auto; + } + + /* The column keeps its two children here rather than dissolving: dissolved, + the panel joins the page's flow at full width and lands on top of the + stacked captions. As a block it reads as what it is — the caption, then + the inspector, then the still picture below them. */ + .column { + position: static; + width: auto; + max-width: 60ch; + margin: 0 auto; + transform: none; + } + + .stage { + position: static; + height: auto; + overflow: visible; + } + + .captions { + position: static; + width: auto; + height: auto; + padding: 0 24px; + display: grid; + gap: 26px; + } + + .panel { + max-height: none; + margin: 26px 24px 0; + } + + .cap { + position: static; + opacity: 1; + transform: none; + transition: none; + } +} + +@supports not (position: sticky) { + .band { + height: auto; + } + + .column { + display: contents; + } + + .stage { + position: static; + height: auto; + } + + .scene, + .panel { + display: none; + } + + .captions { + position: static; + width: auto; + max-width: 60ch; + margin: 0 auto; + display: grid; + gap: 26px; + } + + .cap { + position: static; + opacity: 1; + transform: none; + } +} + +@media (forced-colors: active) { + .scene, + .panel { + display: none; + } +} + +/* Reduced motion keeps the picture and gives up the ride: the driver declines + to attach, so every variable stays at the closing values above. The captions + stack over the still editor, because nobody is turning them. */ +@media (prefers-reduced-motion: reduce) { + .band { + height: auto; + } + + .stage { + position: static; + height: auto; + overflow: visible; + } + + .captions { + position: static; + width: auto; + max-width: 60ch; + margin: 0 auto 44px; + padding: 0 24px; + display: grid; + gap: 26px; + } + + .cap { + position: static; + opacity: 1; + transform: none; + transition: none; + } + + .scene { + position: relative; + height: 100vh; + } +} diff --git a/website/src/components/Showcase/content.ts b/website/src/components/Showcase/content.ts new file mode 100644 index 000000000..012aab4f8 --- /dev/null +++ b/website/src/components/Showcase/content.ts @@ -0,0 +1,76 @@ +/** + * Everything the page says after the editor has finished demonstrating itself. + * + * The scroll-driven editor above this section is where the product is argued: + * six settings, shown working, on a real project file. That leaves this section + * a narrower job — the parts of the app the editor is not — and there are four + * of them: the recorder in front of it, the encoder behind it, and the two + * features that are easier to show than to describe. + * + * All four now get a picture, which reverses the split this file used to carry. + * The old argument was that a screenshot of a format dropdown proves nothing a + * sentence does not and costs a request to say it. That argument was about + * screenshots. These four panels are drawn in DOM: they cost no photograph and + * no second request, they re-render in the page's own typeface and swap with + * the theme, and they cannot go stale against a repaint of the application the + * way a plate shot on one build does. The reason to withhold a picture was its + * price; the price is gone. + * + * What has not changed is that the copy has to stand on its own. Every panel + * here is a drawing, it is labelled as one, and nothing in this section asks to + * be believed on the strength of it. The specification line under each claim is + * still doing the work. + */ + +export type Feature = { + id: string; + kicker: string; + claim: string; + body: string; + fact: string; + /** What the drawn panel depicts, for anyone who cannot see it. */ + label: string; + /** Layout only — the copy stays first in the DOM either way. */ + flip?: boolean; +}; + +export const FEATURES: Feature[] = [ + { + id: "record", + kicker: "record", + claim: "It records with the operating system, not around it.", + body: "Pick a window or a display. macOS goes through ScreenCaptureKit and Windows through Windows Graphics Capture — the same capture path the system uses itself. The pointer is recorded as data rather than burned into the pixels, which is the only reason you could restyle it further up this page.", + fact: "ScreenCaptureKit on macOS · Windows Graphics Capture · system audio without an extra driver", + label: + "A drawing of the recorder: two capture targets side by side, Display 1 selected and a window titled Terminal beside it, then the settings for the take — ScreenCaptureKit, system audio, 1920 × 1080 at 60 fps — a microphone and a system-audio toggle, and a Start recording button.", + }, + { + id: "export", + kicker: "export", + claim: "Then it writes the file.", + body: "MP4 from 720p up to source, at 24, 30 or 60, in H.264 or H.265 — or a GIF. The encode runs on your machine and counts frames while it does. No queue, no account, no watermark, and the file is on disk when the bar fills.", + fact: "H.264 / H.265 · 24, 30, 60 fps · no watermark", + label: + "A drawing of the export panel: recording-1783066227227.mp4 going out as MP4, with H.265 chosen beside H.264, 1080p, 60 fps and GIF, and a progress bar 62 per cent along reading frame 1 488 of 2 400, writing to the Movies folder.", + flip: true, + }, + { + id: "captions", + kicker: "captions", + claim: "Transcription runs on your machine.", + body: "whisper.cpp ships with the app, and the model downloads once on first use — after that it works with the network off. The audio never leaves the laptop, and what comes back is editable text: set the typeface, the size, the colour and the position, then burn it into the render.", + fact: "whisper.cpp · 99 languages · offline after first run", + label: + "A drawing of the captions panel: the line “amber day on the validator, and it” set large over the video, and beside it captions switched on, a note that seven caption lines are derived live from the transcript, and a language row offering English, Français, a Translate button and the option to delete a translation.", + }, + { + id: "agent", + kicker: "agent", + claim: "Or say which parts to cut.", + body: "The wizard further up this page places zooms by watching where your cursor went. The agent goes further: it reads the actual transcript and the actual timeline, so it answers with timecodes you can go and check — which spans it will cut, and how much that saves. Every edit it makes is an ordinary undoable one, and it needs a provider key you supply. Nothing runs until you connect one.", + fact: "bring your own key · off by default · every edit is undoable", + label: + "A drawing of the agent's reply. Asked to cut the dead air, it answers with timecodes: 0 to 2.19 seconds of lead-in before “Hi” and 35.12 to 40.03 seconds of tail after “think.”, taking the video from 40 seconds to 33 seconds of playable footage, with the existing zooms left on the same moments — then a green line reading “applied: added 2 trims”.", + flip: true, + }, +]; diff --git a/website/src/components/Showcase/index.tsx b/website/src/components/Showcase/index.tsx new file mode 100644 index 000000000..b580101a9 --- /dev/null +++ b/website/src/components/Showcase/index.tsx @@ -0,0 +1,54 @@ +/** + * What the app is besides the editor: the recorder in front of it, the encoder + * behind it, and the two features that are easier to show than to describe. + * + * Four claims, four drawn panels, alternating sides. Nothing here plays: the + * page has one moving thing on it and it is the editor above, which is the + * point of having built it. + * + * The copy is first in the DOM in every band, including the two that draw the + * panel on the left — the sides are swapped by grid placement, so which way a + * band faces is a layout decision and never a reading-order one. + */ + +import Heading from "@theme/Heading"; + +import { FEATURES } from "./content"; +import { PANELS } from "./panels"; +import styles from "./styles.module.css"; + +export default function Showcase() { + return ( +
+
+ + Recorder, captions, agent, encoder. + + + {FEATURES.map((item) => ( +
+
+

{item.kicker}

+

{item.claim}

+

{item.body}

+

{item.fact}

+
+ + {/* One label for the whole drawing. Without it a screen reader walks + two dozen interface fragments — "Display 1", "60 fps", "62%" — + that mean nothing out of the picture they are drawn in. */} +
+ + {PANELS[item.id]} +
+
+ ))} +
+
+ ); +} diff --git a/website/src/components/Showcase/panels.tsx b/website/src/components/Showcase/panels.tsx new file mode 100644 index 000000000..9a63fe7ed --- /dev/null +++ b/website/src/components/Showcase/panels.tsx @@ -0,0 +1,274 @@ +/** + * The four panels, drawn. + * + * Every string in here is a string the application shows, and every one of them + * is either in the fixture the editor above is driven from or in the repository + * — the format list is the export dialog's, the capture chips are what macOS + * reports for the take, the agent's timecodes are the ones it answered with. + * The panels are illustrations and are labelled as such by their `role="img"`, + * which is also why the placeholder bars and window controls are drawn rather + * than described: with the subtree collapsed to one label, nothing in here is + * read out twice. + * + * Sizes are the design's. The only thing that moved is where the two wallpapers + * come from: both are already on the page, loaded by the composite the editor + * scrubs, so the panels reuse those two URLs and cost no further request. + */ + +import { + Check, + ChevronDown, + ChevronRight, + CircleCheck, + Languages, + Mic, + Trash, + Volume1, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import styles from "./styles.module.css"; + +/** macOS window controls, drawn once for every panel that has a title bar. */ +function TrafficLights({ small }: { small?: boolean }) { + const cls = small ? styles.dotSm : styles.dot; + return ( + <> + + + + + ); +} + +function Bar({ name }: { name: string }) { + return ( +
+ + {name} + +
+ ); +} + +function RecordPanel() { + return ( +
+ +
+
+
+ +
+ Display 1 + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ Window — Terminal +
+
+
+ +
+ ScreenCaptureKit + system audio + 1920 × 1080 · 60 fps +
+ +
+
+ + + + + + +
+ + + Start recording + +
+
+
+ ); +} + +function ExportPanel() { + return ( +
+ +
+
+ recording-1783066227227.mp4 + MP4 +
+ +
+ H.265 + H.264 + 1080p + 60 fps + GIF +
+ +
+
+ +
+
+ frame 1 488 / 2 400 + 62% +
+
+ +
+ writing to ~/Movies — no queue, no account, no watermark +
+
+
+ ); +} + +function CaptionsPanel() { + return ( +
+
+ +
+
+ +
+
+ + + + +
+
+ {/* The burned-in line, at the size and the offset the app draws it. */} +
amber day on the validator, and it
+
+ +
+
+ Captions + +
+ +
+ Show captions + + + +
+ +
7 caption lines, derived live from the transcript.
+ +
Language
+ +
+ Display + + English + + +
+ +
+ + Français + + + + + Translate + +
+ +
+ + Delete this translation +
+ +
+ Translations are stored beside the transcript, never in it — the original text and its + timings stay untouched. +
+
+
+ ); +} + +function AgentPanel() { + return ( +
+
+ + Agent + your key +
+ +
Cut the dead air — how much does it save?
+ +
+ The only silences over a second are at the two ends:{" "} + 0–2.19s of dead lead-in before “Hi” and{" "} + 35.12–40.03s of dead tail after “think.” Cutting both + takes the video about seven seconds shorter —{" "} + 40.0s → 33.0s of playable footage. Existing zooms + stay anchored to the same moments. +
+ +
+ + applied: added 2 trims +
+
+ ); +} + +/** Keyed by the feature id in `content.ts`. */ +export const PANELS: Record = { + record: , + export: , + captions: , + agent: , +}; diff --git a/website/src/components/Showcase/styles.module.css b/website/src/components/Showcase/styles.module.css new file mode 100644 index 000000000..c4e08396c --- /dev/null +++ b/website/src/components/Showcase/styles.module.css @@ -0,0 +1,900 @@ +/** + * Four claims, four drawn panels. + * + * Two kinds of colour live in here and they are not interchangeable: + * + * The section's own chrome — headings, rules, kickers — is --os-* and swaps + * with the theme, like the rest of the page. + * + * The panels are pictures of an interface, and a few of their colours are + * properties of the thing depicted rather than of this page: the macOS window + * controls are the system's three, and the caption plate is the same + * translucent black the app burns into the render. Those stay literal in both + * themes, because a light-theme traffic light is not a traffic light. + * + * Everything in between — the placeholder bars standing in for a page, the + * progress track, the record button's red — is a tint of the surface it sits + * on, so it is a local custom property here with a light-theme value beside it. + * The design ships only the dark half; white at 8% alpha is invisible on white. + */ + +.section { + /* Explicit: the dark-theme document canvas resolves to Infima's own #1b1b1d, + and `html, body { height: 100% }` means the body paints only the first + viewport — so a section without a background sits on the wrong grey. */ + background: var(--os-bg); + color: var(--os-fg); + padding: 130px 28px 0; + + /* The glow behind each panel bleeds 50px past its box. Clip, not hidden: + `clip` trims the bleed without making the section a scroll container. */ + overflow-x: clip; + + --ph-a: rgba(255, 255, 255, 0.14); + --ph-b: rgba(255, 255, 255, 0.08); + --ph-dot: rgba(255, 255, 255, 0.25); + --ph-track: rgba(255, 255, 255, 0.1); + --panel-elev: 0 24px 64px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.04); + --win-elev: 0 18px 40px rgba(0, 0, 0, 0.45); + --rec-fg: #f87171; + --rec-bg: rgba(239, 68, 68, 0.13); + --rec-border: rgba(239, 68, 68, 0.32); +} + +:global([data-theme="light"]) .section { + --ph-a: rgba(15, 23, 32, 0.13); + --ph-b: rgba(15, 23, 32, 0.07); + --ph-dot: rgba(15, 23, 32, 0.2); + --ph-track: rgba(15, 23, 32, 0.09); + --panel-elev: var(--os-elev-pop); + --win-elev: 0 18px 40px -14px rgba(15, 23, 32, 0.28); + --rec-fg: var(--os-danger); + --rec-bg: var(--os-danger-soft); + --rec-border: rgba(220, 38, 38, 0.28); +} + +.inner { + max-width: 1080px; + margin: 0 auto; +} + +.title { + margin: 0 auto; + max-width: 640px; + text-align: center; + font-size: clamp(30px, 3.4vw, 40px); + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1.12; + color: var(--os-fg-emphasis); +} + +/* ---- the bands ---- */ + +.band { + display: grid; + grid-template-columns: 1fr 1.14fr; + gap: 60px; + align-items: center; + margin-top: 120px; + text-align: left; + /* The navbar is 60px and sticky; without this an anchor jump parks the + band's heading underneath it. */ + scroll-margin-top: 76px; +} + +.band:first-of-type { + margin-top: 100px; +} + +.band[data-band="captions"] { + grid-template-columns: 1fr 1.25fr; +} + +/* Assigning columns and rows, not `order`: the tracks are asymmetric, and + reordering auto-placed items moves the panel into the narrow track instead of + swapping the two sides. Pinning only the column is not enough either — + auto-placement refuses to go backwards, so the figure drops to a second row + and the band silently becomes two stacked halves. The DOM keeps the copy + first either way, so reading order never depends on it. */ +.flip { + grid-template-columns: 1.14fr 1fr; +} + +.flip .copy { + grid-column: 2; + grid-row: 1; +} + +.flip .figure { + grid-column: 1; + grid-row: 1; +} + +/* ---- copy ---- */ + +.itemKicker { + margin: 0; + font-family: var(--ifm-font-family-monospace); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--os-accent); +} + +.claim { + margin: 14px 0 0; + font-size: clamp(24px, 2.6vw, 31px); + font-weight: 750; + letter-spacing: -0.025em; + line-height: 1.15; + color: var(--os-fg-emphasis); + text-wrap: pretty; +} + +.body { + margin: 16px 0 0; + max-width: 46ch; + font-size: 15px; + line-height: 1.75; + color: var(--os-fg); + text-wrap: pretty; +} + +.fact { + margin: 24px 0 0; + padding-top: 16px; + border-top: 1px solid var(--os-border-soft); + font-family: var(--ifm-font-family-monospace); + font-size: 12px; + line-height: 1.7; + color: var(--os-muted); +} + +/* ---- the panel shell ---- */ + +.figure { + position: relative; + margin: 0; + min-width: 0; +} + +.glow { + position: absolute; + inset: -40px -50px; + background: radial-gradient(ellipse 62% 56% at 50% 44%, var(--os-accent-wash), transparent 68%); + pointer-events: none; +} + +.panel { + position: relative; + border-radius: 16px; + border: 1px solid var(--os-border); + background: var(--os-surface); + box-shadow: var(--panel-elev); + overflow: hidden; +} + +.panelBar { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 13px; + background: var(--os-surface-1); + border-bottom: 1px solid var(--os-border-soft); +} + +.panelName { + flex: 1; + text-align: center; + font-size: 11.5px; + color: var(--os-muted); +} + +/* Balances the three window controls so the title sits on the panel's centre + line and not on the centre of what is left of it. */ +.panelPad { + width: 38px; +} + +.dot, +.dotSm { + border-radius: 50%; +} + +.dot { + width: 8px; + height: 8px; +} + +.dotSm { + width: 7px; + height: 7px; +} + +.dotRed { + background: #ff5f57; +} + +.dotAmber { + background: #febc2e; +} + +.dotGreen { + background: #28c840; +} + +/* The bars standing in for a page nobody is meant to read. */ +.phA { + background: var(--ph-a); +} + +.phB { + background: var(--ph-b); +} + +/* ---- record ---- */ + +.recBody { + padding: 18px; +} + +.targets { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.target { + position: relative; + aspect-ratio: 16 / 10; + border-radius: 12px; + overflow: hidden; +} + +.targetOn { + border: 1.5px solid var(--os-accent); + box-shadow: 0 0 0 3px var(--os-accent-ring); +} + +.targetOff { + border: 1px solid var(--os-border); + background: var(--os-surface-1); +} + +.targetShot { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.targetFoot { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 11px; +} + +/* The selected tile's label sits on a photograph, so it carries its own scrim + rather than trusting whichever wallpaper is underneath. */ +.targetFootOn { + background: linear-gradient(transparent, rgba(8, 10, 13, 0.85)); +} + +.targetNameOn { + font-size: 12px; + font-weight: 600; + color: #fff; +} + +.targetNameOff { + font-size: 12px; + font-weight: 500; + color: var(--os-muted); +} + +.targetTick { + color: var(--os-accent); +} + +.miniWin { + position: absolute; + left: 17%; + right: 17%; + top: 20%; + bottom: 24%; + border-radius: 8px; + background: var(--os-surface-2); + border: 1px solid var(--os-border-soft); + overflow: hidden; +} + +.miniBar { + display: flex; + align-items: center; + gap: 4px; + height: 16px; + padding: 0 7px; + background: var(--os-surface-3); +} + +.miniDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--ph-dot); +} + +.miniBody { + padding: 9px 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.miniLine { + height: 6px; + border-radius: 3px; +} + +.chips { + margin-top: 14px; + display: flex; + gap: 7px; + flex-wrap: wrap; +} + +.chip { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--os-border-soft); + font-family: var(--ifm-font-family-monospace); + font-size: 10.5px; + color: var(--os-muted); +} + +.recFoot { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.toggles { + display: flex; + gap: 7px; +} + +.toggle { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 9px; + border: 1px solid var(--os-border); + color: var(--os-fg); +} + +.toggleOff { + color: var(--os-muted); +} + +.recStart { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 15px; + border-radius: 999px; + background: var(--rec-bg); + border: 1px solid var(--rec-border); + color: var(--rec-fg); + font-size: 12.5px; + font-weight: 600; +} + +.recDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--rec-fg); +} + +/* ---- export ---- */ + +.expBody { + padding: 22px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.expHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.expFile { + font-family: var(--ifm-font-family-monospace); + font-size: 12px; + color: var(--os-fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.expContainer { + flex-shrink: 0; + padding: 3px 10px; + border-radius: 999px; + border: 1px solid var(--os-accent-border); + background: var(--os-accent-soft); + color: var(--os-accent); + font-family: var(--ifm-font-family-monospace); + font-size: 10.5px; + font-weight: 600; +} + +.pills { + display: flex; + gap: 7px; + flex-wrap: wrap; +} + +.pill { + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--os-border); + font-size: 11.5px; + font-weight: 500; + color: var(--os-muted); +} + +.pillOn { + border-color: var(--os-accent-border); + background: var(--os-accent-soft); + color: var(--os-accent); + font-weight: 600; +} + +.track { + position: relative; + height: 8px; + border-radius: 999px; + background: var(--ph-track); + overflow: hidden; +} + +.fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 62%; + border-radius: 999px; + background: var(--os-accent); +} + +.trackFoot { + margin-top: 9px; + display: flex; + justify-content: space-between; + font-family: var(--ifm-font-family-monospace); + font-size: 11.5px; +} + +.frames { + color: var(--os-muted); +} + +.pct { + color: var(--os-accent); + font-weight: 600; +} + +/* --os-meta is the page's faintest grey, and body copy is not allowed to use + it. This is not body copy: it is a line inside a drawing of a dialog, the + drawing is labelled, and the sentence it repeats — no queue, no account, no + watermark — is made in full size in the paragraph beside the panel. */ +.expNote { + font-family: var(--ifm-font-family-monospace); + font-size: 10.5px; + color: var(--os-meta); +} + +/* ---- captions ---- */ + +.capPanel { + display: flex; + min-height: 380px; +} + +.capStage { + position: relative; + flex: 1; + min-width: 0; +} + +.capWall { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.capWin { + position: absolute; + left: 9%; + right: 9%; + top: 10%; + bottom: 30%; + border-radius: 10px; + background: var(--os-surface-1); + border: 1px solid var(--os-border); + box-shadow: var(--win-elev); + overflow: hidden; +} + +.capWinBar { + height: 24px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + background: var(--os-surface-2); + border-bottom: 1px solid var(--os-border-soft); +} + +.capWinBody { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 9px; +} + +.capWinLine { + height: 8px; + border-radius: 4px; +} + +/* The plate the app burns into the render: literal in both themes, because it + is drawn over video and not over the page. + + No backdrop-filter, though the app's own plate has one. It always sits over + the bottom of the wallpaper — the drawn window stops at 70% and the plate + starts at 91% — so its backdrop is a smooth gradient, and blurring a smooth + gradient returns the same gradient. Measured: removing the blur moved 0.8% + of the panel's pixels by a mean of 0.13/255, all of them on the plate's own + edge. It bought a compositing layer and a backdrop read-back for that. */ +.capLine { + position: absolute; + left: 50%; + bottom: 9%; + transform: translateX(-50%); + max-width: 90%; + padding: 10px 18px; + border-radius: 10px; + background: rgba(8, 10, 13, 0.72); + font-size: 18px; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.capSide { + width: 252px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 11px; + border-left: 1px solid var(--os-border); + background: var(--os-surface); + padding: 15px 16px; +} + +.capSideHead { + display: flex; + align-items: center; + justify-content: space-between; +} + +.capSideTitle { + font-size: 14px; + font-weight: 650; + color: var(--os-fg-emphasis); +} + +.capChevron { + color: var(--os-muted); + flex-shrink: 0; +} + +.capRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 11px 13px; + border-radius: 12px; + border: 1px solid var(--os-border); + background: var(--os-surface-1); +} + +.capRowLabel { + font-size: 12.5px; + font-weight: 600; + color: var(--os-fg-emphasis); +} + +.switch { + position: relative; + width: 34px; + height: 20px; + flex-shrink: 0; + border-radius: 999px; + background: var(--os-accent); +} + +.switchKnob { + position: absolute; + right: 2px; + top: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; +} + +.capMeta { + font-size: 11px; + line-height: 1.55; + color: var(--os-muted); +} + +.capGroup { + margin-top: 2px; + font-size: 12.5px; + font-weight: 650; + color: var(--os-fg-emphasis); +} + +.capSelectRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 13px; + border-radius: 12px; + border: 1px solid var(--os-border); + background: var(--os-surface-1); +} + +.capSelectLabel { + font-size: 12px; + color: var(--os-fg); +} + +.capSelect { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border-radius: 9px; + border: 1px solid var(--os-border); + background: var(--os-surface-2); + font-size: 11.5px; + font-weight: 500; + color: var(--os-fg); +} + +.capActions { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.capPicker { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 7px; + padding: 8px 11px; + border-radius: 10px; + border: 1px solid var(--os-border); + font-size: 11.5px; + font-weight: 500; + color: var(--os-fg); +} + +.capButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--os-border); + font-size: 11.5px; + font-weight: 600; + color: var(--os-fg); +} + +.capButtonWide { + align-self: flex-start; + gap: 7px; + font-weight: 500; +} + +.capFoot { + font-size: 10.5px; + line-height: 1.6; + color: var(--os-meta); +} + +/* ---- agent ---- */ + +.agentPanel { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px; +} + +.agentHead { + display: flex; + align-items: center; + gap: 9px; + padding-bottom: 12px; + border-bottom: 1px solid var(--os-border-soft); +} + +.agentLogo { + width: 18px; + height: 18px; + object-fit: contain; +} + +.agentName { + font-size: 13.5px; + font-weight: 600; + color: var(--os-fg-emphasis); +} + +.agentKey { + margin-left: auto; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--os-border); + font-family: var(--ifm-font-family-monospace); + font-size: 10px; + color: var(--os-muted); +} + +.ask { + align-self: flex-end; + max-width: 72%; + padding: 10px 14px; + border-radius: 12px; + background: var(--os-accent-soft); + border: 1px solid var(--os-accent-border); + font-size: 13px; + line-height: 1.6; + color: var(--os-fg-2); +} + +.reply { + align-self: flex-start; + max-width: 94%; + padding: 12px 15px; + border-radius: 12px; + background: var(--os-surface-2); + border: 1px solid var(--os-border-soft); + font-size: 13px; + line-height: 1.75; + color: var(--os-fg); +} + +.tc, +.tcTotal { + font-family: var(--ifm-font-family-monospace); + font-size: 12px; + white-space: nowrap; +} + +.tc { + color: var(--os-accent); +} + +.tcTotal { + color: var(--os-fg-emphasis); +} + +.applied { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 13px; + border-radius: 999px; + background: var(--os-accent-soft); + border: 1px solid var(--os-accent-border); + color: var(--os-accent); + font-family: var(--ifm-font-family-monospace); + font-size: 11.5px; + font-weight: 600; +} + +/* ---- narrow ---- */ + +@media (max-width: 900px) { + .section { + padding-top: 72px; + } + + .band, + .flip, + .band[data-band="captions"] { + grid-template-columns: 1fr; + gap: 28px; + margin-top: 72px; + } + + /* The explicit placement above has to be undone with it, or the single + column inherits `grid-column: 2` and collapses to nothing. */ + .flip .copy, + .flip .figure { + grid-column: 1; + grid-row: auto; + } + + /* Side by side, the caption panel's inspector is a quarter of it; stacked in + one column it would be half. */ + .capPanel { + flex-direction: column; + min-height: 0; + } + + .capStage { + aspect-ratio: 16 / 10; + flex: none; + } + + .capSide { + width: auto; + border-left: none; + border-top: 1px solid var(--os-border); + } +} + +@media (max-width: 600px) { + .section { + padding-left: 18px; + padding-right: 18px; + } + + .glow { + inset: -30px -20px; + } + + .capLine { + font-size: 14px; + padding: 8px 13px; + } +} + +@media print { + .section { + padding: 24px 0; + } +} diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 9ef07b8f4..29c060471 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -57,12 +57,17 @@ --os-fg: #3a3f47; --os-muted: #6b7280; --os-meta: #98a0aa; - --os-accent: #0d9668; - --os-brand-lo: #0b7f59; - --os-accent-soft: rgba(13, 150, 104, 0.11); - --os-accent-wash: rgba(13, 150, 104, 0.06); - --os-accent-border: rgba(13, 150, 104, 0.25); - --os-accent-ring: rgba(13, 150, 104, 0.28); + /* Darkened from #0d9668. The design system's light accent measures 3.63:1 + against --os-bg, which is a Level AA failure twice over: as inline 12.5px + text (`xattr`, `sudo` in the install panes), and — it being the same pair + of colours either way round — as the fill under the --os-bg label of both + Download buttons. #0a7d57 measures 4.96:1, at the same hue. */ + --os-accent: #0a7d57; + --os-brand-lo: #086b4a; + --os-accent-soft: rgba(10, 125, 87, 0.11); + --os-accent-wash: rgba(10, 125, 87, 0.06); + --os-accent-border: rgba(10, 125, 87, 0.25); + --os-accent-ring: rgba(10, 125, 87, 0.28); --os-danger: #dc2626; --os-danger-soft: rgba(220, 38, 38, 0.11); --os-annotation: #b45309; @@ -143,6 +148,40 @@ body { background: var(--os-bg); } +/* ---- focus and scrolling ---- + The site shipped no authored focus style at all: Infima defines `:focus` only + for `.close`, so every link, button and in-page anchor fell back to whatever + the engine draws — which on the dark theme is a thin blue ring nobody can see + against #080a0d. `:focus` is repeated because `:focus-visible` is Safari 15.4, + and the two rules are separate so an engine that fails to parse the second + still applies the first. */ + +:root { + /* Nothing compensated for the 60px sticky navbar, so every anchor jump on + the site parked its target underneath the bar. */ + scroll-padding-top: 76px; +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +} + +:focus { + outline: 2px solid var(--os-accent); + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +:focus-visible { + outline: 2px solid var(--os-accent); + outline-offset: 2px; +} + /* ---- navbar ---- Matches "OpenScreen Docs Site.dc.html" header 1:1: 60px height, 28px side padding, 18px item gap, divider after the brand lockup, @@ -274,7 +313,7 @@ body { padding: 7px 14px !important; border-radius: 9px; background: var(--os-accent); - color: #fff !important; + color: var(--os-bg) !important; font-size: 12.5px; font-weight: 600; } diff --git a/website/src/lib/structured-data.ts b/website/src/lib/structured-data.ts index 61d71aca5..e9136d22d 100644 --- a/website/src/lib/structured-data.ts +++ b/website/src/lib/structured-data.ts @@ -34,6 +34,18 @@ const SOFTWARE_APPLICATION_LD = { downloadUrl: `${SITE_URL}/download/`, installUrl: "https://github.com/getopenscreen/openscreen/releases", softwareHelp: `${SITE_URL}/docs/intro/`, + // No `screenshot`. It pointed at the establishing plate the landing page used + // to open with; that page is the scroll recreation now and the plate went with + // it, so the URL had been resolving to a 404. Nothing shipped is an honest + // replacement — og-image.png is a wordmark card, and the recreation is a + // recreation rather than a frame of the running editor — so restoring the + // property means shipping a real screenshot, not repointing this at the + // nearest available picture. + // + // Deliberately no VideoObject either: the walkthrough's clips are silent + // five-second fragments with no standalone playback page, which is not what + // that rich result describes, and declaring a video the page never presents + // as one is a manual-action risk. license: "https://github.com/getopenscreen/openscreen/blob/main/LICENSE", isAccessibleForFree: true, // `offers` at price 0 is what lets a result carry a "Free" annotation; diff --git a/website/src/pages/index.module.css b/website/src/pages/index.module.css index 3e767c3be..c16397c9a 100644 --- a/website/src/pages/index.module.css +++ b/website/src/pages/index.module.css @@ -1,15 +1,26 @@ -/* Marketing landing page — always dark, styled after the OpenScreen Design System hero. */ +/* Marketing landing page. Both themes — every colour here is an --os-* token, + which swaps under [data-theme="light"]; nothing may be a literal. */ .hero, -.visualSection, .features, .quickStart { background: var(--os-bg); color: var(--os-fg); } +/* A full screen, as the design has it: the recreation is meant to begin only + once the hero has been scrolled past. */ .hero { - padding: 88px 28px 0; + position: relative; + /* One screen, navbar included. At a flat 100vh the hero is 60px taller than + the viewport it is meant to fill, so its bottom edge — and the scroll hint + pinned to it — sat below the fold, which is the one place an edge marker + must not be. */ + min-height: calc(100vh - var(--ifm-navbar-height, 60px)); + display: flex; + align-items: center; + justify-content: center; + padding: 88px 28px 56px; text-align: center; background: radial-gradient(ellipse 60% 40% at 50% 0%, var(--os-accent-wash), transparent 60%), var(--os-bg); } @@ -32,6 +43,15 @@ margin-bottom: 22px; } +/* --os-accent clears 4.5:1 against --os-bg, but the pill is not --os-bg: its own + --os-accent-soft tint composites to rgb(224,237,234) and takes the label down + to 4.28:1 at 12.8px/600, the one place on the page where accent text sits on + an accent-tinted surface. Light only — --os-brand-lo measures 4.45:1 on the + dark pill, so unscoped this would trade the failure rather than fix it. */ +:global([data-theme="light"]) .badge { + color: var(--os-brand-lo); +} + .title { margin: 20px 0 0; font-size: clamp(42px, 6vw, 68px); @@ -85,15 +105,21 @@ transition: transform 0.15s ease, box-shadow 0.15s ease; } +/* The glow is derived from the accent rather than written as a literal: the old + rgba(16,185,129,.25) was the *dark* accent, and it shipped unchanged into the + light theme. Foreground is --os-bg, not #fff — white on the light-theme accent + measures 3.76:1, which is a Level AA failure on the page's main call to + action. Against --os-bg the same button measures 7.81:1 dark and 4.96:1 light: + AA in both, AAA in one. */ .primaryCta { background: var(--os-accent); - color: #fff; - box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25); + color: var(--os-bg); + box-shadow: 0 8px 24px var(--os-accent-ring); } .primaryCta:hover { transform: translateY(-2px); - color: #fff; + color: var(--os-bg); } .secondaryCta { @@ -108,603 +134,120 @@ color: var(--os-fg-2); } -.note { - margin: 22px auto 0; - max-width: 460px; - font-size: 12.5px; - color: var(--os-meta); - line-height: 1.5; -} - -.note strong { - color: var(--os-muted); -} - -/* ---- product visual ---- */ - -.visualSection { - padding: 48px 28px 100px; - display: flex; - justify-content: center; -} - -.visual { - width: 100%; - max-width: 1040px; - border-radius: 16px; - background: var(--os-surface-1); - border: 1px solid var(--os-border); - box-shadow: var(--os-elev-pop); - overflow: hidden; -} - -.visualTopbar { - display: flex; - align-items: center; - gap: 0.75rem; - height: 44px; - padding: 0 1rem; - border-bottom: 1px solid var(--os-border-soft); - background: var(--os-surface); -} - -.dots { - display: flex; - gap: 6px; -} - -.dot { - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--os-surface-3); -} - -.divider { - width: 1px; - height: 16px; - background: var(--os-border); -} - -.segmented { - display: flex; - gap: 2px; - padding: 2px; - border-radius: 8px; - background: var(--os-surface-2); - font-size: 11px; - color: var(--os-muted); -} - -.segmented span { - padding: 3px 10px; - border-radius: 6px; -} - -.segmentedActive { - background: var(--os-surface-hi, var(--os-surface-3)); - color: var(--os-fg-emphasis); -} - -.savedIndicator { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--ifm-font-family-monospace); - font-size: 10.5px; - color: var(--os-muted); -} - -.savedDot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--os-accent); - box-shadow: 0 0 0 3px var(--os-accent-soft); -} - -.visualBody { - display: grid; - grid-template-columns: 260px 1fr; - min-height: 340px; -} - -/* Stacked, the mock has to read top-down: the editor is the product and the - agent panel is the sub-plot, so the stage goes first and the panel's right - border becomes the rule between them. Ordered in CSS rather than in the - markup so the desktop grid keeps its natural sidebar-then-stage source order. */ -@media (max-width: 780px) { - .visualBody { - grid-template-columns: 1fr; - min-height: 0; - } - - .stage { - order: 1; - } - - .agentPanel { - order: 2; - border-right: none; - border-top: 1px solid var(--os-border); - } -} - -.agentPanel { - border-right: 1px solid var(--os-border); - background: var(--os-surface); - padding: 16px 16px 14px; - display: flex; - flex-direction: column; - gap: 14px; -} - -.agentHeader { - display: flex; - align-items: center; - gap: 8px; - font-size: 12.5px; - font-weight: 600; - color: var(--os-fg-emphasis); -} - -.contextBadge { - font-size: 10.5px; - font-weight: 500; - color: var(--os-muted); - padding: 2px 7px; - border-radius: 999px; - background: var(--os-surface-2); -} - -.chatBubbleUser, -.chatBubbleAssistant { - border-radius: 11px; - padding: 9px 11px; - font-size: 12.5px; - line-height: 1.5; -} - -.chatBubbleUser { - background: var(--os-surface-2); - color: var(--os-fg-2); -} - -.chatBubbleAssistant { - background: var(--os-accent-wash); - border: 1px solid var(--os-accent-border); - color: var(--os-fg-2); -} - -.chatMeta { - font-size: 10px; - color: var(--os-muted); - margin-bottom: 3px; -} - -.chipRow { - margin-top: auto; - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.chip { - padding: 5px 10px; - border-radius: 999px; - background: var(--os-surface-2); - border: 1px solid var(--os-border); - font-size: 11.5px; - color: var(--os-fg-2); -} - -.stage { - display: flex; - flex-direction: column; - min-width: 0; -} - -.stageFrame { - flex: 1; - position: relative; - background: radial-gradient(ellipse 68% 60% at 50% 38%, var(--os-surface-2) 0%, var(--os-bg) 70%); - display: flex; - align-items: center; - justify-content: center; - padding: 28px; - min-height: 220px; -} - -.stagePreview { - position: relative; - width: 100%; - max-width: 420px; - aspect-ratio: 16 / 9; - border-radius: 12px; - background: radial-gradient(ellipse at 50% 34%, #e6e8ea 0%, #b7bbc2 34%, #676d78 68%, #14161c 100%); - box-shadow: 0 30px 70px -24px rgba(0, 0, 0, 0.7); -} - -.timeBadge { - position: absolute; - top: 10px; - left: 10px; - padding: 3px 7px; - border-radius: 7px; - background: rgba(8, 10, 13, 0.5); - color: #fff; - font-family: var(--ifm-font-family-monospace); - font-size: 10px; -} - -.transport { - position: absolute; - left: 50%; - bottom: 14px; - transform: translateX(-50%); - display: inline-flex; - align-items: center; - gap: 8px; - padding: 7px 10px; - border-radius: 13px; - background: var(--os-surface-1); - border: 1px solid var(--os-border); - box-shadow: var(--os-elev-card); - color: var(--os-muted); -} - -.playButton { - width: 30px; - height: 30px; - display: grid; - place-items: center; - border-radius: 10px; - background: var(--os-accent); - color: #fff; -} - -/* nowrap: the transport floats over the preview, and a wrapped timecode - doubles its height into a slab covering half the frame on a phone. */ -.transportTime { - font-family: var(--ifm-font-family-monospace); - font-size: 11px; - color: var(--os-fg); - margin-left: 4px; - white-space: nowrap; -} - -.meta { - color: var(--os-meta); -} - -.muted { - color: var(--os-muted); -} - -.timeline { - border-top: 1px solid var(--os-border); - background: var(--os-surface); - padding: 12px 16px 14px; -} - -.timelineRuler { - display: flex; - justify-content: space-between; - margin-bottom: 6px; - font-family: var(--ifm-font-family-monospace); - font-size: 9.5px; - color: var(--os-meta); -} - -.timelineTrack { - position: relative; - height: 20px; - margin-bottom: 5px; - border-radius: 6px; - background: var(--os-surface-1); -} - -.pill { - position: absolute; - top: 1px; - bottom: 1px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 5px; - font-family: var(--ifm-font-family-monospace); - font-size: 9.5px; - font-weight: 600; - white-space: nowrap; - padding: 0 6px; -} - -.pillAccent { - background: var(--os-accent-soft); - color: var(--os-accent); - border: 1px solid var(--os-accent-border); -} - -.pillAnnotation { - background: var(--os-annotation-wash); - color: var(--os-annotation); - border: 1px solid rgba(251, 191, 36, 0.3); -} - -.pillDanger { - background: var(--os-danger-soft); - color: var(--os-danger); - border: 1px solid rgba(248, 113, 113, 0.3); -} - -.pillSpeed { - background: var(--os-speed-wash); - color: var(--os-speed); - border: 1px solid rgba(251, 146, 60, 0.3); -} - -.clipRow { - display: flex; - gap: 6px; - height: 40px; -} - -.clipActive { - flex: 1; - border-radius: 9px; - border: 1.5px solid var(--os-accent); - background: var(--os-surface-1); -} - -.clip { - flex: 1; - border-radius: 9px; - border: 1px solid var(--os-border); - background: var(--os-surface-1); -} - /* ---- shared section chrome ---- */ .sectionKicker { font-family: var(--ifm-font-family-monospace); font-size: 11px; - letter-spacing: 0.08em; + font-weight: 600; + letter-spacing: 0.16em; color: var(--os-accent); text-transform: uppercase; } .sectionTitle { - margin: 10px 0 0 !important; - font-size: clamp(26px, 3vw, 32px); - font-weight: 700 !important; - letter-spacing: -0.02em; + margin: 14px 0 0 !important; + font-size: clamp(24px, 2.8vw, 32px); + font-weight: 750 !important; + letter-spacing: -0.025em; color: var(--os-fg-emphasis) !important; } -.sectionTitleSm { - composes: sectionTitle; - font-size: clamp(24px, 2.6vw, 28px); -} - -/* ---- features / bento ---- */ +/* ---- coda ---- + Three claims that are not events: a licence, an absence of network traffic, a + list of platforms. Everything that *is* an event — the editor, the recorder, + the captions, the agent — is demonstrated above, so these three are set as + columns of prose rather than as cards: a card is a container for a thing, and + there is no thing here, only a sentence. The rules between them are the only + chrome, and they are what makes three paragraphs read as one list. */ .features { - padding: 0 28px 96px; + padding: 140px 28px 0; } .featuresInner { - max-width: 1040px; + max-width: 1080px; margin: 0 auto; text-align: center; } -.bento { - margin-top: 2.5rem; +.trio { + margin-top: 52px; display: grid; - grid-template-columns: repeat(6, 1fr); - grid-template-areas: - "large large large large ai ai" - "large large large large native native" - "free free captions captions captions captions"; - gap: 0.875rem; + grid-template-columns: repeat(3, 1fr); text-align: left; } -@media (max-width: 900px) { - .bento { - grid-template-columns: 1fr; - grid-template-areas: none; - } - - .card { - grid-area: auto !important; - } -} - -.card { - border-radius: 14px; - border: 1px solid var(--os-border); - background: var(--os-surface-1); - box-shadow: var(--os-elev-card); - padding: 22px; +.trioItem { + padding: 6px 38px; + border-left: 1px solid var(--os-border-soft); } -.card h3 { - margin: 0; - font-size: 15px; - font-weight: 600; - color: var(--os-fg-emphasis); - letter-spacing: -0.01em; +/* The first column's rule would be a rule against nothing, and its text has to + line up with the heading above it, so it keeps the gutter and loses the edge. + The last one only loses the gutter. */ +.trioItem:first-child { + padding-left: 0; + border-left: none; } -.card p { - margin: 0; - font-size: 12.5px; - line-height: 1.55; - color: var(--os-fg-2); +.trioItem:last-child { + padding-right: 0; } -.bentoLarge { - grid-area: large; - display: flex; - flex-direction: column; - gap: 16px; - padding: 26px; +.trioTick { + color: var(--os-accent); } -.bentoLarge h3 { - font-size: 16px; +.trioItem h3 { + margin: 16px 0 0; + font-size: 16.5px; + font-weight: 650; + letter-spacing: -0.015em; + color: var(--os-fg-emphasis); } -.bentoLarge p { +.trioItem p { + margin: 10px 0 0; font-size: 13.5px; - max-width: 480px; -} - -.miniTimeline { - flex: 1; - min-height: 64px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 10px; -} - -.miniClipsRow { - display: flex; - gap: 3px; - height: 40px; -} - -.miniClip { - position: relative; - min-width: 0; - border-radius: 10px; - border: 1.5px solid var(--os-border); - background: var(--os-surface-1); - overflow: hidden; - box-shadow: inset 0 1px 6px rgba(255, 255, 255, 0.06); -} - -.miniWaveform { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1.5px; - padding: 0 7px; -} - -.miniWaveform span { - flex: 1 1 0; - min-width: 1px; - max-width: 2.5px; - background: var(--os-accent); - opacity: 0.7; - border-radius: 2px; -} - -.miniTrack { - position: relative; - height: 22px; - border-radius: 7px; - background: var(--os-surface-2); -} - -.miniRegion { - position: absolute; - top: 3px; - bottom: 3px; - border-radius: 4px; -} - -.cardHeader { - display: flex; - align-items: center; - gap: 10px; -} - -.cardHeader h3 { - margin: 0; -} - -.cardPills { - display: flex; - gap: 6px; - margin-top: auto; -} - -.pillStatic { - padding: 3px 9px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; -} - -.bentoAi { - grid-area: ai; - display: flex; - flex-direction: column; - gap: 12px; -} - -.bentoNative { - grid-area: native; - display: flex; - flex-direction: column; - gap: 12px; -} - -.bentoFree { - grid-area: free; - display: flex; - flex-direction: column; - gap: 12px; -} - -.bentoCaptions { - grid-area: captions; - display: flex; - align-items: center; - gap: 16px; + line-height: 1.75; + color: var(--os-fg); + text-wrap: pretty; } -.bentoCaptions h3 { - margin: 0 0 4px; -} +@media (max-width: 900px) { + .trio { + grid-template-columns: 1fr; + gap: 34px; + } -.iconBadge { - width: 34px; - height: 34px; - flex-shrink: 0; - border-radius: 9px; - background: var(--os-accent-soft); - display: grid; - place-items: center; - color: var(--os-accent); + /* Stacked, a left rule points down the page instead of between the columns + it was separating. */ + .trioItem, + .trioItem:first-child { + padding: 0; + border-left: none; + } } /* ---- quick start ---- */ .quickStart { - padding: 0 28px 100px; + padding: 140px 28px 100px; display: flex; justify-content: center; } .quickStartInner { width: 100%; - max-width: 1040px; + max-width: 1080px; text-align: center; } -/* Same three-column rhythm as .platformStripInner below, so the two sections - line up. Grid items stretch, which is what keeps the three panes equal - height even though only two of them carry a command. */ +/* Grid items stretch, which is what keeps the three panes equal height even + though only two of them carry a command. */ .installGrid { - margin-top: 26px; + margin-top: 52px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.875rem; @@ -724,7 +267,7 @@ border: 1px solid var(--os-border-soft); font-family: var(--ifm-font-family-monospace); font-size: 10.5px; - color: var(--os-meta); + color: var(--os-muted); } /* Windows has no command to run. Styling the line as prose rather than as @@ -771,8 +314,12 @@ white-space: pre-wrap; } -/* Second half of each pane: what the platform actually gives you. Was its own - three-card strip below; folded in here so the page has one row, not two. */ +/* Colours the `#` comment lines inside the three install panes. */ +.meta { + color: var(--os-muted); +} + +/* Second half of each pane: what the platform actually gives you. */ .paneFoot { margin: 0; padding: 0.875rem 1rem; @@ -787,9 +334,11 @@ } .quickStartNote { - margin: 22px 0 0; + margin: 30px auto 0; + max-width: 700px; font-size: 12.5px; - color: var(--os-meta); + line-height: 1.7; + color: var(--os-muted); } .quickStartNote a { @@ -807,51 +356,93 @@ } /* ---- phone ---- - The 28px section gutter is a third of the width of a small phone; every - inner padding here is scaled to give the mock and the cards room to breathe - rather than to change their design. */ + The 28px section gutter is a third of the width of a small phone. */ @media (max-width: 600px) { .hero { padding: 64px 20px 0; } - .visualSection { - padding: 32px 16px 72px; - } - .features { - padding: 0 20px 72px; + padding: 96px 20px 0; } .quickStart { - padding: 0 20px 76px; + padding: 96px 20px 76px; } +} - .agentPanel { - padding: 14px; - gap: 12px; - } - .stageFrame { - padding: 16px; - min-height: 0; - } +/* ---- hero, v4 ---- */ - .transport { - gap: 6px; - bottom: 10px; - padding: 6px 8px; - } +.badgeRow { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin: 0 0 16px; +} - .timeline { - padding: 10px 12px 12px; +.badgeNew { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + background: var(--os-accent); + /* --os-bg, not a literal near-black: the accent swaps under + [data-theme="light"] and a fixed foreground therefore sits on two + different greens. #04110b measured 7.59:1 on the dark accent and 3.75:1 on + the light one — an AA failure at this size. Against --os-bg the same label + is 7.81:1 and 4.96:1, which is the CTA's solution and the CTA's reason. */ + color: var(--os-bg); + font-family: var(--ifm-font-family-monospace); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.badgeText { + font-size: 13.5px; + color: var(--os-fg-2); +} + +/* Pinned to the bottom of the first screen, where an edge belongs, rather than + trailing the copy as one more paragraph. No media gate any more: it used to + carry a claim that was only true where the driver runs, and "there is more + below" is true everywhere. */ +.scrollHint { + position: absolute; + left: 50%; + /* 81px, the design's own offset. At 34 it clung to the edge with a void + above it; this reads as the bottom of a screen rather than as the bottom + of the window. */ + bottom: 81px; + transform: translateX(-50%); + margin: 0; + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; + font-weight: 500; + color: var(--os-fg-2); + animation: os-scroll-nudge 2.4s var(--ifm-transition-timing-default, ease-in-out) infinite; +} + +/* The design's nudge: four pixels, slowly, which is enough to read as "keep + going" and little enough not to nag. */ +@keyframes os-scroll-nudge { + 0%, + 100% { + transform: translateX(-50%) translateY(0); } - /* The icon is centred against a three-line paragraph on desktop; on a phone - the same text runs to seven lines and leaves the badge stranded mid-card. */ - .bentoCaptions { - align-items: flex-start; + 50% { + transform: translateX(-50%) translateY(4px); } } +@media (prefers-reduced-motion: reduce) { + .scrollHint { + animation: none; + } +} diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index e7109c5d9..953ebe766 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -2,30 +2,13 @@ import Head from "@docusaurus/Head"; import Link from "@docusaurus/Link"; import Heading from "@theme/Heading"; import Layout from "@theme/Layout"; -import { - Apple, - AppWindow, - Captions, - Cpu, - Download, - HeartHandshake, - Monitor, - Pause, - Scissors, - SkipBack, - SkipForward, - TerminalSquare, -} from "lucide-react"; +import { Apple, AppWindow, ArrowDown, CircleCheck, Download, TerminalSquare } from "lucide-react"; +import Editor from "../components/Editor"; +import Showcase from "../components/Showcase"; import { jsonLd, softwareApplicationLd } from "../lib/structured-data"; import styles from "./index.module.css"; -// Static decorative waveform bars for the "multi-track editor" bento card — -// mirrors the real editor's per-clip waveform rendering (thin accent bars -// of varying height inside a rounded, bordered clip block). -const MINI_WAVEFORM_A = [30, 55, 80, 45, 65, 90, 50, 35, 70, 60, 40, 75, 55, 30]; -const MINI_WAVEFORM_B = [45, 65, 35, 85, 55, 40, 70, 90, 50, 30, 60, 75, 45, 65, 35, 55]; - export default function Home() { return (
- Pre-release · work in progress - {/* The descriptor sits inside the h1 rather than in the paragraph - below it: the brand name alone gave the page's only h1 no - subject. Rendered as a block at the old tagline's size, so the - visual hierarchy is unchanged — big wordmark, descriptor under it. */} +

+ NEW + Export faster +

+ {/* The product's name, not a claim about it. The design opens on + "Screen Recording / Reimagined", which is the one line on a page + that spends its whole length proving specific things — the editor + runs live, the model is 264 MB, every edit is undoable — that + proves nothing. It also left the strongest on-page signal there is + without the word people search once they have heard of us. */} OpenScreen @@ -52,6 +40,11 @@ export default function Home() {

Native capture, local AI, no paywall.

+ {/* Not "Download for macOS". This page's own trio says Windows, macOS + and Linux, and /download offers a .dmg, an .exe, a .deb, an .rpm, a + .pacman, an AppImage and a Nix flake. The label is static, so it was + not adapting to the reader either: it simply told two of the three + platforms that the page's main action was not for them. */} Download @@ -60,219 +53,74 @@ export default function Home() { Read the docs
-

- OpenScreen is not production-grade. Expect rough edges while we build - in the open. -

-
-
-
-
- - - - - - - - Media - Edit - Rec - - - - Saved - -
+ {/* An affordance, not a claim. It said "the scrollbar is the timeline", + which is true and is still the wrong job for this line: what a + reader needs at the fold is to know there is more below, and a + sentence is a worse signal for that than an arrow. The design pins + it to the bottom of the screen with a down arrow beside it, which + is also what makes it read as an edge rather than as a caption. */} +

+ + Scroll down +

+ -
- + {/* The argument, immediately after the hero. */} + -
-
-
- 00:00:12.4 -
-
- - - - - - - 0:12.4 / - 7:03.6 - -
-
-
-
- 0:00 - 1:00 - 2:00 -
-
- - 1.8× - - - Note - -
-
- - 0:12.0 - -
-
-
-
-
-
-
-
-
-
+ {/* The claims the editor cannot make on its own. */} +
-
Why OpenScreen
+
Also true
+ {/* Capabilities are the section above; these three are properties, + and no screenshot of the application can establish any of them — + which is why they get one repeated tick instead of three + illustrations pretending to show something. */} - Recorder-first. AI, if you want it. + Three things a screenshot can't show. -
-
-
- - - -

A real multi-track editor

-
-

- Trim, split, and layer clips on a proper timeline. Captions, cursor smoothing, - zooms, and webcam picture-in-picture. -

-
-
-
-
- {MINI_WAVEFORM_A.map((h, i) => ( - - ))} -
-
-
-
- {MINI_WAVEFORM_B.map((h, i) => ( - - ))} -
-
-
-
- - - -
-
-
- Zoom - 1.5× - Caption -
-
- -
- - - -

AI, opt-in

+
+
+ +

MIT, free forever

- Whisper runs locally for captions — no upload, no cloud. Chat-based editing with - your own LLM key is there if you want it, off unless you connect a provider. + No paywalls, no premium tier, no usage caps. Every feature ships free for personal + and commercial use.

-
- - - -

Native capture

+
+ +

Nothing is uploaded

- ScreenCaptureKit on macOS, Windows Graphics Capture on Windows. No Electron - screen-grab hacks. + Recording, transcription and rendering all happen on your machine, and your video + never leaves it. Text leaves only when you ask: the chat panel and caption + translation, each with a key you supply. Transcription downloads its 264 MB Whisper + model once, on first run.

-
- - - -

MIT, free forever

+
+ +

Windows, macOS, Linux

- No paywalls, no premium tier, no usage caps. Every feature ships free for personal - and commercial use. + One source tree, native capture on each. A .dmg, an .exe, a .deb, a .rpm, a .pacman, + an AppImage and a Nix flake.

- -
- - - -
-

Captions in 13 languages, on-device

-

- Automatic transcription with local Whisper — no upload, works offline. Arabic, - English, Spanish, French, Italian, Japanese, Korean, Portuguese, Russian, Turkish, - Vietnamese, and both Chinese scripts. -

-
-
-
+
Quick start
- + Download and install diff --git a/website/static/img/cursors/00-arrow.png b/website/static/img/cursors/00-arrow.png new file mode 100644 index 000000000..85998081e Binary files /dev/null and b/website/static/img/cursors/00-arrow.png differ diff --git a/website/static/img/cursors/01-arrow.png b/website/static/img/cursors/01-arrow.png new file mode 100644 index 000000000..69ecd5b19 Binary files /dev/null and b/website/static/img/cursors/01-arrow.png differ diff --git a/website/static/img/cursors/02-arrow.png b/website/static/img/cursors/02-arrow.png new file mode 100644 index 000000000..51889625d Binary files /dev/null and b/website/static/img/cursors/02-arrow.png differ diff --git a/website/static/img/cursors/03-arrow.png b/website/static/img/cursors/03-arrow.png new file mode 100644 index 000000000..adfcec8dc Binary files /dev/null and b/website/static/img/cursors/03-arrow.png differ diff --git a/website/static/img/cursors/04-arrow.png b/website/static/img/cursors/04-arrow.png new file mode 100644 index 000000000..84e9a3b34 Binary files /dev/null and b/website/static/img/cursors/04-arrow.png differ diff --git a/website/static/img/cursors/05-arrow.png b/website/static/img/cursors/05-arrow.png new file mode 100644 index 000000000..efb4b7708 Binary files /dev/null and b/website/static/img/cursors/05-arrow.png differ diff --git a/website/static/img/cursors/06-arrow.png b/website/static/img/cursors/06-arrow.png new file mode 100644 index 000000000..143076372 Binary files /dev/null and b/website/static/img/cursors/06-arrow.png differ diff --git a/website/static/img/cursors/07-arrow.png b/website/static/img/cursors/07-arrow.png new file mode 100644 index 000000000..6fe7e89d6 Binary files /dev/null and b/website/static/img/cursors/07-arrow.png differ diff --git a/website/static/img/cursors/08-arrow.png b/website/static/img/cursors/08-arrow.png new file mode 100644 index 000000000..a55875df9 Binary files /dev/null and b/website/static/img/cursors/08-arrow.png differ diff --git a/website/static/img/cursors/09-arrow.png b/website/static/img/cursors/09-arrow.png new file mode 100644 index 000000000..4dcccb360 Binary files /dev/null and b/website/static/img/cursors/09-arrow.png differ diff --git a/website/static/img/cursors/mac-pointer.png b/website/static/img/cursors/mac-pointer.png new file mode 100644 index 000000000..a89d388bf Binary files /dev/null and b/website/static/img/cursors/mac-pointer.png differ diff --git a/website/static/img/cursors/mac-text.png b/website/static/img/cursors/mac-text.png new file mode 100644 index 000000000..27a5e5ef9 Binary files /dev/null and b/website/static/img/cursors/mac-text.png differ diff --git a/website/static/img/walkthrough/canvas-bg-1.jpg b/website/static/img/walkthrough/canvas-bg-1.jpg new file mode 100644 index 000000000..6c17fff37 Binary files /dev/null and b/website/static/img/walkthrough/canvas-bg-1.jpg differ diff --git a/website/static/img/walkthrough/canvas-bg-2.jpg b/website/static/img/walkthrough/canvas-bg-2.jpg new file mode 100644 index 000000000..eb34f7a39 Binary files /dev/null and b/website/static/img/walkthrough/canvas-bg-2.jpg differ diff --git a/website/static/img/walkthrough/canvas-bg-3.jpg b/website/static/img/walkthrough/canvas-bg-3.jpg new file mode 100644 index 000000000..6ca55d725 Binary files /dev/null and b/website/static/img/walkthrough/canvas-bg-3.jpg differ diff --git a/website/static/img/walkthrough/canvas-bg-4.jpg b/website/static/img/walkthrough/canvas-bg-4.jpg new file mode 100644 index 000000000..958b4b2f4 Binary files /dev/null and b/website/static/img/walkthrough/canvas-bg-4.jpg differ diff --git a/website/static/img/walkthrough/canvas-poster.jpg b/website/static/img/walkthrough/canvas-poster.jpg new file mode 100644 index 000000000..4e3a4d68b Binary files /dev/null and b/website/static/img/walkthrough/canvas-poster.jpg differ diff --git a/website/static/img/walkthrough/webcam-poster.jpg b/website/static/img/walkthrough/webcam-poster.jpg new file mode 100644 index 000000000..e6e143f40 Binary files /dev/null and b/website/static/img/walkthrough/webcam-poster.jpg differ diff --git a/website/static/img/walkthrough/wp-01.jpg b/website/static/img/walkthrough/wp-01.jpg new file mode 100644 index 000000000..3b807fdfd Binary files /dev/null and b/website/static/img/walkthrough/wp-01.jpg differ diff --git a/website/static/img/walkthrough/wp-02.jpg b/website/static/img/walkthrough/wp-02.jpg new file mode 100644 index 000000000..371cc69a6 Binary files /dev/null and b/website/static/img/walkthrough/wp-02.jpg differ diff --git a/website/static/img/walkthrough/wp-04.jpg b/website/static/img/walkthrough/wp-04.jpg new file mode 100644 index 000000000..169dd84b8 Binary files /dev/null and b/website/static/img/walkthrough/wp-04.jpg differ diff --git a/website/static/img/walkthrough/wp-05.jpg b/website/static/img/walkthrough/wp-05.jpg new file mode 100644 index 000000000..bc4e93b4a Binary files /dev/null and b/website/static/img/walkthrough/wp-05.jpg differ diff --git a/website/static/img/walkthrough/wp-06.jpg b/website/static/img/walkthrough/wp-06.jpg new file mode 100644 index 000000000..b11145bf4 Binary files /dev/null and b/website/static/img/walkthrough/wp-06.jpg differ diff --git a/website/static/img/walkthrough/wp-07.jpg b/website/static/img/walkthrough/wp-07.jpg new file mode 100644 index 000000000..0fd5b38bf Binary files /dev/null and b/website/static/img/walkthrough/wp-07.jpg differ diff --git a/website/static/img/walkthrough/wp-08.jpg b/website/static/img/walkthrough/wp-08.jpg new file mode 100644 index 000000000..7380ffbb9 Binary files /dev/null and b/website/static/img/walkthrough/wp-08.jpg differ diff --git a/website/static/img/walkthrough/wp-09.jpg b/website/static/img/walkthrough/wp-09.jpg new file mode 100644 index 000000000..baa49c974 Binary files /dev/null and b/website/static/img/walkthrough/wp-09.jpg differ diff --git a/website/static/img/walkthrough/wp-10.jpg b/website/static/img/walkthrough/wp-10.jpg new file mode 100644 index 000000000..67f011b61 Binary files /dev/null and b/website/static/img/walkthrough/wp-10.jpg differ diff --git a/website/static/img/walkthrough/wp-11.jpg b/website/static/img/walkthrough/wp-11.jpg new file mode 100644 index 000000000..3ddae52c0 Binary files /dev/null and b/website/static/img/walkthrough/wp-11.jpg differ diff --git a/website/static/img/walkthrough/wp-12.jpg b/website/static/img/walkthrough/wp-12.jpg new file mode 100644 index 000000000..fcb82f393 Binary files /dev/null and b/website/static/img/walkthrough/wp-12.jpg differ diff --git a/website/static/img/walkthrough/wp-13.jpg b/website/static/img/walkthrough/wp-13.jpg new file mode 100644 index 000000000..cfeb50f66 Binary files /dev/null and b/website/static/img/walkthrough/wp-13.jpg differ diff --git a/website/static/video/webcam.mp4 b/website/static/video/webcam.mp4 new file mode 100644 index 000000000..64edc1747 Binary files /dev/null and b/website/static/video/webcam.mp4 differ