# vid.cat > vid.cat is a browser video editor (WebCodecs, runs fully client-side). AI agents can drive the entire editor from JavaScript through `window.vidcat` — no screenshots, pixel coordinates or mouse clicks are needed. - Landing page: https://vid.cat/ (open beta) - Editor: https://vid.cat/editor/ (public beta; no sign-in) ## How to connect Run JavaScript in the editor tab by any of these: - Chrome DevTools console (or the DevTools MCP `evaluate_script` tool). - Playwright / Puppeteer: `await page.evaluate(() => vidcat.state())`. - Raw CDP: `Runtime.evaluate` with `awaitPromise: true, returnByValue: true`. - Computer-use agents with only a keyboard: open DevTools (⌥⌘J / Ctrl+Shift+J), type commands in the console. Commands return detached data snapshots (or a Promise of them); commands with no result return undefined. Invalid edit parameters throw descriptive errors (export parameters use the typed error documented below). ## First steps ```js await vidcat.ready(); // engine + media loaded vidcat.help(); // every command with its signature vidcat.state(); // project, sequence, playhead, tracks, selection, open dialogs vidcat.clips(); // clips with ids, tracks, start/end, effects, keyframed params vidcat.assets(); // library media ids for place()/append() vidcat.catalog(); // effects, transitions, title presets/styles/animations, eases, blend modes, export presets ``` ## Conventions - Times: seconds (`12.5`), timecode (`"00:00:12:15"`, `"0:12"`) or frames relative to the playhead (`"+10"`, `"-1"`). Edits snap to frames. - Tracks: id (`"t_v1"`) or label (`"V1"`, `"A2"`) or name. - Clip-id arguments accept one id or an array; when omitted they use the current selection. - Edits use undo history (`vidcat.undo()`), re-render the UI and autosave. Composite commands such as append, voiceover and scriptToVideo can create several undo steps. - Linked video/audio pairs move, trim, split and delete together. ## Commands Inspect: `state()`, `clips({ track?, at?, selected? })`, `clip(id)`, `assets()`, `project()`, `catalog()`, `projects()` Transport: `seek(time)`, `play()`, `pause()`, `nextEdit(1 | -1)`, `snapshot()` → PNG data URL Selection: `select(ids, { add? })`, `selectAt(time?, track?)`, `selectAll()` Timeline edits: - `place(assetId, { track?, at?, mode: 'overwrite' | 'insert' })`, `append(assetIds)` - `split(time?, ids?)` → new right-hand clips - `delete(ids?, { ripple? })`, `closeGap(track, time)` - `move(id, { start?, by?, track? })`, `trim(id, 'left' | 'right', deltaSeconds, { ripple? })` - `duplicate(ids?)`, `copy(ids?)`, `paste(time?)`, `speed(ids, rate)`, `enable(ids, on)` (pass `undefined` for ids to use the selection), `link(videoId, audioId)`, `unlink(ids?)` Properties (editable appearance/content leaves of `clip(id)`; identity, timing, links and collections use dedicated commands): - `set(ids, path, value, { at? })` — e.g. `set(id, 'transform.scale', 120)`, `'transform.x'`, `'crop.left'`, `'compositing.opacity'`, `'compositing.blend'`, `'color.exposure'`, `'color.saturation'`, `'audio.gain'` (dB), `'audio.fadeIn'`, `'title.text'`, `'title.color'`, `'name'`. Values are type/range checked; unknown paths are rejected. If the param has keyframes, a frame-snapped key is written at `at` (default playhead). - `keyframe(id, path, { at?, value?, ease? })`, `removeKeyframes(id, path, at?)` - `addTitle(text, { at?, duration?, track?, preset?, font?, size?, color?, x?, y?, style?, accent?, italic?, animation? })` — presets: Headline, Lower Third, Subtitle, Social Caption, Highlight, Neon, Cinematic, Typewriter, Word by Word, Karaoke, End Card, Chapter; styles: plain, box, highlight, outline, shadow, neon, bar (progress bar); animations: none, fade, rise, drop, slide, pop, typewriter, words, karaoke - `transition(id, type | null, duration?)` — crossfade, dipBlack, dipWhite, wipeLeft, wipeRight, wipeUp, wipeDown, iris, slideLeft, slideRight, zoom, zoomOut, flash - `addEffect(id, type, params?)`, `setEffect(id, fxId, { enabled?, params? })`, `removeEffect(id, fxId)` Tracks & range: `track(ref, { hidden?, locked?, muted?, solo?, gain?, pan?, name? })`, `addTrack('video' | 'title' | 'audio')`, `deleteTrack(ref)`, `marker(time?, name?)`, `removeMarker(id)`, `inOut(in, out)` (null/omitted clears that endpoint; out must not precede in) Media & output: - `importUrl(url, name?)` — same-origin, `blob:`, `data:`, or an allowlisted KLIPY CDN URL; other origins remain blocked by CSP. Prefer `memes.insert()` for KLIPY results. `importFiles(files)` accepts `File` objects. - `memes.search(query?, { type?: 'gifs' | 'clips' | 'stickers', page? })` — search KLIPY with the test key saved in this browser under `vidcat.klipy.key`; an empty query returns trending results. Returns normalized MP4-capable items plus `page`, `perPage`, and `hasNext`. The test key is sent directly to KLIPY in the URL path and is not project data. - `memes.insert(result, { track?, at?, mode? })` — import a result returned by `memes.search()` through the normal MP4/OPFS media pipeline, then place it on the timeline. `mode` is `overwrite` (default) or `insert`. - `export({ preset: 'hd', fps?, quality?, range: 'all' | 'inout', loudness?, filename?, download?, fileHandle?, hardwareAcceleration?, onProgress?, signal? })` → `{ filename, bytes, url, renderSeconds, durationSeconds, seconds, options, loudness, warnings, fileState }`. `quality` is a numeric bitrate multiplier **0.1–4** (default 1; UI Medium .6, High 1, Maximum 1.6), `fps` 1–240, `loudness` -70..0 LUFS (-14 YouTube, -16 podcasts). Existing matching filename extensions are retained. `renderSeconds` is elapsed export/measurement time; `durationSeconds` is the exported timeline range; `seconds` is a **deprecated alias for renderSeconds**. - Invalid export params throw `ExportParameterError` (`code: 'INVALID_EXPORT_PARAMETER'`, `parameter` identifies the field). Unknown fields are rejected. Start `const job = vidcat.export({ download: false })`, poll `exportStatus()`, call `cancelExport()` to cancel, then await `job`. `signal: AbortSignal` also cancels. The dialog and API share this service, including publish-package renders. - `fileHandle?: FileSystemFileHandle` streams directly to a previously chosen file (obtain the handle in a user gesture with `showSaveFilePicker`). `hardwareAcceleration?: 'prefer-hardware' | 'no-preference'`. The existing export dialog still asks for the destination before encoding. Without a supplied handle, output streams to temporary OPFS and returns a disk-backed URL; absent picker support shows a localized quota/download warning. No whole-file ArrayBuffer is created. Disk quota/write failures do not silently switch to RAM. - `exportStatus()` returns `state` (idle/running/complete/cancelled/error/interrupted), `phase` (prepare/render/finalize/measure), `stage`, phase-local `progress`, `fps`, `eta` (seconds remaining in this phase), `elapsedSeconds`, `bytesWritten`, `peakWriteBytes`, `peakWriteQueue`, `peakAudioPackets`, `fileState`, `warnings`, `error?`. Heartbeats continue while awaiting slow frames. Rates include elapsed stalls and use no 32-bit time counters; ETA is an estimate, not a total including unstarted phases. Each operation has a 120-second no-progress watchdog, not a total-render deadline. `onProgress` includes output loudness measurement. - Cancellation before commit rejects with `AbortError`: the dedicated worker is terminated, its codecs/GPU resources released, the file transaction aborted, and temporary output removed. Existing destination content stays unchanged; a newly picked file can remain empty. `fileState: 'discarded'` confirms cleanup; `'cleanup-unconfirmed'` explicitly reports a browser/filesystem cleanup failure. Once atomic close begins a successful commit wins over cancellation. Cancelling subsequent loudness measurement of a chosen destination returns `fileState: 'complete'`, empty `url`, and a warning; `loudness` can be absent in that case. An existing completed destination is never deleted. - API/download URLs expire after five minutes; consume them promptly or `await vidcat.releaseExport(url)` to revoke and remove temporary disk storage earlier. Cancelled jobs never create a download URL. A same-tab session receipt records the last phase/progress/bytes and reports `interrupted` after reload, without claiming the unfinished file is playable or resumable. No resume after reload, salvage, checkpointing, or automatic retry. Restart explicitly. Browser/OS native allocations and small container seek indexes are outside queue-byte accounting. - Export loudness is measured from the **decoded encoded file**, including codec overshoot: `{ integratedLufs, truePeakDbtp, targetLufs?, targetReached, gainCapped, measurement: 'decoded-output' }`. Silence has null levels; without a target `targetReached` is null. True peak uses 4× windowed-sinc reconstruction (an estimate, not a certified meter). Normalization is constant gain capped at -1 dBTP, not limiting/compression. Targets missed by more than 0.5 LU produce `warnings`; a capped gain is explicitly identified. The export dialog retains the measured result and warnings until closed. - `ai('silence' | 'beats' | 'reframe' | 'captions' | 'broll')` — captions opens a dialog: then `ui.click('Generate captions')` (the speech model runs on-device; minutes on CPU) - `voiceover(text, { voice?, speed?, at? })` — on-device narration (Piper TTS) on the Dialogue track; voices: tr-dfki, en-ryan, en-lessac, en-amy, en-alba. Returns sentence timings. - `scriptToVideo({ script, voice?, speed?, format?: 'landscape' | 'vertical' | 'square', captions?: 'Subtitle' | 'Social Caption' | 'Karaoke' | 'Word by Word' | 'none', media?: 'visual' | 'images' | 'none', music?: assetId | 'auto' | 'none', musicVolume?, endCard?, name? })` — faceless video in a new sequence: narration, sentence-timed captions, one library picture per sentence, looped music, end card. Blank lines = longer pauses. - `duck({ amount?, fade? })` — music dips under speech found on the dialogue track (audio.gain keyframes). scriptToVideo ducks automatically. - `thumbnail({ at?, text?, accent?, align?: 'left' | 'center', download? })` — 1280×720 PNG data URL from a clean frame with big outlined text; *starred* words take the accent. - `youtube()` — { title, description, chapters: [{ time, t, name }], tags, transcript, thumbnailText? }; chapters come from markers (scriptToVideo adds one per paragraph). - `makeShort({ start?, end?, name? })` — range → new 1080×1920 sequence (pictures cover the frame, titles re-centered). Default: In/Out, else the marker section at the playhead; max 60 s. - `beatSync({ track?, tolerance? })` — move B-roll cuts onto beat markers (detects beats first). - `progressBar({ color?, accent?, size?, y? })` — bar along the bottom that fills over the edit. - `broll({ source?: 'visual' | 'images' | 'selected', assetIds?, group?: 'sentence' | 'caption', order?: 'match' | 'order' | 'shuffle', transition?, motion?, dryRun? })` — after captions, cover every sentence with a library image/video on a new B-Roll track (keyword match on file/folder names first). `dryRun: true` returns the plan only. - `sequence(id)`, `newSequence(name)`, `save()`, `undo()`, `redo()` - `newProject({ name?, format?, fps?, folder? })` — format `1920x1080` | `3840x2160` | `1080x1920` | `1080x1080`; reloads the page - `useFolder(path)`, `openFolder(path)`, `saveToFolder(path?)` — local project folders (see below) ## Local project folders Browsers abort the native folder picker under automation, so agents reach the disk through a small bridge that runs in the agent's own Node process (Playwright or Puppeteer). Only folders under `roots` are reachable. ```js // curl -O https://vid.cat/vidcat-fs-bridge.mjs import { installVidcatFs } from './vidcat-fs-bridge.mjs'; await installVidcatFs(page, { roots: ['/Users/me/Videos'] }); // before page.goto await page.goto('https://vid.cat/editor'); await page.evaluate(() => vidcat.newProject({ name: 'Trailer', folder: '/Users/me/Videos' })); // page reloads → await vidcat.ready(); the project autosaves to /Users/me/Videos/Trailer-xxxx.vidcat/ await page.evaluate(() => vidcat.openFolder('/Users/me/Videos/Trailer-xxxx.vidcat')); // reopen later ``` `useFolder(path)` answers the next folder picker, so the UI buttons (Projects → Open folder…, New project → Choose a folder) also work. Install the bridge on every page/context that should reconnect to a folder project. UI modes: `workspace('edit' | 'color' | 'audio' | 'ai')`, `panel({ library?: 'library' | 'memes' | 'effects' | 'titles' | 'transitions', inspector? })`, `tool('select' | 'ripple' | 'razor' | 'hand')`, `view({ pxPerSec?, fit?, snapping?, quality? })` ## Anything else in the UI Dialogs, menus and forms without a dedicated command are reachable by label: ```js vidcat.ui.controls('project'); // visible controls [{ i, label, role, region, value, options }] await vidcat.ui.click('Projects'); // by label, index from controls(), or CSS selector await vidcat.ui.click('New project'); await vidcat.ui.fill('Project name', 'Trailer'); await vidcat.ui.fill('Format', '1080x1920'); await vidcat.ui.submit(); // primary button of the open dialog vidcat.ui.overlay(); // open modal text, menu items, toast await vidcat.ui.key('k', { meta: true }); // any keyboard shortcut (press "?" in the editor for the list) await vidcat.ui.close(); ``` Opening or creating a project reloads the page: wait for it, then `await vidcat.ready()` again. The native file picker cannot be scripted — use `importUrl`/`importFiles` and `export()`; for folders use the bridge above. ## Example ```js await vidcat.ready(); const [v] = vidcat.clips({ track: 'V1', at: 10 }); vidcat.split(12, v.id); vidcat.set(v.id, 'color.saturation', -40); vidcat.keyframe(v.id, 'transform.scale', { at: v.start, value: 100 }); vidcat.keyframe(v.id, 'transform.scale', { at: 12, value: 115, ease: 'easeInOut' }); vidcat.addTitle('Day one', { at: v.start, duration: 3, animation: 'rise' }); await vidcat.export({ preset: 'social' }); ``` ## YouTube format templates and channel kit Open **Templates** in the top bar, or **AI Tools → Template Gallery** (`ai('templates')`). ```js vidcat.templates.list(); // id/version/name, ratios, required inputs const format = vidcat.templates.get('top-n'); // full JSON + complete sample EpisodeInput const input = format.sample; // also: 'quiz', 'explained' input.ratio = '9:16'; // '16:9' | '9:16' input.data.thesis = 'My original argument…'; // required in EVERY format vidcat.templates.validate('top-n', input); // { valid, errors: [{ path, message }] } const preview = vidcat.templates.plan('top-n', input); // resolved scenes, warnings, estimatedDuration await vidcat.templates.apply(preview); // measured narration → NEW sequence, ONE undo step // Equivalent: await vidcat.templates.apply('top-n', input) const project = vidcat.project(); const instance = project.sequences[project.activeSequenceId].templateInstance; await vidcat.templates.updateScene(instance.scenes[2].id, { text: 'A revised rank card', narration: 'My revised explanation.', media: 'asset-id' }); vidcat.channel.get(); vidcat.channel.set({ name: 'My channel', voice: 'en-amy', speed: 1, accent: '#ffd400', textColor: '#ffffff', font: 'Inter', titlePreset: 'Headline', musicAsset: '', introAsset: '', outroAsset: '', cta: 'What is your perspective?', descriptionSignature: 'More from my channel…', pronunciation: { NASA: 'nassa' } }); await vidcat.youtube(); // template title/description/tags + sources + thumbnailText await vidcat.thumbnail(); // defaults to template thumbnail text and channel accent ``` - EpisodeInput is `{ ratio, data, overrides? }`. `data` follows the format's `inputs` schema; `overrides` is a partial ChannelProfile. Priority: format defaults → saved channel kit → episode overrides. - Preview does not generate audio or mutate the project. It warns about missing media and likely text overflow. Build generates Piper speech on-device, measures PCM durations, and imports the finished scene audio before one timeline commit. A first voice use downloads its model. No stock search or cloud writing service is used. - Missing repeated-item `id`s are generated automatically in `plan().inputs`; reuse those normalized inputs to preserve identity. Supplied IDs remain supported and must be unique. IDs are hidden in creator forms and CSV mapping. Scene IDs are `blockId:itemId`; fixed scenes use `blockId`. Inspect `templateInstance.scenes` and `sceneClips` for timings and editable clip IDs. Each scene has a chapter marker; YouTube output keeps the existing minimum chapter spacing rules. - `updateScene` accepts `text`, `narration`, `query`, `media`, `sources` (newline-separated URLs), `sourceLabel`, `answerText`, `answerNarration`, `countdown` (whole seconds), `minDuration`. Visual-only edits preserve audio/captions, duration, later scenes and manual styling, changing only the requested visual properties. Speech/timing edits regenerate the scene and shift later clips/markers/range endpoints. Narration is cached by original text, voice, speed and pronunciation dictionary in asset metadata, including measured duration and sentences; cache survives reload. Unreferenced generated narration is removed from the live library across all sequences; backing files are retained for undo/redo. Music is one continuous bed rebuilt only when timing changes. - A project edit during an asynchronous build aborts the commit; retry against the updated project. Concurrent template builds are rejected. Synthesis/import failure leaves the project unchanged. Undo/redo includes generated asset references; media remains cached for redo. - Channel kits are stored with project JSON and reused through localStorage. Asset IDs are project-local: select replacements when a reused kit reports missing media. `channel.set` replaces the pronunciation dictionary when supplied. Dictionary substitutions are literal, case-sensitive whole-word matches, applied only before narration. - The UI generates fields from the schema and supports EpisodeInput JSON and repeated-item CSV with column mapping (one row per item; `|` separates source URLs). It also provides **Edit current scenes** for regeneration. New formats: `docs/template-format.md` and `editor/src/templates/gallery/*.json`. ## Capabilities, Lite mode and import progress Core editor support: desktop Chrome/Edge 110+, HTTPS/localhost, WebCodecs (including H.264 1080p decode and AAC or Opus encode), WebGL2, OffscreenCanvas, workers and OPFS. A localized unsupported page appears **before** the heavy bundle; `window.vidcat` is intentionally absent there. File System Access pickers, WebGPU and JSPI are optional; Whisper GPU inference needs JSPI (137+) and WebGPU. - `vidcat.capabilities()` returns the one startup probe: codec/profile support, hardware decode hint, graphics/texture limit, memory/cores, storage/worker/AI capabilities, `tier`, `reasons`, `failures` and minimum browser version. - `vidcat.performance()` reads the saved mode and effective Lite flag. `performance('auto' | 'lite' | 'full')` saves an override; reload to apply it. Required APIs cannot be overridden. UI equivalent: Account → Performance settings. - Lite mode reduces preview, decoder/cache budgets and uses available proxies. Captions, stabilization, realtime scopes and program effects preview are gated. Narration remains available in Lite, with a slower-operation notice. Originals are retained for export. Proxies cannot decode an otherwise unsupported source codec. - `const job = vidcat.importFiles(files)` starts a queued batch. Poll `vidcat.importStatus()` for `{ active, completed, total, fraction, canceled }`; `vidcat.cancelImport()` cancels the active batch **after the current file** and preserves completed files. `await job` returns that batch's assets. Import is one undo step; unsupported assets have `warning` in `vidcat.assets()`. - `await vidcat.diagnostics()` reports thumbnail bytes/pending jobs and each worker's bounded image cache, open inputs and resource budgets. These are owned-cache counters, not total process/GPU memory. ## Interface language `vidcat.locale()` returns `en`, `tr`, or `ca`. `vidcat.locale('ca')` (also `en`/`tr`) saves the preference under `vidcat.locale` in localStorage and reloads the editor to recreate all UI labels without duplicating engine listeners. Wait for the page to reload and `vidcat.ready()` again. Detection is saved preference → the first supported entry in `navigator.languages` → English. Existing project data, IDs, command names and this guide are unchanged. The top-bar language selector uses the same command. Public landing and 404 pages negotiate `Accept-Language` (including quality weights); `?lang=en|tr|ca` overrides it and works without JavaScript. Forms and redirects preserve that language. Built-in template samples follow the interface language. Catalan samples use `overrides.voice: 'none'`: this creates editable scenes with estimated reading time, without synthesizing speech. Import a recorded Catalan narration afterwards. `channel.set({ voice: 'none' })` also disables template narration. Piper offers English and Turkish only; there is no Catalan voice. Whisper captions support Catalan (`catalan`) independently of narration. Workflow modes (UI preferences, never project edits) -------------------------------------------------- `vidcat.mode()` returns `vidcat | social | pro | grade`; `vidcat.mode('social')` applies that mode and returns its ID immediately. No reload, project mutation, undo entry, selection change or performance override. `catalog().modes` lists every mode's layout, tab/workspace order, defaults and complete `shortcuts` map alongside `catalog().workspaces` (the active order). `state().ui` reports the active mode, actual layout, timeline display heights and editing defaults. First launch offers Workflow modes once; closing keeps the current mode. Reopen via Account → Workflow modes. Preferences use `vidcat.modes.v1.admin` in localStorage: the key retains a legacy admin suffix, although the editor is public. They are browser-local, not embedded in project files or synchronized. Switching applies the chosen defaults; resizing panels is remembered for that mode on reload. Existing titles, transitions, track data and sequence dimensions are untouched. Defaults affect subsequent title/transition creation; explicit API options take precedence. - vidcat: original workspace/tab order with Memes after Library; Edit first; 390/292 px side panels, 58% upper; selection tool; existing project track heights, 9.2 px/s timeline; original plain title defaults, 1 s transitions. Compositing/stabilization/color/audio collapsed. - social: Edit → Media → AI → Audio → Color → Export; Library → Memes → Titles → Transitions → Effects; 430/260 px panels, 68% upper; selection; 36 px tracks, 24 px/s; Social Caption titles, 0.3 s transitions. Portrait preview area (letterboxes the unchanged sequence), meters hidden, advanced timeline buttons hidden; crop/compositing/stabilization/color/audio collapsed. - pro: Media → Edit → Audio → Color → AI → Export, Edit opens first; 370/292 px panels, 52% upper; source thumbnail scrubber above the library beside the program monitor; ripple tool; 56 px tracks, 12 px/s; Lower Third titles, 1 s transitions. Stabilization/color/audio collapsed. Source preview is a scrubber, not a separate playback engine; I/O and JKL operate the sequence. - grade: Color → Edit → Media → Audio → AI → Export; Effects library tab first; Inspector → Metadata → Effects; 350/340 px panels, 70% upper; docked scopes above the library/mixer; selection; 40 px tracks, 10 px/s; Cinematic titles, 0.5 s transitions. Transform/crop/compositing/stabilization/audio collapsed; color expanded. Lite mode still pauses scopes. All modes enable snapping initially. Collapsed inspector sections can be expanded normally. Hidden advanced timeline controls remain available from keys/API. Shared styling/icons stay vid.cat's own. Complete keyboard maps: Mod = Command on macOS or Ctrl on Windows/Linux; shortcuts do not fire while typing or in a dialog. `?` / Account → Keyboard shortcuts generates the ACTIVE map from the definition. All modes inherit every common operation below; the per-mode substitutions replace only their listed bindings. `vidcat.ui.key('b', { ctrl: true })` sends a chord; inspect the active map before choosing one. Common map: - Space play/pause; J reverse shuttle, K stop, L forward shuttle (repeated J/L increase speed up to 4×). - I/O mark sequence in/out; Alt+X clears both. M marker; T title; Shift+E toggle selected clip enabled. - V selection, B ripple, C razor, H hand; S toggle snapping. - Mod+K or Mod+B split at playhead; Delete/Backspace delete; Shift+Delete/Shift+Backspace ripple delete. - Mod+Z undo; Mod+Shift+Z or Mod+Y redo; Mod+C/X/V/D copy/cut/paste/duplicate; Mod+A select all. - ArrowLeft/Right step one frame, Shift+ArrowLeft/Right ten frames; Alt+ArrowLeft/Right nudge selected clips one frame, Alt+Shift+ArrowLeft/Right ten frames. - ArrowUp/Down previous/next edit; Home/End sequence start/end; Escape deselect. - = or + zoom in; - or _ zoom out; Shift+Z or backslash zoom to fit. - Q/W ripple-trim selected clip start/end to playhead (playhead must be inside the clip; linked/locked-track rules apply). - Mod+Alt+V/A/T add video/audio/title track; Shift+K curve editor; Mod+I import; Mod+E export; Mod+S save; ? shortcut reference. Per-mode maps (all other common bindings above retained): - vidcat: common map unchanged. - social: split Mod+B only; razor R; snap N. B remains ripple and V remains selection. - pro: split Mod+K only; zoom-to-fit backslash only. Q/W ripple trimming, JKL shuttle and V/B/C tools as common. - grade: selection A; ripple T; razor B; title Shift+T; snap N; split Mod+B only; zoom-to-fit Shift+Z only. Library previews: - `vidcat.previews()` inspects Effects/Transitions/Titles previews: visible tiles, `render` (`real` or `placeholder`), source (`selection`, `sequence`, `asset`, `neighbours`, `asset-pair`, `reference`), animation policy, render/cache-hit counters and bounded cache bytes. Use `panel({ library: 'effects' | 'transitions' | 'titles' })` to show them; only intersecting tiles render. DOM equivalents: `data-preview`, `data-preview-id`, `data-preview-kind`, `data-preview-source`, `data-preview-animated`. - Real previews use the program/export renderer in an isolated worker. Selection at playhead → sequence at playhead → first library video/image → bundled reference photo. Transitions use neighbouring clips, separated frames of a video, or reference photos. Titles show the preset at two seconds into a four-second clip. - Full mode uses up to 320×180 (160×90 CSS at up to 2× DPR), a 4 MiB/48-entry cache, and a 1.2-second transition animation on hover/focus. Reduced motion and Lite use static midpoint frames; Lite is 96×54 with a 512 KiB/12-entry cache. Work is serial, visibility-driven, canceled offscreen, and deferred during monitor work, playback and export. Import/edit/selection/seek changes invalidate source identity. Failures retain the styled placeholder. These tiny static effects previews remain available in Lite even though program-monitor effects are disabled there. Illustrative template artwork: - `templates.list()` / `templates.get(id)` include optional `cover: { src, alt }` from the format JSON. Covers are illustrative, not engine previews; UI alt text follows en/tr/ca. - `await vidcat.templates.importDemoFrames('16:9' | '9:16')` imports four original generated images through the normal engine/OPFS path in one undo step; repeated calls reuse live imports. Also available as **Import demo frames** in an empty media library. - `templates.apply` and `templates.updateScene` fetch/import a bundled frame only for scenes without usable matching media. Builds retain scene warnings and add an editable, localized **Generated placeholder art** label throughout each placeholder scene. `assets()` reports `bundledArtwork`; these assets are excluded from automatic semantic matching and persist with folder projects. Generated images are fictional scenery, never cited evidence. All gallery formats expose `hookText`/`hookMedia`, `angleText`/`angleMedia`, `outroText`/`outroMedia` and `outroNarration` before Build. Omitted display text falls back to the topic, angle heading or channel CTA; outro media falls back to the channel outro asset. Repeated items accept `sourceLabel`; without it, on-screen sources show domains. Full URLs remain in description/metadata. `keywords` supplies episode-specific tags alongside the topic; no unrelated default tags are added. Missing item IDs are generated in `plan().inputs` and hidden in forms; reuse normalized inputs for stable scene identity. `await vidcat.templates.calibrate('tr-dfki')` measures a short voice/language sample once and saves its character rate locally. Preview uses the cached calibration or a broad estimate without downloading a voice. Build calibrates automatically. Synchronous `plan()` returns `estimatedDurationRange: { min, max, calibrated }` and the central `estimatedDuration`; uncalibrated plans use a broad language-based range. Speed affects the estimate; this remains approximate, so Build measures actual speech. Silent Catalan scenes use reading-time estimates. `ui.close()` uses a stable modal attribute independent of translated labels. Caption tracks use semantic role/identity with legacy `t_cc`/`CC` fallback. The caption language select has the localized accessible name Language/Dil/Llengua. `thumbnail({ text: '*COOL SHADE*\nCOOL CITY' })` preserves explicit newlines and accents the marked phrase only, including when it wraps. Timeline interchange (native project remains the source of truth) --------------------------------------------------------------- Projects → Export for Premiere / DaVinci / Final Cut… exports the active sequence; Import timeline… reads an XML file and an optional media folder into a NEW project after a review report. await vidcat.exportTimeline({ format: 'fcpxml', download: false }) // format: 'fcpxml' (1.10, Resolve/Final Cut), 'xmeml' (FCP7 .xml, Premiere/Resolve), 'edl' (CMX3600), 'otio'. // Returns { format, filename, content, warnings: [{ code, message, clipId?, assetId? }], lossy, savedFile? }. // Options: sequenceId?, folderPath?, dropFrame?, download?, saveToFolder?. // Default: save a uniquely named export + JSON report beside project.vidcat.json in folder mode; download in browser-only mode. // download:false returns content without writing unless saveToFolder:true. download:true also downloads. await vidcat.exportTimeline({ format: 'xmeml', folderPath: '/Volumes/Media/Film.vidcat', saveToFolder: true, download: false }) References come from the actual saved folder media manifest, with URL-encoded names. Save to a folder first (saveToFolder); OPFS/blob-only sources produce MEDIA_OFFLINE warnings. XMEML requires the saved project folder's absolute path; the vidcatFs bridge supplies it automatically. FCPXML/OTIO relative media paths require the export to stay beside project.vidcat.json. Browser pickers do not reveal absolute paths. Optional folderPath accepts an absolute POSIX/Windows path or local file:/// URL. await vidcat.importTimeline(xmlText, { folder: '/Volumes/Media/Film.vidcat', open: false }) // Returns { project, warnings, offline: assetIds[] }; open:false previews without switching projects. await vidcat.importTimeline(xmlText, { files: originalFiles }) // Default open:true saves the current project, opens the imported project and reloads; await ready() again. // folder: directory handle or local path using the vidcatFs bridge; files: File[] (webkitRelativePath used when available). // FCPXML 1.10/1.11 and basic XMEML import; first sequence only. Original sources are copied to the working cache. // Exact path, most-specific suffix, then unique basename matching. Missing/ambiguous files remain offline and visible with warnings. // XML URLs are descriptive only, never fetched. External/custom DTDs/entities, malformed XML and unsupported formats are rejected. FCPXML maps cuts, track lanes, source in/out, constant speed/reverse, transform, static trim crop, opacity, gain/pan/fades, and linear position/scale/rotation/opacity/gain/pan keyframes. Nonlinear easing, color/LUT/effects/stabilization, unsupported keys, titles, transitions and grouping are lossy and reported. Title text/timing becomes a placeholder (XMEML: marker). Rebuild crossfade/dip transitions in the receiving editor. XMEML is a cuts/constant-Time-Remap fallback with separate audio tracks; appearance/mixing is not mapped. Its import rejects transition-relative -1 boundaries: request cuts-only XML. Compound/multicam/generator content and speed ramps have explicit import warnings; freeze maps are rejected. NTSC aliases map to exact /1001 rates. Timeline endpoints and source points are snapped to their own frame grids and quantization is reported; XML uses reduced rational seconds, no cumulative duration rounding. dropFrame is only valid at 30000/1001 or 60000/1001 and changes labels only. Imported timecode origins and trailing gaps persist in the native project. EDL exports the lowest visible video track, no layers/audio mix; 24/25/30 and NTSC equivalents, at most 999 events, no overlapping cuts. Missing embedded source timecode is reported as zero-origin; M2 motion and mixed-rate support vary. OTIO is a bridge JSON format with external/missing references, tracks/gaps, and LinearTimeWarp; intrinsic effects remain unsupported metadata. Premiere's documented FCPXML workflow requires conversion, so choose xmeml for Premiere. CapCut native draft JSON and .prproj are not generated. For CapCut, render MP4 with vidcat.export() and import the video; an optional EDL is a manual reference, NOT an editable CapCut project. Keep the native project/media for future changes. Detailed contract, supported subset, source references and validation: docs/timeline-interchange.md in the repository. Real NLE acceptance/visual parity must be checked separately from XML schema validation. Async silence removal, beat detection, ducking, captions, voiceover and meme insertion validate their original project/sequence and source clips before committing. If the sequence changes during analysis, retry on the intended sequence. Deleting library media keeps backing originals available for undo/redo; history-aware physical reclamation is not performed by that action. ## Pacing inspector (local editorial review) Open the non-modal panel with `vidcat.pacing.open()` or AI Tools → Pacing inspector (also in More share options). It stays beside the timeline, with clickable timecodes and optional one-click caption fixes. ```js const review = await vidcat.pacing.scan({ profile: 'auto', narrationTrack: 'A1' }); vidcat.pacing.result(); // last report, including stale: true after edits vidcat.pacing.jump(review.findings[0].id); const fixable = review.findings.find(f => f.fix); if (fixable) await vidcat.pacing.fix(fixable.id); // one undo step, then rescan ``` Each finding returns `id`, `rule`, `severity` (`info | warning | error`), localized `explanation`, timeline seconds `time`/`end`, optional `clipId`, and optional `fix`. Scan never edits. Jump/fix reject a changed sequence; rescan first. Locked tracks and changes that require overwriting animation, overlapping captions or rippling narration have no automatic fix. Safe fixes extend a caption into available space or center/resize it inside the 5% safe margin. Undo restores the edit. Auto selects the saved template (`top-n`, `quiz`, `explained`), then workflow mode: social → social, pro/grade → explained, classic → general. Explicit profiles are `general | social | top-n | quiz | explained | ambient`. Ambient is an editorial review profile, not a new gallery format. Thresholds are conservative starting points, returned with every report; the product plan supplies qualitative guidance, not calibrated numeric limits: | Profile | Short shot (s) | Long shot (s) | Opening (s) | Silence (s) | Repeat gap (s) | Allowed uses | Caption chars/s | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | general | 2 | 15 | 8 | 3 | 12 | 4 | 17 | | social | 1 | 8 | 3 | 2 | 8 | 4 | 17 | | top-n | 2 | 12 | 5 | 3 | 15 | 3 | 17 | | quiz | 5 | 30 | 8 | 7 | 5 | 5 | 17 | | explained | 5 | 30 | 12 | 5 | 20 | 4 | 15 | | ambient | 60 | none | none | none | 0 | unlimited | 17 | Checks cover unusually long shots compared to neighbours, unmoving stills, repeated visuals, fast/slow runs of shots, caption reading time and approximate rendered bounds, delayed substantive openings, narration gaps and quiet stretches below −38 dBFS. Narration selection prefers Dialogue/Narration/Voice tracks and generated narration; specify a track id/label for imported recordings. Waveforms respect source trims, speed, reversal, gain, fades, mute and solo; unavailable audio is reported. Quiz countdown clips preserve intentional thinking time. Hidden/disabled visuals are excluded, top video layers determine shot boundaries, and long video shots use three low-resolution frame samples to flag possible freezes. Bounds follow wrapping, fonts and numeric keyframe samples; they are a review aid, not pixel-perfect collision proof. Intro/outro scene roles are excluded from hook detection; without a template, text and narration are heuristic evidence. No semantic model runs, and no score predicts real viewer retention. ## Publish package Open `vidcat.publish.open()` or AI Tools → Publish package / More share options → Publish package. Edit title, description, tags, thumbnail text and export preset, then Create publish package. It uses the current full sequence and existing video export, editable caption clips, 1280×720 thumbnail renderer and YouTube chapter/metadata rules. No media or script is uploaded. ```js const job = vidcat.publish.create({ filename: 'episode.zip', preset: 'hd', quality: 1, fps: 30, loudness: -14, thumbnail: { at: 2, text: '*NEW*\nPERSPECTIVE' }, metadata: { title: 'Episode title', description: 'Original description', tags: ['topic'] }, download: false, onProgress: state => console.log(state.phase, state.progress) }); vidcat.publish.status(); // state idle/running/complete/cancelled/error; phase; overall progress 0..1 // vidcat.publish.cancel(); // or supply signal: AbortSignal const bundle = await job; ``` ZIP contents: `video.mp4` (or `.webm` for that export preset), `subtitles.srt`, `subtitles.vtt`, `thumbnail.png`, `metadata.json`, `credits.json`. Subtitle text and timings come from existing enabled caption clips on visible caption tracks (semantic role, legacy `t_cc`/`CC`/Captions supported); other titles are excluded. Empty captions yield empty files plus a warning, not fabricated speech. Metadata includes title, description, tags, transcript and chapters derived from actual markers using existing YouTube rules (three chapters, first at zero, each ≥10s). Template scene sources are retained. Credits list used media and audio assets, known source/creator/license/attribution from optional `Asset.credit`, generated artwork status, and recorded Piper voice IDs/model-card links. New standalone voiceovers persist the chosen voice; template narration caches retain their actual voice. Unknown provenance stays null and is flagged; attribution is not permission. This is not a full license ledger. Result: `{ filename, bytes, url, savedFile?, files: [{ name, bytes }], warnings, metadata, renderSeconds }`. Default writes a uniquely named ZIP beside `project.vidcat.json` in folder mode, otherwise downloads one ZIP. `download:false` suppresses writes unless `saveToFolder:true`; `download:true` can additionally download when saving to a folder. `url` is a local temporary Blob URL, valid for five minutes; consume it promptly. The ZIP is streamed to local OPFS, then through the existing folder save/download path, with no video-sized JS buffer or new dependencies. ZIP32 supports packages below 4 GB; ZIP64, cloud upload, tab-reload resumption, automatic transcription and retention prediction are out of scope. Cancel during preparation/render/bundling/save removes unfinished output and rejects with `AbortError`. Already completed bundles are preserved; call create again to retry. Folder close is the atomic commit point. Sequence/media changes during preparation cancel the job so video, captions and metadata cannot silently come from different edits. Progress and cancellation survive closing/reopening the panel while the tab remains open; closing a panel does not cancel a job. ## Data-driven scene blocks AI Tools → Scene blocks, `vidcat.blocks.open()`, or `vidcat.ai('blocks')` opens a non-modal JSON editor with localized samples and a read-only timing preview. Blocks work in both 16:9 and 9:16 sequences. They compile to normal title clips and numeric keyframes; inspect/edit them with `clips`, `set`, `move`, `trim` and `keyframe`. There is no special renderer or flattened bitmap. ```js vidcat.blocks.catalog(); // type, localized name, fields, sample for each block vidcat.blocks.validate({ type: 'quiz', question: 'Closest planet to the Sun?', answer: 'Mercury' }); const data = { type: 'quiz', question: 'Closest planet to the Sun?', answer: 'Mercury', pacing: { thinkingSec: 7, revealSec: 2, holdSec: 3 } }; const preview = vidcat.blocks.plan(data); // duration, profile, pacing, channel, safeArea, layers with pixel boxes const result = vidcat.blocks.compile(data, { at: 20 }); // omitted at = playhead vidcat.blocks.get(result.block.id); vidcat.blocks.list(); vidcat.blocks.compile({ ...data, answer: 'Mercury — the innermost planet' }, { id: result.block.id }); // vidcat.blocks.remove(result.block.id); ``` Data contracts (optional `title` and `pacing` are shared): - Quiz: `{ type:'quiz', question, answer }`. Question appears first, a numbered countdown starts after `revealSec`, and localized answer chrome appears after `thinkingSec`. Answer stays for `holdSec`. - Ranking: `{ type:'ranking', entries:[{ label, value? }, ...] }`. 1–8 entries; numbers follow array order. Each entry appears `revealSec` after the previous entry. The final card stays for `holdSec`. Values are strings so units and locale-specific formatting remain yours. - Comparison: `{ type:'comparison', columns:['A','B',...], rows:[{label:'Cost', values:['$10','$20',...]}, ...] }`. 2–6 columns, 1–8 attributes, exactly one value per column. Wide tables paginate with repeated attribute labels: two data columns per portrait page, three per landscape page. Each page lasts `max(holdSec, rows.length * revealSec)`. - Timeline: `{ type:'timeline', events:[{date:'1969',label:'First landing',detail?}, ...] }`. 1–12 strictly increasing, valid ISO dates (`YYYY`, `YYYY-MM`, `YYYY-MM-DD`). A horizontal milestone strip advances using editable `title.x` cursor keyframes; each event gets `revealSec`, with `holdSec` extra on the last event. Milestones are evenly spaced for readable labels, not a proportional elapsed-time chart. Long strips paginate at three events in portrait and four in landscape. Pacing defaults resolve from the active template, then the workflow mode (social → social, pro/grade → explained, classic → general). General/top-n use thinking/reveal/hold seconds 5/2.5/3; social 3/1.5/2; quiz 5/2/3; explained 8/5/5; ambient 15/15/20. A quiz format's configured countdown overrides its thinking default. Explicit block pacing wins. Thinking accepts 1–60s, reveal .5–30s, hold 1–60s; all timings snap to the sequence frame rate. No universal cutting tempo is imposed. The active channel kit supplies font, accent and text colour; episode channel overrides win. `channel.set({ safeArea:{ left:.08, right:.08, top:.1, bottom:.2 } })` saves normalized frame margins (.05–.3 each) with the reusable kit. Without that setting, block defaults are left/right .08; landscape top .08/bottom .12; portrait top .1/bottom .2. Explicit line wrapping and measured cell fitting keep text inside these margins. Overfull text rejects before editing instead of silently truncating or becoming unreadably small. Schema field errors include paths; `plan` additionally checks actual layout fit. `compile` returns `{block, replaced, added, kept, unchanged}` with clip IDs. Recompilation uses `id`, keeps that block/marker identity, replaces only its owned clips, and preserves unrelated manual edits, even clips hand-added on a block-created track. Edits to the block's own clips are replaced when its data/style/timing changes; an identical compile is a no-op and preserves manual edits. Recompilation never ripples neighbours: review the returned duration before enlarging a block into another scene. Compilation, recompilation and removal each have one undo/redo entry; data and ownership persist in `Sequence.sceneBlocks` in browser/folder project saves. Locked affected tracks reject. Blocks provide visual structure; add narration separately or use the template episode workflow. ## Selective scene regeneration AI Tools → Regenerate scene, Template Gallery → Edit current scenes, `vidcat.scenes.open(sceneId?)` or `vidcat.ai('regenerate')` opens a non-modal scene editor. It edits scenes belonging to the current template instance; scene and chapter marker IDs remain stable. ```js const scenes = vidcat.scenes.list(); const id = scenes.find(s => s.id.includes(':')).id; const speech = await vidcat.scenes.regenerate(id, { narration: 'A revised spoken sentence.' }); const voice = await vidcat.scenes.regenerate(id, { voice: 'en-ryan', speed: 1.1 }); const visual = await vidcat.scenes.regenerate(id, { query: 'moon orbit' }); // Or select an exact existing library asset: // await vidcat.scenes.regenerate(id, { media: 'asset-id' }); await vidcat.scenes.regenerate(id, { text: 'Revised display title' }); ``` Dependency rules: - `narration` changes only question/main narration and its captions. Quiz `answerNarration` changes only answer narration and captions. `voice`/`speed` change both speech slots for that scene, with per-scene overrides saved in the instance. `voice:'none'` removes affected generated speech/captions. Caption timing uses the existing sentence/character-based caption service, not forced word alignment. - `text`/`answerText` change display text in place while retaining title clip IDs, manual styling and timing. They do not implicitly rewrite spoken words. - `query`/`media` replace only owned B-roll and generated placeholder chrome. A query searches the existing local B-roll service with match-only selection; no match rejects without changing the current visual. Empty `media` requests library matching from the current query or narration. Spoken text changes do not silently discard a deliberate visual/query choice; supply `query` in the same patch when visuals should change too. Every other clip, marker, music bed/ducking edit, in/out point and scene boundary stays exactly where it is. New speech must fit the existing scene slot; quiz thinking and answer slots remain fixed. Default `fit:'reject'` returns required/available durations without editing if speech is too long. Explicit `{fit:'speed'}` can time-fit playback up to 2×, returning a warning; this changes playback speed/pitch, not the synthesis model. Prefer shorter wording or an explicit synthesis `speed` when possible. Nothing truncates narration or silently ripples downstream edits. The legacy `templates.updateScene` and Template Gallery → Scene timing and sources remain available for deliberate scene retiming/ripple and source edits; they retain per-scene voice overrides. Returns `{sceneId, sequenceId, start, duration, unchanged, replaced, kept, warnings, summary}`. `replaced` contains narration/captions/broll/titles groups, each with `removed`, `added`, `updated` clip IDs; `kept` lists all untouched live clip IDs. The UI reports replacement/keep counts and timing warnings. A changed scene commits imported audio, clips and metadata in one undo step; repeated equal patches do nothing and add no undo entry. Generated audio uses the existing persistent speech cache. Undo retains backing audio files and restores previous scene data and voice overrides. Regeneration uses the shared analysis-target guard with a dependency read set. Unrelated manual edits can continue while synthesis/import runs and are merged into the live timeline; one undo then reverses regeneration while retaining those intervening edits. Edits to dependencies, affected tracks/assets, project/sequence switches, concurrent requests for the same scene and newly occupied output slots reject without a partial timeline. Failed jobs clean up their staged imports. Re-read the scene and retry after resolving a conflict. Music ducking is intentionally kept and flagged for review after speech changes; use the existing `duck` tool explicitly if desired. No cloud service, automatic stock download, whole-episode rebuild or forced alignment is introduced. ## Series and batch queue UI: AI → Series and batch queue (`vidcat.ai('series')`), also available from Templates. Every episode is its own ordinary, editable sequence in the current project. The project library and folder save keep the queue, channel snapshot, links, input rows, drafts and register. No export, upload or publication occurs on finalization. ```js const input = { templateId: 'top-n', name: 'Field notes', startNumber: 1, channel: { voice: 'en-amy', name: 'My channel' }, episodes: [episodeInput1, episodeInput2] // each: { ratio: '16:9', data: { ... } } }; const checked = vidcat.series.validate(input); // { valid, errors: [{ row, path, message }], inputs, channel } const created = vidcat.series.create(input); // invalid: all errors, no queue/synthesis; valid: { series, ... } const id = created.series.id; const job = vidcat.series.start(id); // keep this promise; sequential work in this open tab vidcat.series.status(id); // { running, paused, message, series: [...] } // vidcat.series.pause(); // finish the current episode, then stop await job; // failures don't discard later rows const episode = vidcat.series.status(id).series[0].episodes[0]; vidcat.series.open(id, episode.id); // open the editable draft; queue must be idle vidcat.series.finalize(id, episode.id); // only the currently open review draft becomes done // await vidcat.series.retry(id, failedEpisodeId); // retry that row only, leaving other pending rows alone ``` `episodes` accepts an array or JSON text containing EpisodeInput objects or plain template-input objects. Plain rows use `ratio` on the batch options (default `16:9`). For CSV use `{ templateId, episodes: csvText, format: 'csv', mapping: { topic: 'Topic', hook: 'Opening', thesis: 'Angle', items: 'Items' }, ratio?: '16:9' | '9:16', channel?, name?, startNumber? }`. Mapping keys are template input fields; values are CSV header names. Without mapping, headers must match input fields. Arrays and objects in a CSV cell must be JSON; RFC quoting supports commas, escaped quotes and multiline text. One data row is one entire episode, not one ranking item. Limit: 1–100 episodes per queue. Errors use 1-based data-row numbers; row 0 denotes the file/options as a whole. Structural CSV errors that prevent parsing are reported at file level. All recoverable row/field errors are collected before any generation. States: `pending`, `running`, `review`, `done`, `failed`; each row includes `progress` (0–1), `attempts`, `error?`, `warnings`, `sequenceId?`, `number`, `previous?`, `next?` (episode IDs). `status()` without an ID lists all queues. Closing the panel leaves work running. Closing/reloading the tab interrupts it: completed drafts persist, the running row becomes failed with an interruption explanation, and untouched rows remain pending. There is no background/tab-closed execution or partially synthesized episode resume. Start continues pending rows; retry restarts one failed row. A channel identity is frozen at creation. Episode overrides must match it; set common voice, colors, assets or pronunciation on `channel` before creating the queue. Duplicate normalized topics and identical substantive narration within a batch are rejected; this is not a semantic originality guarantee. `series.register` lists actual draft/finalized topics and asset IDs. Automatic template B-roll avoids assets registered by earlier episodes; explicitly selected repeated visuals stay selected with a warning. Common music/intro/outro remain shared. Each output sequence stores `series: { seriesId, episodeId, number, previous?, next?, finalized }`. The open/review controls resolve these episode references to the individual sequences. ## Narration control UI: AI → Narration control (`vidcat.ai('narration')`). Reuses Piper, generated WAV files, captions and ducking. Available for complete generated narration clips (standalone Voiceover, Script to Video, templates); trimmed/reversed/relinked or imported recordings without sentence metadata are rejected rather than guessed. ```js vidcat.narration.dictionary(); vidcat.narration.setDictionary({ 'Şişli': 'Shish lee', 'AI': 'ey ay', 'Iğdır': 'Ih duhr' }); // Optional sharing through the existing reusable channel kit; default is project-only: vidcat.narration.setDictionary({ 'Şişli': 'Shish lee' }, { share: true }); const voice = vidcat.narration.list()[0]; await vidcat.narration.retake(voice.clipId, 1, { text: 'Şişli AI.', voice: 'en-amy', speed: 1.1 }); await vidcat.narration.pauses(voice.clipId, { sentenceGap: .3, paragraphGap: .9, after: { '1': 1.2 } }); // Reviewed word timing: one { start, end } per displayed word, in SOURCE seconds: await vidcat.narration.align(voice.clipId, 0, reviewedWordIntervals); ``` Dictionary entries replace literal, case-sensitive Unicode whole words/phrases, longest first, in one non-recursive pass. Substitutions affect synthesis only; display text, sentences and captions retain the original wording. Per-project dictionaries persist in project JSON; sharing updates the reusable channel kit for future projects without overwriting existing projects. Existing template instances keep their channel snapshot; a sentence retake deliberately uses the current project dictionary. Examples above illustrate spelling substitutions, not a phoneme alphabet or guaranteed Turkish pronunciation. Sentence indices are zero-based. `retake(clipId, index, { text?, voice?, speed? } = {})` synthesizes exactly one sentence, normalizes that take, resamples only it if the voice's sample rate differs, then splices lossless PCM into the original WAV. It consumes only that sentence and its following silence. The next sentence's start, PCM bytes, word data and caption clips remain unchanged. Shorter takes are padded with silence. A take too long for the slot rejects atomically: increase its speed, shorten its text or enlarge the following pause first. No neighbouring sentence is regenerated or normalized. The original asset remains available to undo and other sequences. Track locks and concurrent timeline edits reject safely; successful audio edits use one undo step. Audio waveform output is reimported through the existing engine. `pauses(clipId, { sentenceGap?, paragraphGap?, after? })` replaces silence between sentences/paragraphs, preserving every sentence's PCM. Gaps must be 0–10 seconds; `after` overrides the gap after a particular sentence (not the last sentence). Omitted gaps stay unchanged. Captions, word timing, later clips, markers, template scene/quiz boundaries and ranges follow the timing change. Spanning visual clips preserve their source range by adjusting playback rate; generated music stays at its original rate and loops when needed. Existing automatic ducking parameters are reapplied to new sentence regions; unrelated hand-authored volume curves are time-mapped. Preview timing changes before finalization. `narration.list()` returns clip/asset IDs, synthesis voice/speed, playback speed, timeline-relative sentences/words and a localized timing warning. New synthesis uses measured per-sentence PCM and energy-assisted word estimates: leading/trailing silence is excluded and weighted interior boundaries favor nearby low-energy valleys. These are **not phonetic forced alignment**. Every estimated word reports `method: 'energy-estimate'`, `confidence: 'low'`; legacy metadata falls back to `'character-estimate'`. There is no invented confidence percentage or accuracy guarantee, especially for pronunciation expansions and uninterrupted speech. Caption chunks, word-by-word reveal and karaoke use these explicit word intervals; the older character-count animation is only a fallback for titles without word data. Caption clip edges are quantized to sequence frames. `await align(clipId, sentenceIndex, [{ start, end }, ...])` accepts ordered, non-overlapping intervals inside that source sentence. It retains audio bytes and updates just that sentence's generated captions; words report `method: 'manual'`, `confidence: 'reviewed'`, meaning user-reviewed, not statistically verified. Piper does not provide phoneme/word timestamps. Automatic acoustic forced alignment and premium voice APIs are not included. RETENTION: TITLE / THUMBNAIL HYPOTHESES AND HOOKS These tools start from a built template episode (at least three scenes) and its saved channel kit. They make local, editable editorial drafts from the supplied episode content: original explanation, a concrete case/answer, and breadth. They do not invent evidence or call a cloud model. Review the promises against the actual episode before using them. await vidcat.variants.generate() vidcat.variants.get() await vidcat.variants.update(id, { title?, promise?, thumbnail?, evidenceSceneIds? }) await vidcat.variants.preview(id) vidcat.variants.select(id) vidcat.variants.record(id, { shippedAt: '2026-09-22', youtubeUrl: 'https://youtu.be/VIDEO', outcome: 'User-reported result', impressions?: 5000, ctr?: 5.2, watchTimeHours?: 30 }) vidcat.variants.history() vidcat.variants.open() `generate` saves exactly three hypotheses with different promises, rationales and evidence scene IDs, and renders all through the existing thumbnail renderer. `update` reruns the check and invalidates prepared hooks. Thumbnail options are the existing { at, text, accent, color, align, darken }. Text uses the episode channel colors; the frame comes from an evidence scene. `preview` returns { url, previewUrl, legibility }; both PNG URLs expire after five minutes, full size is 1280×720 and preview size is 160×90. Download from the variants panel. Legibility checks the actual 160×90 downsampled glyph pixels against their outlined backdrop. It reports font size at that scale, the 10th percentile contrast of covered glyph pixels, clipping, warnings and pass/fail (at least 10 px, at least 3:1 contrast, no clipping). This is a conservative text heuristic, not an audience-response or image-composition score. Review the actual small preview. No text also fails this text-focused check. The real experiment runs on YouTube. vid.cat prepares variants and records outcomes the user reports; it does not upload, run an A/B test, fetch analytics, estimate audience retention or declare a winner. Selection becomes the default title and thumbnail for the existing publish package; explicit publish options override it. A failed legibility check remains visible and does not forbid an editorial choice. Outcome records append an immutable snapshot of the shipped variant with the YouTube URL, date, optional metrics and notes. CTR is percent 0–100. Results persist in project JSON, accumulate across episodes, and `history` also reads other locally saved projects, with project/episode/channel identity. Regeneration never rewrites past outcomes; normal undo can undo a record. vidcat.hooks.prepare(variantId) vidcat.hooks.get() vidcat.hooks.update(hookId, { narration, text, payoff: { start, end, quote, sceneIds } }) await vidcat.hooks.apply(hookId) vidcat.hooks.open() Three short alternatives use answer-first, question-first and preview-first openings. Each shows the title/thumbnail promise, an exact highlighted excerpt in the opening, and supporting scenes with current timeline positions. Payoff start/end are character offsets (end exclusive); quote must equal narration.slice(start, end). Timing is estimated until synthesis. User edits allow at most 50 words / 600 characters; evidence IDs must exist. This is an explicit editorial mapping, not automatic semantic verification. `get` reports applied state, estimates and whether supporting scene content/channel changes made the workspace stale. `apply` uses templates.updateScene on the existing opening: channel voice, narration cache, titles, captions, media and music use the existing compiler. The normal editable clips and later scene timings are committed in one undo step. Later manual clips retain their styling and shift with the opening's duration change. Locked tracks and stale evidence reject before regeneration. The opening must be a normal, non-quiz template scene. Silent channel kits retain the compiler's silent-scene behavior; no Catalan voice is invented. AI workspace and Tools menu expose all three panels. SOURCE AND LICENCE LEDGER vidcat.ledger.list({ sequenceId? }) vidcat.ledger.add({ name, kind: 'media' | 'music' | 'quote' | 'voice', sequenceId?, sceneId?, source?, provider?, creator?, license?, licenseUrl?, permission?, attribution?, attributionRequired?, retrievedAt? }) vidcat.ledger.update(id, { name?, kind?, source?, provider?, creator?, license?, licenseUrl?, permission?, attribution?, attributionRequired?, retrievedAt? }) vidcat.ledger.remove(id) vidcat.ledger.open() A project register is derived from every library asset (including unused imports), template scene sources and actual generated Piper voice IDs. Audio without narration is initially classified as music; change kind when it is another audio source. A plain imported filename, URL or licence URL never establishes permission. Entries without a recorded licence or permission have missingPermission:true and a missing-permission flag. Automatic IDs are asset:ASSET_ID, voice:VOICE_ID and quote:SEQUENCE:SCENE:SOURCE. Update attaches a licence, permission note or document reference without losing automatic provider metadata. No upload is needed. Manual entries should have sequenceId to feed that episode's credits. Automatic entries cannot be deleted; remove their asset/source to stop gathering them. Updates and manual records persist in project JSON and support undo/redo. KLIPY imports retain the returned source URL (or media URL), provider, creator when supplied, retrieval date, "Powered by KLIPY" attribution and attributionRequired:true. API availability is not treated as permission to reuse the underlying work. Bundled Piper voices link their model cards and known dataset licence references (reviewed 2026-09-22); dataset terms alone leave missing-permission and review-model-terms flags until the user records permission for their use. The same Asset.credit contract accepts future stock-provider source, provider, creator, license, licenseUrl, permission, attribution, attributionRequired and retrievedAt metadata; no stock provider integration is added here. The existing publish ZIP now writes credits.json schemaVersion:2 with its compatible assets and sources lists plus ledger entries scoped to enabled, visible timeline assets, their voice models, scene sources, and manually linked records. Ledger overrides feed the assets credits too. Missing permissions produce the existing package warning. Credits document known provenance and reported permissions; they do not certify copyright or monetization eligibility.