Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit acf5b6f6c51122bc6e453ed2b4cac2d989df7bc8
parent d29412de5c3517bf069e92fad955e8b86f67e45c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 23:27:59 -0400

Merge main (3ead8acf: U1 umtool media root, export 0.11.1, XP X posts private) into r17/dashboard-answers

Two conflicts, both appends: plans/release-17.md keeps U1's and XP's record
sections before D0's under ## Record; editor/CHANGELOG.md keeps every
[Unreleased] bullet (main's, then D0's).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
M.gitignore | 2++
MSETTINGS.md | 3++-
MSITE.md | 7+++++++
Mcommon/bin/compose-hub.test.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-hub.ts | 40++++++++++++++++++++++++++++++++++++++++
Acommon/bin/compose-site.postsVisibility.test.ts | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-site.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcommon/controller/buildIndex.ts | 19++++++++++++++++++-
Mcommon/controller/poolSummary.test.ts | 23+++++++++++++++++++++++
Mcommon/controller/poolSummary.ts | 28++++++++++++++++++++++++----
Mcommon/lib/builtExport.test.ts | 8++++++++
Mcommon/lib/builtExport.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/corpus.ts | 7+++++++
Acommon/lib/postsVisibility.test.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/postsVisibility.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settingsSchema.ts | 11++++++++++-
Mcommon/lib/site.ts | 6+++++-
Mcommon/lib/siteSchema.ts | 39++++++++++++++++++++++++++++++++++++---
Mcommon/publish/build.test.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/build.ts | 48++++++++++++++++++++++++++++++++++++++++++------
Mcommon/social/xCookieSource.ts | 29+++++++++++++++++++++++++----
Mdocker/publish-site.sh | 17+++++++++++++++++
Meditor/CHANGELOG.md | 14++++++++++++--
Aeditor/app/settings/components/XPostsVisibilityControl.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/components/XSessionSection.tsx | 9+++++++++
Meditor/app/settings/page.tsx | 7++++++-
Meditor/app/settings/xSessionActions.ts | 50+++++++++++++++++++++++++++++++++++++++++++++++---
Meditor/app/sites/actions.ts | 7+++++++
Meditor/app/sites/components/SiteForm.tsx | 21++++++++++++++++++++-
Meditor/app/sites/lib/buildAction.ts | 23+++++++++++++++++++++++
Meditor/app/sites/lib/deployAction.ts | 13++++++++++++-
Meditor/e2e/sites-crud.spec.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/x-session.spec.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 2+-
Mexport/app/offline/page.tsx | 19++++++++++++++-----
Aexport/e2e/x-posts-private.spec.ts | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/FACTS.md | 22+++++++++++++++++++++-
Mplans/deck-posts.md | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/release-17.md | 490+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/app/api/report/audio/route.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/api/report/chrome/preview/route.ts | 7+++++++
Mumtool/app/api/report/clip/route.ts | 1+
Mumtool/app/api/report/window/route.ts | 4++++
Mumtool/bin/umtool.mjs | 167++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mumtool/components/projects/ClipBench.tsx | 793++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ClipBenchPage.tsx | 5+++++
Mumtool/components/projects/OnscreenSection.tsx | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mumtool/docs/cli.md | 9+++++++--
Mumtool/docs/folders.md | 36++++++++++++++++++++++++++++++++++++
Mumtool/docs/quirks.md | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/clip-bench.spec.ts | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/e2e/dashboard.spec.ts | 9++++++++-
Mumtool/e2e/fixtures/make-fixture.mjs | 38+++++++++++++++++++++++++++++++++++++-
Mumtool/e2e/onscreen-posts.spec.ts | 215++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/e2e/onscreen.spec.ts | 5+++++
Mumtool/e2e/projects.spec.ts | 6+++++-
Mumtool/e2e/report-longform.spec.ts | 4++++
Aumtool/e2e/storage.spec.ts | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/media.ts | 32+++++++++++++++++++++++++++-----
Mumtool/lib/paths.mjs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mumtool/lib/paths.ts | 4++++
Mumtool/lib/projects/kinds.mjs | 23+++++++++++++++++++++++
Mumtool/lib/projects/report.mjs | 4+++-
Mumtool/lib/projects/walk.mjs | 4++--
Mumtool/lib/report/export.mjs | 30++++++++++++++++++++++++++++--
Aumtool/lib/report/footage-move.mjs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/footage-move.test.mjs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.mjs | 29+++++++++++++++++++++++++++++
Mumtool/lib/report/manifest.test.mjs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/onscreen.mjs | 53+++++++++++++++++++++++++++++++++++++++--------------
Mumtool/lib/report/onscreen.test.mjs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/lib/report/playback.mjs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/playback.test.mjs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/storage.mjs | 569+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/report/storage.test.mjs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/next.config.ts | 5+++--
Mumtool/playwright.config.ts | 9+++++++++
Mumtool/report-to-video/README.md | 234++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aumtool/report-to-video/av-sync.test.mjs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/build-video.mjs | 685+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/check-availability.mjs | 6++++--
Mumtool/report-to-video/chrome-deck.mjs | 41++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/chrome-deck.test.mjs | 37+++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/chrome-posts.mjs | 103++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mumtool/report-to-video/chrome-posts.test.mjs | 17+++++++++++++++--
Aumtool/report-to-video/chrome-teaser.mjs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/chrome-teaser.test.mjs | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 55+++++++++++++++++++++++++++++++++++++++++++------------
Aumtool/report-to-video/cut-edits.test.mjs | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/deck-overlay.test.mjs | 4+++-
Aumtool/report-to-video/deck-room.test.mjs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/deck.mjs | 479++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mumtool/report-to-video/deck.test.mjs | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Aumtool/report-to-video/mute.mjs | 7+++++++
Mumtool/report-to-video/package.json | 1+
Mumtool/report-to-video/render-cards.mjs | 2++
Aumtool/report-to-video/teaser-audio.test.mjs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
98 files changed, 9727 insertions(+), 340 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -169,6 +169,8 @@ yarn-error.log* # run alongside a dev server someone is judging clips in (Next refuses two for # one project). umtool/.e2e-song/ +# ...and the storage spec's media root, a sibling of it (UMTOOL_MEDIA_DIR). +umtool/.e2e-song-media/ umtool/.next/ # Any alternate dist dir, not just the e2e one. # diff --git a/SETTINGS.md b/SETTINGS.md @@ -160,7 +160,7 @@ Default: `"when-required"` ## `social` -Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts. +Per-platform settings of the social posts. Today two keys, both X's, both chosen in the X account session section of /settings: where the X fetchers' login comes from (`social.x.cookieSource`) and where X posts may appear (`social.x.visibility`). See common/social/xCookieSource.ts. #### `social` @@ -173,6 +173,7 @@ Per-platform settings of the social-post fetchers. Today one key: where the X fe | Key | Default | Description | |---|---|---| | `cookieSource` | absent | Where the X fetchers' login comes from. `"browser"`: the operator's everyday browser, named by `cookiesFromBrowser` — gallery-dl is handed `--cookies-from-browser <spec>` and reads it on every run, and the Playwright fallback reads the same store (Firefox only; common/social/xBrowserLogin.ts), so the login lasts as long as the browser's. `"profile"`: the session broker's persistent profile ("Connect X account" on /settings) and the cookie jar it exports. ABSENT (the default) is resolved at read time, never stored: `"browser"` when `cookiesFromBrowser` is set and no profile is connected (no exported jar carrying an auth_token), else `"profile"`. The source, not `cookieMode`, governs the X fetchers. | +| `visibility` | absent | Where X posts may appear. `"public"` (the default; absent): an X channel's posts are built into every site that has the channel. `"private"`: every X channel's posts (a channel with `sourceKind: "social"` and `platform: "twitter"`) are left out of every PUBLIC site build — the channel with them, since posts are all an X channel holds — and built only into PRIVATE sites (`site.json` `audience`). Nothing on disk changes and fetching does not; a site already published changes on its next build and deploy, and flipping back is a rebuild. Chosen in the X account session section of /settings; the rule is common/lib/postsVisibility.ts. | Default: diff --git a/SITE.md b/SITE.md @@ -24,6 +24,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | [`accent`](#accent) | absent | | [`siteUrl`](#siteurl) | absent | | [`listed`](#listed) | `true` | +| [`audience`](#audience) | absent | | [`relatedSites`](#relatedsites) | `[]` | | [`pwa`](#pwa) | `false` | | [`archives`](#archives) | `true` | @@ -164,6 +165,12 @@ Whether the family lists this site. Opt-OUT: absent/true = listed, only an expli Default: `true` +## `audience` + +Who this site is built for. `"public"` (the default; absent) or `"private"`: the operator's own reading copy, built on this machine and never deployed — every deploy path (Build & deploy, Deploy, `archilyzer deploy site`, Build & deploy all, docker/publish-site.sh) refuses it before any upload, while a build without a deploy still works. A private site is never listed (as `listed: false`, whatever `listed` says), publishes no `hubUrl`, and its `/corpus.json` says `"audience": "private"`. Content kept from the public — X posts while `social.x.visibility` is `"private"` — is built only into private sites. Only `"private"` is written. + +Default: absent + ## `relatedSites` Pulls specific siblings to the front of the footer's cross-site list, in named groups. Siblings not named here fall into a trailing "Other sites" group. Absent/empty = one flat list of every sibling. diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { getPaths, type Paths } from "../lib/paths"; import { main } from "./compose-hub"; +import { readGlobalAliases } from "../lib/aliasesStore"; // Run with: // pnpm --filter yt-dlp-transcript-common test @@ -170,3 +171,54 @@ test("an unlisted site is in none of the hub's files; a listed one is in each", rmSync(root, { recursive: true, force: true }); } }); + +// Release 17 slice XP (the review's HIGH 1): a site's compose leaves its data +// in public/ — a private site's X posts included — and the hub builds from +// public/ next. compose-hub removes every per-site entry, through a link only +// the link, and ships the global alias dictionary as its own. +test("compose-hub removes a site's data from public/, a linked entry by its link only", async () => { + const root = mkdtempSync(path.join(tmpdir(), "compose-hub-")); + const log = console.log; + try { + const paths = fixturePaths(root); + const pub = paths.exportPublicDir; + // What a private site's compose leaves. + for (const tree of ["summaries", "transcripts", "subs", "digests", "stats", "archives"]) { + mkdirSync(path.join(pub, tree, "x"), { recursive: true }); + writeFileSync(path.join(pub, tree, "x", "page-0000.json"), "[]"); + } + for (const f of ["site.json", "tags.json", "duplicates.json", "chart-templates.json", "sitemap.xml"]) { + writeFileSync(path.join(pub, f), "{}"); + } + writeFileSync(path.join(pub, "search-aliases.json"), JSON.stringify({ aliases: [{ site: 1 }] })); + // posts/ as a worktree has it: a link into the primary checkout. + const primaryPosts = path.join(root, "primary-public", "posts"); + mkdirSync(path.join(primaryPosts, "jer-x"), { recursive: true }); + writeFileSync(path.join(primaryPosts, "manifest.json"), '{"channels":[{"slug":"jer-x"}]}'); + symlinkSync(primaryPosts, path.join(pub, "posts")); + // A checked-in static asset stays. + writeFileSync(path.join(pub, "globe.svg"), "<svg/>"); + // No global dictionary file: the seeded defaults are the global one. + const hubPaths = { ...paths, globalAliasesFile: path.join(root, "no-aliases.json") }; + + console.log = () => {}; + await main({ paths: hubPaths }); + console.log = log; + + for (const gone of [ + "summaries", "transcripts", "subs", "posts", "digests", "stats", "archives", + "site.json", "tags.json", "duplicates.json", "chart-templates.json", "sitemap.xml", + ]) { + assert.ok(!existsSync(path.join(pub, gone)), `${gone} was removed`); + } + assert.ok(existsSync(path.join(primaryPosts, "manifest.json")), "the link's target is untouched"); + assert.ok(existsSync(path.join(pub, "globe.svg"))); + assert.ok(existsSync(path.join(pub, "hub-sites.json"))); + const aliases = JSON.parse(readFileSync(path.join(pub, "search-aliases.json"), "utf8")); + assert.deepEqual(aliases, { aliases: readGlobalAliases(hubPaths).aliases }); + assert.ok(!JSON.stringify(aliases).includes('"site"'), "not the site's aliases"); + } finally { + console.log = log; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts @@ -11,6 +11,10 @@ // there is no index to walk // public/_headers <- CORS for the hub's own served JSON // public/sw.js <- the hub service worker (the hub always ships a PWA) +// public/search-aliases.json <- the global alias dictionary +// +// and REMOVES every per-site entry a site's compose left in public/ +// (SITE_ONLY_PUBLIC_ENTRIES below): the hub holds no site's data. // // The hub's branding ("Archilyzer") is resolved at build/render time from the // HomepageConfig (see export/app/lib/site.ts hubSite()), not composed here. @@ -32,6 +36,7 @@ import { import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers"; import { buildPoolSummary } from "../controller/poolSummary"; import { HUB_SUMMARY_FILE, toHubSummary } from "../lib/hubSummary"; +import { readGlobalAliases } from "../lib/aliasesStore"; import { runIfEntryPoint } from "./_cli"; import { writePublicFile } from "./_publicFile"; @@ -76,10 +81,45 @@ async function composeHubSummary( } } +// THE HUB CARRIES NO SITE'S DATA (release 17 slice XP, the review's HIGH 1). +// public/ is the one directory every site composes into in turn, and the hub +// builds from it next: whatever the last site's compose left there — its data +// trees and its per-site files — `next build` copied into the hub's out/, and +// the hub deploy shipped it. The live hub served jeralyzer's posts manifest; +// after a PRIVATE site's build it would have served every X post. So the hub's +// compose removes every per-site entry first. A worktree's public/ entries are +// links into the primary checkout: rm removes the link, never its target. +export const SITE_ONLY_PUBLIC_ENTRIES: readonly string[] = [ + "summaries", + "transcripts", + "subs", + "posts", + "digests", + "stats", + "archives", + "site.json", + "tags.json", + "duplicates.json", + "search-aliases.json", + "chart-templates.json", + "sitemap.xml", +]; + export async function main(opts: { paths?: Paths } = {}): Promise<void> { const paths = opts.paths ?? getPaths(); const publicDir = paths.exportPublicDir; + for (const entry of SITE_ONLY_PUBLIC_ENTRIES) { + await rm(path.join(publicDir, entry), { recursive: true, force: true }); + } + // The hub's own alias dictionary is the global one (no site's overrides): + // what a hub reader loads for hub-wide search (lib/archive/reader-hub.ts), + // where it used to get whichever site had composed last. + await writePublicFile( + path.join(publicDir, "search-aliases.json"), + JSON.stringify({ aliases: readGlobalAliases(paths).aliases }), + ); + // Built-in pool: every configured site that publishes a public URL and is // listed. An unlisted site (`listed: false`) still builds and deploys, but the // hub does not list it: not a member, not in federated search, not in the diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts @@ -0,0 +1,331 @@ +// Integration: X posts are private (release 17 slice XP), through the REAL +// index build and the REAL site compose, over a temp corpus. +// +// One video channel, one X channel and one Bluesky channel, on two sites that +// both have all three: `pub` (public) and `priv` (`audience: "private"`). With +// `social.x.visibility` "private", the public site's build carries no X channel +// at all — no posts manifest entry, no posts tree, no channel in its channel +// list, site.json or corpus.json — while the private one carries everything +// and says `"audience": "private"` with no hubUrl. Flipping the setting back +// is a rebuild. The rule itself is lib/postsVisibility.ts (its own tests). +// +// The export e2e cannot show this: its data is route-mocked, never built by +// buildIndex and compose. export/e2e/x-posts-private.spec.ts serves the two +// posts manifests this file pins and checks what a visitor sees. +// +// Run with: node_modules/.bin/tsx --test common/bin/compose-site.postsVisibility.test.ts + +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// Every path getPaths() can resolve to a place this file's code may write is +// pinned under ROOT before anything calls it (buildIndex.test.ts's list). +const ROOT = mkdtempSync(path.join(tmpdir(), "posts-visibility-")); +const PINNED: Record<string, string> = { + TRANSCRIPTS_DIR: path.join(ROOT, "transcripts"), + SAVED_VIDEOS_DIR: path.join(ROOT, "saved-videos"), + SITES_DIR: path.join(ROOT, "transcripts", "sites"), + SETTINGS_FILE: path.join(ROOT, "settings.json"), + EXPORT_PUBLIC_DIR: path.join(ROOT, "public"), + EXPORT_INDEX_DIR: path.join(ROOT, ".export-index"), + EXPORT_BUILDS_DIR: path.join(ROOT, ".export-builds"), + EDITOR_CHANGELOG_FILE: path.join(ROOT, "editor-CHANGELOG.md"), + EXPORT_CHANGELOG_FILE: path.join(ROOT, "export-CHANGELOG.md"), + CHARTS_CONFIG_FILE: path.join(ROOT, "chart-templates.json"), + SEARCH_ALIASES_FILE: path.join(ROOT, "transcripts", "search-aliases.json"), + CURATED_TAGS_FILE: path.join(ROOT, "transcripts", "tags.json"), + ARCHILYZER_CONFIG_DIR: path.join(ROOT, "config"), + ARCHILYZER_SOURCE_SCRATCH: path.join(ROOT, "source-scratch"), +}; +Object.assign(process.env, PINNED); +after(() => rmSync(ROOT, { recursive: true, force: true })); + +const { getPaths } = await import("../lib/paths"); +const { buildIndex } = await import("../controller/buildIndex"); +const { writePosts } = await import("../lib/posts-server"); +const { main: composeSite } = await import("./compose-site"); +const { builtAudienceProblem, deployAudienceProblem } = await import("../lib/builtExport"); + +const paths = getPaths(); +const VIDEOS = "vids"; +const X = "jer-x"; +const SKY = "jer-sky"; +const HUB = "https://hub.example.test"; + +const writeJson = (file: string, value: unknown) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value, null, 2)); +}; +const readJson = <T>(file: string): T => JSON.parse(readFileSync(file, "utf8")) as T; + +// YouTube's rolling-caption shape (parseVtt keeps lines with inline timing). +const VTT = + "WEBVTT\nKind: captions\nLanguage: en\n\n" + + "00:00:00.000 --> 00:00:05.000 align:start position:0%\n" + + "First<00:00:01.000><c> caption</c><00:00:02.000><c> line.</c>\n"; + +function post(slug: string, id: string, platform: "twitter" | "bluesky", text: string) { + return { + id, + slug: `${slug}/${id}`, + channelSlug: slug, + author: slug, + createdAt: "2026-09-01T12:00:00.000Z", + uploadDate: "20260901", + text, + url: `https://example.test/${slug}/${id}`, + platform, + isReply: false, + isRepost: false, + links: [], + }; +} + +function writeSettings(visibility?: "public" | "private") { + writeJson(paths.settingsFile, { + homepageUrl: HUB, + ...(visibility ? { social: { x: { visibility } } } : {}), + }); +} + +async function seedCorpus() { + writeJson(path.join(paths.channelsDir, VIDEOS, "config.json"), { + handling: "youtube", + name: "Videos", + url: "https://www.youtube.com/@vids/videos", + }); + const dir = path.join(paths.channelsDir, VIDEOS, "data", "v1"); + writeJson(path.join(dir, "metadata.info.json"), { + id: "v1", + title: "Video one", + channel: VIDEOS, + upload_date: "20260601", + duration: 120, + webpage_url: "https://www.youtube.com/watch?v=v1", + extractor_key: "Youtube", + }); + writeFileSync(path.join(dir, "transcript.en.vtt"), VTT); + + writeJson(path.join(paths.channelsDir, X, "config.json"), { + handling: "youtube", + name: "Jer on X", + url: "https://x.com/jer", + sourceKind: "social", + platform: "twitter", + socialHandle: "jer", + }); + await writePosts(path.join(paths.channelsDir, X), [ + post(X, "1001", "twitter", "an x post"), + post(X, "1002", "twitter", "another x post"), + ]); + writeJson(path.join(paths.channelsDir, SKY, "config.json"), { + handling: "youtube", + name: "Jer on Bluesky", + url: "https://bsky.app/profile/jer.example", + sourceKind: "social", + platform: "bluesky", + socialHandle: "jer.example", + }); + await writePosts(path.join(paths.channelsDir, SKY), [ + post(SKY, "3kabc", "bluesky", "a bluesky post"), + ]); + + const site = (siteId: string, extra: Record<string, unknown> = {}) => + writeJson(path.join(paths.sitesDir, siteId, "site.json"), { + siteId, + siteTitle: siteId, + siteDescription: "fixture", + headerTitle: siteId, + homeTagline: "", + socialLinks: [], + groups: [{ id: "default", name: "All channels", selectedByDefault: true }], + defaultGroupId: "default", + channels: [VIDEOS, X, SKY].map((slug) => ({ slug, groupId: "default" })), + siteUrl: `https://${siteId}.example.test`, + archives: false, + ...extra, + }); + site("pub"); + site("priv", { audience: "private" }); +} + +const quiet = () => {}; +async function index() { + await buildIndex({ paths, onLog: quiet }); +} + +// What one site's compose put in public/. +type Composed = { + postsManifest: { channels: { slug: string; platform: string; postCount: number }[]; totalCount: number }; + postTrees: string[]; + transcriptTrees: string[]; + siteJson: { channels: { slug: string }[]; hubUrl?: string }; + corpus: { + site: { id: string; hubUrl?: string; audience?: string }; + channels: { slug: string; postCount?: number; manifests: { posts?: string } }[]; + postScheme?: unknown; + totals: { channels: number }; + }; +}; +async function compose(siteId: string): Promise<Composed> { + const log = console.log; + console.log = quiet; + try { + await composeSite({ siteId, paths }); + } finally { + console.log = log; + } + const pub = paths.exportPublicDir; + const trees = (dir: string) => + [VIDEOS, X, SKY].filter((slug) => existsSync(path.join(dir, slug))); + return { + postsManifest: readJson(path.join(pub, "posts", "manifest.json")), + postTrees: trees(path.join(pub, "posts")), + transcriptTrees: trees(path.join(pub, "transcripts")), + siteJson: readJson(path.join(pub, "site.json")), + corpus: readJson(path.join(pub, "corpus.json")), + }; +} + +const slugs = (xs: { slug: string }[]) => xs.map((c) => c.slug).sort(); + +test("X private: a public site carries no X channel; a private site carries all of it and says so", async () => { + await seedCorpus(); + writeSettings("private"); + await index(); + + // The index build's per-site manifests, before any compose. + const sitePosts = (id: string) => + readJson<Composed["postsManifest"]>( + path.join(paths.exportSitesIndexDir, id, "posts", "manifest.json"), + ); + assert.deepEqual(slugs(sitePosts("pub").channels), [SKY]); + assert.deepEqual(slugs(sitePosts("priv").channels), [SKY, X]); + // The shared posts tree is corpus-wide and keeps X: the private site and + // the MCP over its build read it. + assert.ok(existsSync(path.join(paths.exportSharedPostsDir, X, "manifest.json"))); + + const pub = await compose("pub"); + assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); + assert.equal(pub.postsManifest.totalCount, 1); + assert.deepEqual(pub.postTrees, [SKY]); + assert.deepEqual(pub.transcriptTrees, [VIDEOS, SKY]); + assert.deepEqual(slugs(pub.siteJson.channels), [SKY, VIDEOS]); + assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); + assert.equal(pub.corpus.totals.channels, 2); + assert.equal(pub.corpus.site.audience, undefined); + assert.equal(pub.corpus.site.hubUrl, HUB); + assert.equal(pub.siteJson.hubUrl, HUB); + assert.equal(builtAudienceProblem(paths.exportPublicDir), null); + assert.equal(deployAudienceProblem({ siteId: "pub" }, paths.exportPublicDir), null); + + const priv = await compose("priv"); + assert.deepEqual(slugs(priv.postsManifest.channels), [SKY, X]); + assert.equal(priv.postsManifest.totalCount, 3); + assert.deepEqual(priv.postTrees, [X, SKY]); + assert.deepEqual(slugs(priv.corpus.channels), [SKY, X, VIDEOS]); + assert.equal(priv.corpus.channels.find((c) => c.slug === X)?.postCount, 2); + assert.ok(priv.corpus.channels.find((c) => c.slug === X)?.manifests.posts); + assert.equal(priv.corpus.site.audience, "private"); + // A private site belongs under no hub. + assert.equal(priv.corpus.site.hubUrl, undefined); + assert.equal(priv.siteJson.hubUrl, undefined); + // And its bundle refuses to deploy — under its own id, and under another + // site's (a public site's identity on a private build is still refused). + assert.match(builtAudienceProblem(paths.exportPublicDir) ?? "", /private build of "priv"/); + assert.match( + deployAudienceProblem({ siteId: "priv", audience: "private" }, paths.exportPublicDir) ?? "", + /^Site "priv" is private \(audience: private\)/, + ); + + // Compose the public site again over the private one's public/, with + // nothing changed since its last compose: the X channel the private site + // carried is pruned from every tree, and the summaries are the public + // site's again — its compose cache is not trusted over another site's + // compose (the summaries used to be skipped as "unchanged" and shipped the + // private site's channel list). + const again = await compose("pub"); + assert.deepEqual(again.postTrees, [SKY]); + assert.deepEqual(again.transcriptTrees, [VIDEOS, SKY]); + assert.deepEqual(slugs(again.siteJson.channels), [SKY, VIDEOS]); + assert.deepEqual(slugs(again.corpus.channels), [SKY, VIDEOS]); + assert.equal(again.corpus.site.audience, undefined); + // And over its own last compose the cache is trusted as before. + const third = await compose("pub"); + assert.deepEqual(slugs(third.corpus.channels), [SKY, VIDEOS]); +}); + +test("a config compose cannot read does not ship the posts tree the index build withheld", async () => { + // The index build (X private) withheld X from pub's posts manifest; compose + // then fails to read X's config, so its own rule reads X as visible. The + // site posts manifest is the index build's word: no X posts tree ships. + writeSettings("private"); + await index(); + const cfg = path.join(paths.channelsDir, X, "config.json"); + const saved = readFileSync(cfg, "utf8"); + rmSync(cfg); + try { + const pub = await compose("pub"); + assert.deepEqual(pub.postTrees, [SKY]); + assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); + assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); + } finally { + writeFileSync(cfg, saved); + } +}); + +test("a compose over an index built before the setting flipped lists no X channel anywhere", async () => { + // Index with X public, then flip to private and compose WITHOUT indexing. + writeSettings("public"); + await index(); + writeSettings("private"); + const pub = await compose("pub"); + assert.deepEqual(pub.postTrees, [SKY]); + assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); + assert.equal(pub.postsManifest.totalCount, 1); + assert.equal(pub.corpus.channels.find((c) => c.slug === X)?.postCount, undefined); + assert.equal(pub.corpus.postScheme !== undefined, true, "the Bluesky posts are still advertised"); + // The channel list too: no X channel in site.json or corpus.json. + assert.deepEqual(slugs(pub.siteJson.channels), [SKY, VIDEOS]); + assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); +}); + +test("X public again: the next build puts X back on the public site", async () => { + writeSettings("public"); + await index(); + const pub = await compose("pub"); + assert.deepEqual(slugs(pub.postsManifest.channels), [SKY, X]); + assert.deepEqual(pub.postTrees, [X, SKY]); + assert.deepEqual(slugs(pub.corpus.channels), [SKY, X, VIDEOS]); + + // No setting at all is public too. + writeSettings(); + await index(); + assert.deepEqual(slugs((await compose("pub")).postsManifest.channels), [SKY, X]); +}); + +test("a public site whose only posts were X posts ships an empty posts manifest and no post scheme", async () => { + writeSettings("private"); + writeJson(path.join(paths.sitesDir, "xonly", "site.json"), { + siteId: "xonly", + siteTitle: "xonly", + siteDescription: "fixture", + headerTitle: "xonly", + homeTagline: "", + groups: [{ id: "default", name: "All channels", selectedByDefault: true }], + defaultGroupId: "default", + channels: [VIDEOS, X].map((slug) => ({ slug, groupId: "default" })), + archives: false, + }); + await index(); + const xonly = await compose("xonly"); + // SearchSessionContext's hasPostsCorpus is `channels.length > 0`: no Posts + // toggle on this site, with nothing special-cased. + assert.deepEqual(xonly.postsManifest.channels, []); + assert.deepEqual(xonly.postTrees, []); + assert.equal(xonly.corpus.postScheme, undefined); + assert.deepEqual(slugs(xonly.corpus.channels), [VIDEOS]); +}); diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -60,6 +60,10 @@ import { type ArchiveManifest, type ArchiveManifestEntry, } from "../lib/archiveOptions"; +import { readChannelConfig } from "../controller/channels"; +import { builtSiteIdIn } from "../lib/builtExport"; +import { publishedMemberSlugs } from "../lib/postsVisibility"; +import { isPrivateSite } from "../lib/siteSchema"; import { runIfEntryPoint } from "./_cli"; import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; @@ -98,7 +102,10 @@ async function emitFederationFiles( // navigate the already-served paginated shards; they never enumerate per-video // files, so the count is constant regardless of corpus size. Runs after // site.json and the archives are composed (both feed into these files). -async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> { +async function emitAiFiles( + site: Site, + paths: ReturnType<typeof getPaths>, +): Promise<void> { const sitePath = path.join(paths.exportPublicDir, "site.json"); if (!(await exists(sitePath))) return; // no composed data → nothing to describe const descriptor = JSON.parse( @@ -161,6 +168,9 @@ async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> { postCounts, digestCounts, hasTags, + // A private site says so in its own corpus.json — the bundle's word that + // the deploy guard (lib/builtExport.ts builtAudienceProblem) reads. + private: isPrivateSite(site), }); await writePublicFile( path.join(paths.exportPublicDir, "corpus.json"), @@ -485,6 +495,34 @@ async function replaceDir(src: string, dest: string): Promise<void> { } } +// Rewrite a served manifest whose `channels` name a slug outside `members`, +// keeping the rest of it. A missing or unreadable manifest is left alone. +// Answers whether it rewrote the file. +async function narrowManifestChannels(file: string, members: Set<string>): Promise<boolean> { + let m: { channels?: { slug?: string }[] }; + try { + m = JSON.parse(await readFile(file, "utf8")); + } catch { + return false; + } + const channels = m.channels ?? []; + const kept = channels.filter((c) => typeof c.slug !== "string" || members.has(c.slug)); + if (kept.length === channels.length) return false; + await writePublicFile(file, JSON.stringify({ ...m, channels: kept })); + return true; +} + +// The channel slugs a site posts manifest lists, or null when there is no +// readable manifest. +async function readPostsManifestSlugs(file: string): Promise<Set<string> | null> { + try { + const pm = JSON.parse(await readFile(file, "utf8")) as PostsManifest; + return new Set((pm.channels ?? []).map((c) => c.slug)); + } catch { + return null; + } +} + // --- Incremental compose cache ------------------------------------------------ // Per-site record of what we last materialized into public/, keyed by a cheap // content signature of each source. When the signature is unchanged and the @@ -665,11 +703,58 @@ export async function main( } const paths = opts.paths ?? getPaths(); const site = getSite(siteId, paths); - const memberSlugs = site.channels.map((c) => c.slug); + // The members this site may publish (lib/postsVisibility.ts): every member, + // less an X channel while `social.x.visibility` is "private" and the site is + // public. The index build's per-site manifests are narrowed by the same rule; + // narrowing the trees here prunes an X channel a public site shipped before. + const configs = new Map( + await Promise.all( + site.channels.map( + async (c) => [c.slug, await readChannelConfig(paths, c.slug).catch(() => null)] as const, + ), + ), + ); + const memberSlugs = publishedMemberSlugs( + site, + (slug) => configs.get(slug), + getSettings(), + ); + const withheld = site.channels.length - memberSlugs.length; + if (withheld > 0) { + console.log( + `[compose] ${withheld} X channel(s) left out: X posts are private (social.x.visibility) and this site is public.`, + ); + } // Incremental compose: skip stages whose source is unchanged since last build. + // + // ONLY OVER THIS SITE'S OWN LAST COMPOSE. The cache is per site but public/ + // is one directory every site composes into in turn (the basic build), so + // after another site's compose a skipped stage would ship THAT site's files — + // its summaries, its whole channel list — under this site's name: composing + // a private site and then a public one shipped the private site's summaries + // as the public site's. public/site.json names the site composed into it + // last (it is written below, every compose); another name, or none, and the + // site's own stages (summaries, stats, duplicates) are composed afresh. + // + // The per-channel tree signatures stay trusted: those trees are copies of + // the SHARED trees, the same bytes whichever site copied them, and a + // channel another site pruned is re-copied because its directory is gone. const cachePath = composeCachePath(paths, siteId); - const cache = await readComposeCache(cachePath); + const lastComposed = builtSiteIdIn(paths.exportPublicDir); + const cached = await readComposeCache(cachePath); + const cache: ComposeCache = + lastComposed === siteId + ? cached + : { ...cached, summaries: undefined, stats: undefined, duplicates: undefined }; + if (lastComposed !== siteId && lastComposed !== null) { + console.log( + `[compose] public/ was last composed for "${lastComposed}": composing ${siteId}'s summaries, stats and duplicates afresh.`, + ); + } + // Until this compose writes its own, public/ names no site: a compose cut + // short part-way leaves the next one nothing to trust. + await rm(path.join(paths.exportPublicDir, "site.json"), { force: true }); // --- per-site aggregates (whole-dir swaps), gated on the source signature --- const summariesSrc = path.join(paths.exportSitesIndexDir, siteId, "summaries"); @@ -683,6 +768,19 @@ export async function main( } else { console.log("[compose] summaries: unchanged."); } + // The served summaries manifest lists only the members this compose + // publishes (lib/postsVisibility.ts), even over an index built before + // `social.x.visibility` flipped: site.json and corpus.json are built from it. + // A narrowed copy is no longer the source's copy: the next compose copies + // the summaries again rather than trusting it. + if ( + await narrowManifestChannels( + path.join(paths.exportSummariesDir, "manifest.json"), + new Set(memberSlugs), + ) + ) { + cache.summaries = undefined; + } const statsSrc = path.join(paths.exportSitesIndexDir, siteId, "stats"); const statsSig = await dirSignature(statsSrc); if (cache.stats !== statsSig || !(await exists(paths.exportStatsDir))) { @@ -713,11 +811,20 @@ export async function main( // The social-post corpus: same shared-tree shape, same incremental reconcile. // Only social member channels have a source dir; reconcileChannelTree treats a // missing one as "nothing to copy", so passing every member slug is correct. + // + // NEVER A TREE THE SITE'S POSTS MANIFEST DOES NOT LIST. The index build wrote + // that manifest by the same visibility rule as memberSlugs above, so the two + // agree — unless a channel's config could not be read here (it then reads as + // visible): the manifest is the index build's word, and a tree it withheld is + // not shipped on a failed read. A site with no manifest yet keeps the old rule. + const postsListed = await readPostsManifestSlugs( + path.join(paths.exportSitesIndexDir, siteId, "posts", "manifest.json"), + ); cache.posts = await reconcileChannelTree( "posts", paths.exportSharedPostsDir, paths.exportPostsDir, - memberSlugs, + postsListed ? memberSlugs.filter((slug) => postsListed.has(slug)) : memberSlugs, cache.posts ?? {}, console.log, ); @@ -752,7 +859,21 @@ export async function main( ); if (await exists(postsManifestSrc)) { await ownDir(paths.exportPostsDir); - await copyPublicFile(postsManifestSrc, path.join(paths.exportPostsDir, "manifest.json")); + // Narrowed to the members this compose publishes, so a compose run over an + // index built before `social.x.visibility` flipped (`archilyzer compose + // site` alone, `build site --nodata`) does not list a withheld X channel's + // name and count beside the pruned tree. + const pm = JSON.parse(await readFile(postsManifestSrc, "utf8")) as PostsManifest; + const members = new Set(memberSlugs); + const channels = (pm.channels ?? []).filter((c) => members.has(c.slug)); + await writePublicFile( + path.join(paths.exportPostsDir, "manifest.json"), + JSON.stringify({ + ...pm, + channels, + totalCount: channels.reduce((n, c) => n + (c.postCount ?? 0), 0), + }), + ); } // Same for the per-site digests manifest (which channels carry digests). const digestsManifestSrc = path.join( @@ -934,7 +1055,7 @@ export async function main( // --- AI discovery: llms.txt / corpus.json / robots.txt / sitemap.xml --- // (after site.json + archives — both feed into these fixed-count files) - await emitAiFiles(paths); + await emitAiFiles(site, paths); // Persist the incremental-compose signatures for the next build. await writeComposeCache(cachePath, cache); diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -141,6 +141,7 @@ import { postsAvailabilityPath, } from "../lib/posts-server"; import { isSocialChannel } from "../lib/channelConfig"; +import { publishedMemberSlugs } from "../lib/postsVisibility"; import { DIGESTS_MANIFEST_VERSION, SITE_DIGESTS_MANIFEST_VERSION, @@ -1940,8 +1941,21 @@ export async function buildIndex({ let aggregateSummaries = 0; let representativeChannelCount = 0; + // Which members a site may publish: an X channel is built only into private + // sites while `social.x.visibility` is "private" (lib/postsVisibility.ts — + // compose-site narrows its trees by the same rule). + const visibilitySettings = getSettings(); for (const site of sites) { - const memberSlugs = site.channels.map((c) => c.slug); + const memberSlugs = publishedMemberSlugs( + site, + (slug) => channelConfigs.get(slug), + visibilitySettings, + ); + // Members the rule leaves out of THIS build, named in the fingerprint below + // so flipping the setting (or the site's audience) rebuilds the site. + const withheld = site.channels + .map((c) => c.slug) + .filter((slug) => !memberSlugs.includes(slug)); const slugSet = new Set(memberSlugs); const slugGroup = new Map<string, string>(); for (const m of site.channels) { @@ -1972,6 +1986,9 @@ export async function buildIndex({ // yesterday's counts. curatedRules: curated.rulesHash, curatedAssign: curated.assignHash, + // Only when the visibility rule withholds a member, so a site it does + // not touch keeps the fingerprint it had. + ...(withheld.length > 0 ? { withheld } : {}), }); const fpKey = `siteFp:${site.siteId}`; if ( diff --git a/common/controller/poolSummary.test.ts b/common/controller/poolSummary.test.ts @@ -26,3 +26,26 @@ test("channel-sites.json names listed sites only; a channel only an unlisted sit }); assert.ok(!JSON.stringify(channelSitesOf(sites)).includes("fixture-unlisted")); }); + +// Release 17 slice XP: a PRIVATE site is never listed, so it is in neither; and +// with X posts private an X channel a public site leaves out of its build is +// not mapped to that site (buildPoolSummary passes the narrowing). +test("channel-sites.json leaves out a private site, and an X channel its public site withholds", async () => { + const { publishedMemberSlugs } = await import("../lib/postsVisibility"); + const sites = [ + parseSite("fixture-a", { channels: [{ slug: "vids" }, { slug: "jer-x" }] }), + parseSite("fixture-private", { + audience: "private", + channels: [{ slug: "vids" }, { slug: "jer-x" }, { slug: "own" }], + }), + ]; + const configs: Record<string, { sourceKind?: "social"; platform?: "twitter" }> = { + "jer-x": { sourceKind: "social", platform: "twitter" }, + }; + const privateX = { social: { x: { visibility: "private" } } }; + assert.deepEqual( + channelSitesOf(sites, (site) => publishedMemberSlugs(site, (slug) => configs[slug], privateX)), + { vids: ["fixture-a"] }, + ); + assert.deepEqual(channelSitesOf(sites), { vids: ["fixture-a"], "jer-x": ["fixture-a"] }); +}); diff --git a/common/controller/poolSummary.ts b/common/controller/poolSummary.ts @@ -12,6 +12,9 @@ import { mkdir, readFile } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import { buildStats } from "./buildStats"; import { isListedSite, listSites, type Site } from "../lib/site"; +import { getSettings } from "../lib/settings"; +import { publishedMemberSlugs } from "../lib/postsVisibility"; +import { readChannelConfig } from "./channels"; import { statsPageFileName, type StatsManifest, @@ -49,12 +52,21 @@ export async function readStatsPages(statsDir: string): Promise<VideoStat[]> { // published `channel-sites.json`. A channel on multiple sites maps to all of // them; a pool-only channel is simply absent, and so is an unlisted site // (site.json `listed: false`) and a channel only unlisted sites expose. -export function channelSitesOf(sites: readonly Site[]): ChannelSitesMap { +// +// `membersOf` answers the members a site's build publishes; absent, every +// member. buildPoolSummary passes lib/postsVisibility.ts's narrowing, so an X +// channel a public site leaves out while X posts are private is not mapped to +// that site here either. +export function channelSitesOf( + sites: readonly Site[], + membersOf: (site: Site) => readonly string[] = (site) => + site.channels.map((c) => c.slug), +): ChannelSitesMap { const channelSites: ChannelSitesMap = {}; for (const site of sites) { if (!isListedSite(site)) continue; - for (const c of site.channels) { - (channelSites[c.slug] ??= []).push(site.siteId); + for (const slug of membersOf(site)) { + (channelSites[slug] ??= []).push(site.siteId); } } return channelSites; @@ -79,7 +91,15 @@ export async function buildPoolSummary(opts: { // side effect, which is harmless. await buildStats({ paths, wholePoolStatsDir: statsDir }); const sites = listSites(paths); - const channelSites = channelSitesOf(sites); + // The members each site's build publishes (lib/postsVisibility.ts). + const configs = new Map<string, Awaited<ReturnType<typeof readChannelConfig>>>(); + for (const slug of new Set(sites.flatMap((s) => s.channels.map((c) => c.slug)))) { + configs.set(slug, await readChannelConfig(paths, slug).catch(() => null)); + } + const settings = getSettings(); + const channelSites = channelSitesOf(sites, (site) => + publishedMemberSlugs(site, (slug) => configs.get(slug), settings), + ); const stats = await readStatsPages(statsDir); const summary = buildHomepageSummary( stats, diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -116,6 +116,14 @@ test("builtHubProblem accepts only a hub bundle", () => { builtHubProblem(none.dir), "export/out holds no hub build — build the hub first", ); + + // Release 17 XP: a hub bundle composed over a site's data is refused. + mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true }); + mkdirSync(path.join(hub.dir, "summaries")); + assert.equal( + builtHubProblem(hub.dir), + "export/out holds a hub build that still carries a site's data (summaries, posts) — build the hub again", + ); } finally { hub.cleanup(); site.cleanup(); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -98,6 +98,63 @@ export function builtBundleProblem(outDir: string, siteId: string): string | nul return null; } +/** + * Why `site` may not be deployed because of who it is built for, as one + * sentence — or null when it may (release 17 slice XP). + * + * A PRIVATE site (`site.json` `audience: "private"`) is the operator's own + * reading copy: it may carry what the public may not (X posts while + * `social.x.visibility` is "private"), so no deploy path ships it. Every + * deploy path asks this BEFORE ANY UPLOAD — the R2 archive push included — in + * the place it asks builtBundleProblem: runDeployIntoLog, the container deploy + * phase, deploySite, and the editor's Build & deploy, Deploy and Build & + * deploy all. A build without a deploy is untouched. + */ +export function siteDeployProblem(site: { + siteId: string; + audience?: string; +}): string | null { + if (site.audience !== "private") return null; + return ( + `Site "${site.siteId}" is private (audience: private): it is built for reading ` + + `on this machine and is never deployed. Build it without deploying, or set its ` + + `audience to public on its Settings tab` + ); +} + +/** + * Why the bundle in `outDir` may not be deployed because it was built PRIVATE + * — its corpus.json says `"audience": "private"` (compose-site writes it for a + * private site) — as one sentence, or null. Asked beside siteDeployProblem, so + * a site switched to public after a private build still cannot ship that + * build: it is rebuilt first. + */ +export function builtAudienceProblem(outDir: string): string | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8")); + const site = (parsed as { site?: { audience?: unknown; id?: unknown } } | null)?.site; + if (site?.audience !== "private") return null; + const id = typeof site.id === "string" ? ` of "${site.id}"` : ""; + return ( + `${outDir} holds a private build${id} (its corpus.json says "audience": "private"), ` + + `which is never deployed` + ); + } catch { + return null; + } +} + +/** + * Both audience refusals, the site's first: the one sentence a deploy path + * logs or throws, or null. + */ +export function deployAudienceProblem( + site: { siteId: string; audience?: string }, + outDir: string, +): string | null { + return siteDeployProblem(site) ?? builtAudienceProblem(outDir); +} + // corpus.json's `site.id`, or null when there is no readable one. function corpusSiteIdIn(outDir: string): string | null { try { @@ -128,9 +185,23 @@ export function builtHubProblem(outDir: string): string | null { if (!existsSync(path.join(outDir, "hub-sites.json"))) { return "export/out holds no hub build — build the hub first"; } + // The hub holds no site's data (compose-hub removes it): a hub bundle that + // still carries a site's data trees was composed over one, and could ship + // that site's posts — a private site's included. + const carried = HUB_FORBIDDEN_TREES.filter((tree) => existsSync(path.join(outDir, tree))); + if (carried.length > 0) { + return ( + `export/out holds a hub build that still carries a site's data (${carried.join(", ")}) — ` + + `build the hub again` + ); + } return null; } +// The per-site data trees a hub bundle must never carry (the trees of +// compose-hub's SITE_ONLY_PUBLIC_ENTRIES). +const HUB_FORBIDDEN_TREES = ["summaries", "transcripts", "subs", "posts", "digests", "stats", "archives"]; + /** * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as * the homepage, as one sentence, or null when it holds a build. diff --git a/common/lib/corpus.ts b/common/lib/corpus.ts @@ -175,6 +175,9 @@ export type SiteCorpus = { description: string; url?: string; hubUrl?: string; + // Present only on a PRIVATE site's build (site.json `audience`, release 17 + // slice XP): the operator's own reading copy, which no deploy path ships. + audience?: "private"; }; totals: { channels: number; videos: number }; channels: CorpusChannel[]; @@ -229,6 +232,9 @@ export function buildSiteCorpus( // visible tag has a non-zero count here). Absent/false leaves corpus.json // shaped as before apart from the spec bump. hasTags?: boolean; + // A private site's build (site.json `audience: "private"`): corpus.json's + // `site.audience` says so. Absent/false leaves corpus.json as before. + private?: boolean; }, ): SiteCorpus { const base = descriptor.siteUrl; @@ -268,6 +274,7 @@ export function buildSiteCorpus( description: descriptor.siteDescription, ...(descriptor.siteUrl ? { url: descriptor.siteUrl } : {}), ...(descriptor.hubUrl ? { hubUrl: descriptor.hubUrl } : {}), + ...(opts.private ? { audience: "private" as const } : {}), }, totals: { channels: channels.length, videos }, channels, diff --git a/common/lib/postsVisibility.test.ts b/common/lib/postsVisibility.test.ts @@ -0,0 +1,99 @@ +// The one rule for which sites an X channel's posts are built into (release 17 +// slice XP). The build that applies it is pinned by +// bin/compose-site.postsVisibility.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isXPostsChannel, + postsVisibleTo, + publishedMemberSlugs, + xPostsVisibility, +} from "./postsVisibility"; +import { isListedSite, parseSite, siteToDisk } from "./siteSchema"; +import { sanitizeSocial } from "../social/xCookieSource"; + +const X = { sourceKind: "social" as const, platform: "twitter" as const }; +const BLUESKY = { sourceKind: "social" as const, platform: "bluesky" as const }; +const VIDEOS = { platform: "youtube" as const }; +const PRIVATE_X = { social: { x: { visibility: "private" } } }; +const PUBLIC_X = { social: { x: { visibility: "public" } } }; +const publicSite = { audience: undefined }; +const privateSite = { audience: "private" as const }; + +test("an X channel is a social channel on platform twitter, and nothing else is", () => { + assert.equal(isXPostsChannel(X), true); + assert.equal(isXPostsChannel(BLUESKY), false); + assert.equal(isXPostsChannel(VIDEOS), false); + // A video channel on X (were there one) is not a posts channel. + assert.equal(isXPostsChannel({ platform: "twitter" }), false); + assert.equal(isXPostsChannel(null), false); + assert.equal(isXPostsChannel(undefined), false); +}); + +test("social.x.visibility: absent and unknown read as public", () => { + assert.equal(xPostsVisibility({}), "public"); + assert.equal(xPostsVisibility({ social: { x: {} } }), "public"); + assert.equal(xPostsVisibility({ social: { x: { visibility: "hidden" } } }), "public"); + assert.equal(xPostsVisibility(PUBLIC_X), "public"); + assert.equal(xPostsVisibility(PRIVATE_X), "private"); +}); + +test("public: X posts go wherever the channel is a member", () => { + for (const settings of [{}, PUBLIC_X]) { + assert.equal(postsVisibleTo(publicSite, X, settings), true); + assert.equal(postsVisibleTo(privateSite, X, settings), true); + } +}); + +test("private: X posts go to private sites only; every other channel is untouched", () => { + assert.equal(postsVisibleTo(publicSite, X, PRIVATE_X), false); + assert.equal(postsVisibleTo({}, X, PRIVATE_X), false); + assert.equal(postsVisibleTo(privateSite, X, PRIVATE_X), true); + for (const site of [publicSite, privateSite]) { + assert.equal(postsVisibleTo(site, BLUESKY, PRIVATE_X), true); + assert.equal(postsVisibleTo(site, VIDEOS, PRIVATE_X), true); + assert.equal(postsVisibleTo(site, null, PRIVATE_X), true); + } +}); + +test("publishedMemberSlugs narrows the membership, in its order", () => { + const configs: Record<string, object> = { vids: VIDEOS, x: X, sky: BLUESKY }; + const channels = [{ slug: "x" }, { slug: "vids" }, { slug: "sky" }, { slug: "gone" }]; + const configOf = (slug: string) => configs[slug] as typeof X | undefined; + assert.deepEqual( + publishedMemberSlugs({ channels }, configOf, PRIVATE_X), + ["vids", "sky", "gone"], + ); + assert.deepEqual( + publishedMemberSlugs({ channels, audience: "private" }, configOf, PRIVATE_X), + ["x", "vids", "sky", "gone"], + ); + assert.deepEqual( + publishedMemberSlugs({ channels }, configOf, {}), + ["x", "vids", "sky", "gone"], + ); +}); + +test("site.json audience: only private is kept and written; a private site is never listed", () => { + assert.equal(parseSite("a", {}).audience, undefined); + assert.equal(parseSite("a", { audience: "public" }).audience, undefined); + assert.equal(parseSite("a", { audience: "secret" }).audience, undefined); + const priv = parseSite("a", { audience: "private" }); + assert.equal(priv.audience, "private"); + assert.equal(siteToDisk(priv).audience, "private"); + assert.equal("audience" in siteToDisk(parseSite("a", {})), false); + assert.equal(isListedSite(priv), false); + assert.equal(isListedSite({ ...priv, listed: true }), false); + assert.equal(isListedSite(parseSite("a", {})), true); +}); + +test("sanitizeSocial keeps visibility beside cookieSource and drops an unknown one", () => { + assert.deepEqual(sanitizeSocial({ x: { visibility: "private" } }), { + x: { visibility: "private" }, + }); + assert.deepEqual( + sanitizeSocial({ x: { cookieSource: "browser", visibility: "public" } }), + { x: { cookieSource: "browser", visibility: "public" } }, + ); + assert.deepEqual(sanitizeSocial({ x: { visibility: "nobody" } }), { x: {} }); +}); diff --git a/common/lib/postsVisibility.ts b/common/lib/postsVisibility.ts @@ -0,0 +1,75 @@ +// WHICH SITES A CHANNEL'S POSTS ARE BUILT INTO (release 17 slice XP). +// +// THE ONE RULE, asked by both places a site's posts are decided: +// - the index build's per-site loop (controller/buildIndex.ts), which writes +// each site's summaries, subs, posts and digests manifests from the site's +// member channels; +// - the site compose (bin/compose-site.ts), which copies the shared +// per-channel trees (transcripts, subs, posts, digests) of those members +// into the served public dir and writes /site.json and /corpus.json. +// Both narrow the site's members through `publishedMemberSlugs`, so the +// manifests and the trees can never disagree about a channel. +// +// The rule: with `social.x.visibility` "private", an X channel (a social +// channel on platform "twitter") is built only into a PRIVATE site +// (`site.json` `audience: "private"`). Posts are all a social channel holds — it +// has no videos (buildIndex's scan skips it) — so a public site leaves the +// channel out whole: no posts tree, no posts-manifest entry, no empty channel +// in its channel list or its corpus.json. Every other channel, and every +// channel on a private site, is unaffected. Nothing on disk changes and +// fetching does not: the shared posts tree (exportSharedPostsDir) and the LMDB +// posts sub-DB are corpus-wide and keep every channel, which is what a private +// site and the MCP over a private build read. +// +// The Search in row's Posts toggle needs no rule of its own: it shows only +// when the site's posts manifest lists a channel (SearchSessionContext +// hasPostsCorpus), so a public site whose only posts were X posts ships an +// empty posts manifest and no Posts toggle. +// +// Pure: no fs, no settings read. The callers pass the settings and the configs. + +import { isSocialChannel, type ChannelConfig } from "./channelConfig"; +import { isPrivateSite, type Site } from "./siteSchema"; +import type { XPostsVisibility } from "../social/xCookieSource"; + +// An X channel: the posts of a social channel whose platform is X. +export function isXPostsChannel( + config: Pick<ChannelConfig, "sourceKind" | "platform"> | null | undefined, +): boolean { + return isSocialChannel(config) && config?.platform === "twitter"; +} + +// `social.x.visibility`, resolved: absent (or anything unknown) is "public". +export function xPostsVisibility(settings: { + social?: { x?: { visibility?: unknown } }; +}): XPostsVisibility { + return settings.social?.x?.visibility === "private" ? "private" : "public"; +} + +// Whether `site` may carry the posts of the channel `config` describes. A +// channel with no readable config is not an X channel as far as this rule +// knows, and is left to whatever already decides its fate. +export function postsVisibleTo( + site: Pick<Site, "audience">, + config: Pick<ChannelConfig, "sourceKind" | "platform"> | null | undefined, + settings: { social?: { x?: { visibility?: unknown } } }, +): boolean { + if (!isXPostsChannel(config)) return true; + if (xPostsVisibility(settings) === "public") return true; + return isPrivateSite(site); +} + +// The site's member slugs, in membership order, less the channels whose posts +// it may not carry (an X channel holds nothing else). `configOf` answers a +// slug's channel config, or null/undefined when it has none. +export function publishedMemberSlugs( + site: Pick<Site, "audience" | "channels">, + configOf: ( + slug: string, + ) => Pick<ChannelConfig, "sourceKind" | "platform"> | null | undefined, + settings: { social?: { x?: { visibility?: unknown } } }, +): string[] { + return site.channels + .map((c) => c.slug) + .filter((slug) => postsVisibleTo(site, configOf(slug), settings)); +} diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -116,6 +116,15 @@ export const X_SOCIAL_SETTINGS_FIELD_DOCS: FieldDocs<XSocialSettings> = { "stored: `\"browser\"` when `cookiesFromBrowser` is set and no profile is connected (no " + "exported jar carrying an auth_token), else `\"profile\"`. The source, not `cookieMode`, " + "governs the X fetchers.", + visibility: + "Where X posts may appear. `\"public\"` (the default; absent): an X channel's posts are " + + "built into every site that has the channel. `\"private\"`: every X channel's posts (a " + + "channel with `sourceKind: \"social\"` and `platform: \"twitter\"`) are left out of every " + + "PUBLIC site build — the channel with them, since posts are all an X channel holds — and " + + "built only into PRIVATE sites (`site.json` `audience`). Nothing on disk changes and " + + "fetching does not; a site already published changes on its next build and deploy, and " + + "flipping back is a rebuild. Chosen in the X account session section of /settings; the " + + "rule is common/lib/postsVisibility.ts.", }; export type { AutoQueueSettings } from "./autoQueueTypes"; export type { ChannelPriority } from "./channelPriority"; @@ -1477,7 +1486,7 @@ export const siteSettingsSchema = z.object({ "How yt-dlp invocations use the configured cookies (see common/lib/cookiePolicy.ts): \"always\" passes them on every invocation, \"when-required\" (default; the historical behavior) only to retry an auth/age failure, \"defer\" never in normal runs — auth-gated videos are excluded from batches and collected into the per-channel \"Needs cookies\" bucket for a manual cookie run. Per-channel override available (ChannelConfig.cookieMode).", ), social: settingsField((v): SocialSettings => sanitizeSocial(v)).describe( - "Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts.", + "Per-platform settings of the social posts. Today two keys, both X's, both chosen in the X account session section of /settings: where the X fetchers' login comes from (`social.x.cookieSource`) and where X posts may appear (`social.x.visibility`). See common/social/xCookieSource.ts.", ), sleepBetweenDownloadsSeconds: settingsField((v): number => clampSleepBetweenDownloadsSeconds(v)).describe( "Pause (seconds) inserted between per-video yt-dlp invocations in managed batch downloads. yt-dlp's own `-t sleep` only paces requests within one invocation, so without this the managed loop hammers the source IP back-to-back. 0 disables. Per-channel override available.", diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -14,6 +14,7 @@ import { socialLinksForSave } from "./socialLinks"; import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server"; import { isListedSite, + isPrivateSite, isValidSiteId, parseSite, parseSiteUrl, @@ -87,11 +88,14 @@ export function siteStatsDir(paths: Paths, siteId: string): string { } // The hub URL this site points visitors toward: its own override, else the -// family default (SiteSettings.homepageUrl). Undefined when neither is set. +// family default (SiteSettings.homepageUrl). Undefined when neither is set — +// and always for a PRIVATE site (`audience: "private"`), which belongs under no +// hub: a hub tells its members by the hubUrl they publish. export function resolveHubUrl( site: Site, settings: SiteSettings = getSettings(), ): string | undefined { + if (isPrivateSite(site)) return undefined; return site.hubUrl ?? parseSiteUrl(settings.homepageUrl); } diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -68,6 +68,27 @@ export const RELATED_SITE_GROUP_FIELD_DOCS: FieldDocs<RelatedSiteGroup> = { "Sibling site ids, in display order. Invalid and repeated ids are dropped, and a group left with none is dropped. Ids are resolved against the live pool at render time, so an id for a site that does not exist (yet) is harmless — it is skipped.", }; +// Who a site is built for (release 17 slice XP). "public" (the default, never +// written) is every site there has ever been. "private" is the operator's own +// reading copy: never deployed (publish/build.ts asks siteDeployProblem in +// lib/builtExport.ts before any upload), never listed (isListedSite below), +// publishing no hubUrl (lib/site.ts resolveHubUrl), and the only kind of site +// that content kept from the public (X posts while `social.x.visibility` is +// "private", lib/postsVisibility.ts) is built into. +export type SiteAudience = "public" | "private"; + +export const SITE_AUDIENCES: readonly SiteAudience[] = ["public", "private"]; + +export function isSiteAudience(v: unknown): v is SiteAudience { + return v === "public" || v === "private"; +} + +// THE ONE PREDICATE for a private site. Absent or anything but "private" reads +// as public. +export function isPrivateSite(site: Pick<Site, "audience">): boolean { + return site.audience === "private"; +} + // A Site is a selection + presentation layer over the single global channel // pool. Each field is documented in SITE_FIELD_DOCS below. export type Site = { @@ -85,6 +106,7 @@ export type Site = { accent?: string; siteUrl?: string; listed?: boolean; + audience?: SiteAudience; relatedSites?: RelatedSiteGroup[]; pwa?: boolean; archives?: boolean; @@ -119,6 +141,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = { "Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.", listed: "Whether the family lists this site. Opt-OUT: absent/true = listed, only an explicit `false` is written. An unlisted site still builds and deploys as before, and its own pages are unchanged; it is left out of the homepage (cards, chart, `/stats`), the hub (members, federated search, `/corpus.json`, `/llms.txt`), every other site's footer, and the published `channel-sites.json` and pooled `stats/`. A channel only unlisted sites expose is in none of the family's public totals; a channel a listed site also exposes is credited to the listed one.", + audience: + 'Who this site is built for. `"public"` (the default; absent) or `"private"`: the operator\'s own reading copy, built on this machine and never deployed — every deploy path (Build & deploy, Deploy, `archilyzer deploy site`, Build & deploy all, docker/publish-site.sh) refuses it before any upload, while a build without a deploy still works. A private site is never listed (as `listed: false`, whatever `listed` says), publishes no `hubUrl`, and its `/corpus.json` says `"audience": "private"`. Content kept from the public — X posts while `social.x.visibility` is `"private"` — is built only into private sites. Only `"private"` is written.', relatedSites: "Pulls specific siblings to the front of the footer's cross-site list, in named groups. Siblings not named here fall into a trailing \"Other sites\" group. Absent/empty = one flat list of every sibling.", pwa: @@ -150,8 +174,11 @@ export function isValidSiteId(id: unknown): id is string { // (lib/site.ts resolveRelatedSites). The editor's own pages list every site. // Here, beside the key, and exported from lib/site like isValidSiteId, so the // pure summary builder can use it without importing file I/O. -export function isListedSite(site: Pick<Site, "listed">): boolean { - return site.listed !== false; +// +// A PRIVATE site (`audience: "private"`) is never listed, whatever `listed` +// says: it is never deployed, so there is nothing at its URL to list. +export function isListedSite(site: Pick<Site, "listed" | "audience">): boolean { + return site.listed !== false && !isPrivateSite(site); } // The channels whose content belongs to unlisted sites alone: exposed by at @@ -160,7 +187,7 @@ export function isListedSite(site: Pick<Site, "listed">): boolean { // and a channel no site exposes (pool-only) is not here either — the family's // instance-wide totals have always counted it. export function channelsOnlyOnUnlistedSites( - sites: readonly Pick<Site, "listed" | "channels">[], + sites: readonly Pick<Site, "listed" | "audience" | "channels">[], ): Set<string> { const onListed = new Set<string>(); const onUnlisted = new Set<string>(); @@ -281,6 +308,10 @@ export const siteFieldsSchema = z.object({ siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl), // Opt-out: only an explicit false unlists. Absent/true stays listed. listed: settingsField((v): boolean => v !== false).describe(d.listed), + // Only "private" is kept; absent (and anything else) is the public default. + audience: settingsField((v): SiteAudience | undefined => + v === "private" ? "private" : undefined, + ).describe(d.audience), relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites), pwa: settingsField((v): boolean => v === true).describe(d.pwa), // Opt-out: only an explicit false disables. Absent/true stays on. @@ -372,6 +403,8 @@ export function siteToDisk(site: Site): Site { ...(siteUrl ? { siteUrl } : {}), // Listed is the default: only the opt-out is persisted. ...(site.listed === false ? { listed: false } : {}), + // Public is the default: only the private audience is persisted. + ...(isPrivateSite(site) ? { audience: "private" as const } : {}), ...(relatedSites.length > 0 ? { relatedSites } : {}), ...(site.pwa ? { pwa: true } : {}), // Persist only the non-default: archives is on unless explicitly disabled. diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -23,6 +23,7 @@ import { homepageDeployArgs, homepageOutDir, deployHomepage, + deploySite, dockerSiteOutDir, dockerSiteStagingDir, resolveOutDir, @@ -393,3 +394,120 @@ test("runDeployIntoLog refuses a bundle that is not the site's own before wrangl rmSync(root, { recursive: true, force: true }); } }); + +// A PRIVATE site (site.json `audience: "private"`, release 17 slice XP) is never +// deployed, and neither is a bundle built private (its corpus.json says so): +// refused at the same door as the wrong-site bundle, before wrangler, in words +// naming the audience. The fake `pnpm` is the test above's. +function writePrivateBundle(dir: string, siteId: string): void { + writeBundle(dir, siteId, siteId); + writeFileSync( + path.join(dir, "corpus.json"), + JSON.stringify({ site: { id: siteId, audience: "private" } }), + ); +} + +test("runDeployIntoLog refuses a private site and a private build before wrangler", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "deploy-private-")); + const bin = path.join(root, "bin"); + const argvFile = path.join(root, "pnpm-argv"); + mkdirSync(bin); + writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh\nprintf '%s\\n' "$@" >> '${argvFile}'\n`); + chmodSync(path.join(bin, "pnpm"), 0o755); + const savedPath = process.env.PATH; + process.env.PATH = bin; + const signal = new AbortController().signal; + const testPaths = { ...paths, exportDir: root } as Paths; + try { + await runChildIntoLog(() => {}, signal, { command: "pnpm", args: ["--fake?"], cwd: root, env: { ...process.env } }); + assert.equal(readFileSync(argvFile, "utf8"), "--fake?\n"); + rmSync(argvFile); + + // The site is private: its own, well-formed bundle is still refused. + const own = path.join(root, "own", "out"); + writeBundle(own, "mine", "mine"); + const priv = { siteId: "mine", cloudflareProject: "w3c-never-real", audience: "private" } as Site; + let log: string[] = []; + assert.equal(await runDeployIntoLog((l) => log.push(l), signal, priv, own, testPaths), 1); + assert.equal(log.length, 1, log.join("")); + assert.match( + log[0], + /^\[deploy\] REFUSED — Site "mine" is private \(audience: private\): it is built for reading on this machine and is never deployed\./, + ); + assert.match(log[0], /Nothing was sent to Cloudflare Pages\.\n$/); + + // The site is public now, but the bundle was built private. + const built = path.join(root, "built", "out"); + writePrivateBundle(built, "mine"); + log = []; + const pub = { siteId: "mine", cloudflareProject: "w3c-never-real" } as Site; + assert.equal(await runDeployIntoLog((l) => log.push(l), signal, pub, built, testPaths), 1); + assert.match(log[0], /holds a private build of "mine" \(its corpus\.json says "audience": "private"\)/); + assert.equal(existsSync(argvFile), false, "pnpm was spawned"); + } finally { + process.env.PATH = savedPath; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runDockerDeployAllPhase skips a private site before the upload, in the audience's words", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "deploy-all-private-")); + try { + const outFor = (id: string) => path.join(root, id, "out"); + writeBundle(outFor("mine"), "mine", "mine"); + writePrivateBundle(outFor("built"), "built"); + const log: string[] = []; + // A Cloudflare project on each: without the audience check the run would + // reach the upload, which the log would show. + const sites = [ + { siteId: "mine", audience: "private", cloudflareProject: "w3c-never-real" }, + { siteId: "built", cloudflareProject: "w3c-never-real" }, + ] as Site[]; + const outcomes = await runDockerDeployAllPhase( + (l) => log.push(l), + new AbortController().signal, + sites, + new Set(["mine", "built"]), + { ...paths, exportBuildsDir: root } as Paths, + outFor, + ); + assert.deepEqual(outcomes.map((o) => [o.siteId, o.status]), [["mine", "skipped"], ["built", "skipped"]]); + assert.match(outcomes[0].reason!, /^Site "mine" is private \(audience: private\)/); + assert.match(outcomes[1].reason!, /private build of "built"/); + assert.ok(log.some((l) => l.startsWith("[mine] deploy skipped — Site \"mine\" is private")), log.join("\n")); + assert.ok(!log.some((l) => l.startsWith("=== Deploy")), "nothing reached the deploy"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("deploySite (archilyzer deploy site) refuses a private site before anything, and a private build before the upload", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "deploy-site-private-")); + try { + const sitesDir = path.join(root, "sites"); + const site = (id: string, extra: Record<string, unknown> = {}) => { + mkdirSync(path.join(sitesDir, id), { recursive: true }); + writeFileSync( + path.join(sitesDir, id, "site.json"), + JSON.stringify({ siteId: id, cloudflareProject: "w3c-never-real", ...extra }), + ); + }; + site("mine", { audience: "private" }); + site("other"); + const testPaths = { ...paths, exportDir: root, sitesDir } as Paths; + const log: string[] = []; + await assert.rejects( + deploySite("mine", { paths: testPaths, onLog: (l) => log.push(l) }), + /^Error: Site "mine" is private \(audience: private\): it is built for reading on this machine and is never deployed\. Build it without deploying, or set its audience to public on its Settings tab\.$/, + ); + // Public, but export/out holds a private build of it. + writePrivateBundle(path.join(root, "out"), "other"); + await assert.rejects( + deploySite("other", { paths: testPaths, onLog: (l) => log.push(l) }), + /private build of "other".*Build other again, then deploy\.$/, + ); + assert.deepEqual(log, [], "nothing was logged: no upload, no deploy"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -14,7 +14,14 @@ import { createReadStream, existsSync } from "node:fs"; import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { runChildIntoLog } from "../jobs/runChild"; -import { builtBundleProblem, builtHubProblem, builtSiteProblem } from "../lib/builtExport"; +import { + builtAudienceProblem, + builtBundleProblem, + builtHubProblem, + builtSiteProblem, + deployAudienceProblem, + siteDeployProblem, +} from "../lib/builtExport"; import { getHomepageConfig } from "../lib/homepage"; import { deploymentUrlIn, @@ -340,6 +347,16 @@ export async function runDeployIntoLog( // job (a build of another site, the hub) can rewrite export/out. Nothing runs // between this check and the spawn. The hub and the homepage deploy through // runPagesDeployIntoLog and never come here. + // + // A PRIVATE site, or a bundle built private, is refused first: it is never + // deployed, whatever the bundle's identity (deployAudienceProblem). + const audienceProblem = deployAudienceProblem(site, outDir); + if (audienceProblem) { + onLog( + `[deploy] REFUSED — ${audienceProblem}. Nothing was sent to Cloudflare Pages.\n`, + ); + return 1; + } const bundleProblem = builtBundleProblem(outDir, site.siteId); if (bundleProblem) { onLog( @@ -604,10 +621,21 @@ export async function runDockerDeployAllPhase( outcomes.push({ siteId: site.siteId, status: "skipped", reason: "build failed" }); continue; } - // The bundle must be this site's own before anything else is asked of it — - // the check build-site.sh makes before it hands the bundle back, made again - // over whatever the per-site dir holds now. First, so that nothing past it - // (the R2 upload, the Pages deploy) is ever reached with another site's data. + // A private site (or a private build) is not deployed at all: skipped, in + // its own words, so a family with one private site does not fail every run. + // Asked first; a per-site dir holding another site's bundle built private + // therefore reads "skipped" rather than "REFUSED" — never shipped either way. + // + // Then the bundle must be this site's own — the check build-site.sh makes + // before it hands the bundle back, made again over whatever the per-site dir + // holds now — before anything past it (the R2 upload, the Pages deploy) is + // reached with another site's data. + const audienceProblem = deployAudienceProblem(site, outDirFor(site.siteId)); + if (audienceProblem) { + onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`); + outcomes.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem }); + continue; + } const bundleProblem = builtBundleProblem(outDirFor(site.siteId), site.siteId); if (bundleProblem) { onLog(`[${site.siteId}] deploy REFUSED — ${bundleProblem}`); @@ -753,6 +781,9 @@ export async function deploySite( } const branch = opts.previewBranch?.trim() || undefined; const site = getSite(siteId.trim(), paths); + // Before anything else is asked of a private site: it is never deployed. + const privateProblem = siteDeployProblem(site); + if (privateProblem) throw new Error(`${privateProblem}.`); if (!site.cloudflareProject) { throw new Error( `Site "${site.siteId}" has no Cloudflare Pages project configured.`, @@ -761,6 +792,9 @@ export async function deploySite( const outDir = resolveOutDir(site.siteId, paths); const builtProblem = builtSiteProblem(outDir, site.siteId); if (builtProblem) throw new Error(builtProblem); + // Before the R2 upload below: a bundle built private is never deployed. + const builtPrivate = builtAudienceProblem(outDir); + if (builtPrivate) throw new Error(`${builtPrivate}. Build ${site.siteId} again, then deploy.`); // The production path logs no banner and gains none here: its log has // always opened on wrangler's own first line. if (branch) { @@ -892,7 +926,9 @@ export async function composeHub(opts: PublishOpts = {}): Promise<number> { /** * Build the hub into export/out. Removes public/site.json first — a site's * compose left it there, and a hub bundle carrying one would read as that - * site's (builtExport.ts). Returns the exit code. + * site's (builtExport.ts). compose-hub then removes every other per-site entry + * a site's compose left in public/ (SITE_ONLY_PUBLIC_ENTRIES), so the hub + * never ships a site's data. Returns the exit code. */ export async function buildHub(opts: PublishOpts = {}): Promise<number> { const { paths, onLog, signal } = resolved(opts); diff --git a/common/social/xCookieSource.ts b/common/social/xCookieSource.ts @@ -27,11 +27,28 @@ export function isXCookieSource(v: unknown): v is XCookieSource { return v === "browser" || v === "profile"; } -// The `social` block of settings.json. Only X has a login to choose today; the -// block is per platform so a second one does not need a second top-level key. +// WHERE X POSTS MAY APPEAR — `social.x.visibility` (release 17 slice XP). +// "public" — the default (absent): an X channel's posts are built into every +// site that has the channel, as every other channel's are. +// "private" — an X channel's posts are left out of every PUBLIC site build and +// built only into PRIVATE sites (`site.json` `audience`). Nothing +// on disk changes, and fetching does not; flipping back is a +// rebuild. The rule itself is lib/postsVisibility.ts. +export type XPostsVisibility = "public" | "private"; + +export const X_POSTS_VISIBILITIES: readonly XPostsVisibility[] = ["public", "private"]; + +export function isXPostsVisibility(v: unknown): v is XPostsVisibility { + return v === "public" || v === "private"; +} + +// The `social` block of settings.json. Only X has settings today; the block is +// per platform so a second one does not need a second top-level key. export type XSocialSettings = { // Absent = the read-time default above. cookieSource?: XCookieSource; + // Absent = "public". + visibility?: XPostsVisibility; }; export type SocialSettings = { @@ -39,7 +56,8 @@ export type SocialSettings = { }; // Total over `unknown`, as every settings coercion is: anything that is not a -// known source reads as absent (the default), and unknown keys are dropped. +// known source or visibility reads as absent (the default), and unknown keys +// are dropped. export function sanitizeSocial(value: unknown): SocialSettings { const r = (value && typeof value === "object" && !Array.isArray(value) ? value @@ -48,7 +66,10 @@ export function sanitizeSocial(value: unknown): SocialSettings { ? r.x : {}) as Record<string, unknown>; return { - x: isXCookieSource(x.cookieSource) ? { cookieSource: x.cookieSource } : {}, + x: { + ...(isXCookieSource(x.cookieSource) ? { cookieSource: x.cookieSource } : {}), + ...(isXPostsVisibility(x.visibility) ? { visibility: x.visibility } : {}), + }, }; } diff --git a/docker/publish-site.sh b/docker/publish-site.sh @@ -29,6 +29,20 @@ if [ -z "${SITE_ID}" ]; then fi export SITE_ID + +# A PRIVATE site (site.json `audience: "private"`) is never deployed, and the +# volume this script fills is what the `site` service serves: refused before +# the build, and again over the built corpus.json below (lib/builtExport.ts). +# Building it without publishing is `archilyzer build site <id>`. +SITE_JSON="${SITES_DIR:-${TRANSCRIPTS_DIR:-/data/transcripts}/sites}/${SITE_ID}/site.json" +refuse_private() { + echo "[publish-site] REFUSED — site '${SITE_ID}' is private (audience: private): it is built for reading on this machine and is never deployed. Nothing was published to ${SITE_OUT}. Build it without publishing: pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build site ${SITE_ID}" >&2 + exit 1 +} +if grep -Eq '"audience"[[:space:]]*:[[:space:]]*"private"' "${SITE_JSON}" 2>/dev/null; then + refuse_private +fi + cd /repo echo "[publish-site] building '${SITE_ID}'" @@ -37,6 +51,9 @@ echo "[publish-site] building '${SITE_ID}'" pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build site "${SITE_ID}" [ -d /repo/export/out ] || { echo "[publish-site] no export/out after build" >&2; exit 1; } +if grep -Eq '"audience"[[:space:]]*:[[:space:]]*"private"' /repo/export/out/corpus.json 2>/dev/null; then + refuse_private +fi echo "[publish-site] publishing -> ${SITE_OUT}" mkdir -p "${SITE_OUT}" diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,8 +1,14 @@ # Changelog ## [Unreleased] -- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. A manifest without `render.chrome` builds exactly as before, byte for byte. -- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear two seconds apart, stack down a column at the footage's top right, and leave together in the change to the next clip; when the column is full the oldest slide up and out. Each card shows the post's date, `@handle · Bluesky` (or X), its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The timing, the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither. A cut without `posts` builds exactly as before, and without the deck `posts` is not drawn at all. +- **umtool can keep each report's render folder on a media drive.** With `UMTOOL_MEDIA_DIR` set, in umtool's environment (restart umtool after setting it), to a directory inside that drive, a report project's `out/` (its fetched windows, segments and finished video) is a link to the same path under that directory: a project's first build makes it there, and `umtool storage move-out <project>` (or `--all`) moves an existing one, copying it, checking the copy and only then leaving the link; `--dry-run` says how much would move, and `umtool storage move-back` brings one home. The manifest, its revisions, notes and sources stay where they are, and nothing in umtool reads a project differently. When the drive is not mounted, a build or source check refuses and says so instead of starting a new folder on the main disk; umtool never creates the media directory itself. `umtool storage` lists where each project's `out/` is. With `UMTOOL_MEDIA_DIR` unset nothing changes. +- **umtool's cache moves to `~/.cache/archilyzer/umtool`** (`$XDG_CACHE_HOME/archilyzer/umtool` when that is set, or `UMTOOL_CACHE_DIR`). It was inside the song project's data folder, so it followed that folder onto whatever drive it was on. Run `umtool index` once after updating to rebuild the project index in its new place; umtool works without it, only slower, and the rest of the cache is remade as it is needed. `umtool doctor` now also shows the reports, media and cache folders, and the old cache folder while it is still there; it can be deleted. +- **umtool's report videos keep every clip's sound on its picture.** In a crossfaded cut each clip's audio was placed by the audio's own length and its picture by the picture's, and an encoded clip's audio is routinely a few to twenty milliseconds shorter or longer than its video, so the sound drifted further ahead clip by clip: by the end of a seventeen-clip cut it was a third of a second early, and two seconds on one with title and sources cards. Each clip's sound is now padded or trimmed to exactly its picture's length before the crossfade. Every crossfaded report video changes when it is rebuilt, and is in sync; a hard-cut video was not affected. +- **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. The deck changes nothing, byte for byte, in a cut whose manifest has no `render.chrome`. +- **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. `posts` changes nothing in a cut that has none, and without the deck it is not drawn at all. +- **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it. +- **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV. +- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. - **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path. - **`archilyzer doctor` checks the image Build all builds sites in.** When a container engine answers, a new **build image** section says whether the image named under **Settings → Build pipeline** is there, when it was built and how big it is. It warns when the image is missing, or older than the last change to its Dockerfile, and prints the one command that rebuilds it. Build all still builds or refreshes the image itself before it builds any site; the warning tells you ahead of time that the next Build all will spend that time. With no container engine the check is skipped in one line, and with no corpus it is only a note. It never fails the doctor. - **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site. @@ -16,6 +22,10 @@ - **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default). - **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of abc123 is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied — and a job you have just cancelled counts until it has actually stopped ("is stopping … — wait for it to stop"); **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. Every file removed from a copy is listed in the move's log, and **Preview** says so when a copy from an earlier attempt is already there. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor. - **Connecting an X account opens your own browser, and the X fetchers can use your everyday browser's X login instead.** **Settings → X account session → Connect X account** used to open Playwright's bundled Chromium with its automation signals on (the "controlled by automated test software" bar, `navigator.webdriver`): Google's sign-in refused it and X's own login form stalled in it. It now opens your Chromium or Chrome when one is installed (`ARCHILYZER_X_BROWSER` names another; Playwright's bundled Chromium otherwise), without those signals. Google's sign-in may still refuse an embedded browser; X's password login is the reliable path. A new **Login source** choice (`social.x.cookieSource` in `settings.json`) says where the X fetchers' login comes from: **Browser login** hands gallery-dl `--cookies-from-browser` with your `cookiesFromBrowser` on every fetch, so the login lasts as long as you stay logged in to x.com in that browser and no window is needed; **Connected profile** is the session broker, as before. Left on **Automatic**, it is the browser login when `cookiesFromBrowser` is set and no profile is connected, and the profile otherwise. **Check** says which source is in use, whether an X login is visible in it and when it was last used (the browser's cookies are read from a private copy, never written; this reads Firefox's, and gallery-dl reads Chromium's itself). Needs a rebuild and restart of the editor. +- **X posts can be kept off every public site.** **Settings → X account session** has a new choice, **Where X posts appear** (`social.x.visibility` in `settings.json`): **Public**, the default, builds an X channel's posts into every site that has the channel, as before; **Private** leaves every X channel out of every public site's build — its posts, its posts manifest entry and its place in the channel list, `site.json` and `corpus.json` — and builds it only into private sites (below). Nothing on disk changes and fetching goes on. A site already published changes on its next build and deploy, and a public site whose only posts were X posts loses its **Posts** box under **Search in**. Choosing the X login source no longer forgets this choice, and choosing this one keeps the login source. Needs a rebuild and restart of the editor, then a rebuild and deploy of every site and the hub. +- **A site can be private: built for reading on this machine, never deployed and never listed.** A site's settings have a new **Audience** choice (`audience` in `site.json`; only `"private"` is written). A private site is refused by every deploy — **Build & deploy**, **Deploy**, `archilyzer deploy site`, `pnpm ops build-deploy` and `deploy-site`, **Build & deploy all** (which builds it and skips its deploy) and `docker/publish-site.sh` — before anything is uploaded, in a sentence naming the audience; **Build** still builds it. It is left off the homepage, the hub and every other site's footer whatever **List on the Archilyzer homepage and hub** says, it publishes no hub URL, and its `corpus.json` says `"audience": "private"`, so a build of it is refused too if the site is switched back to public before it is rebuilt. Point the MCP at a private site's build to ask about what only it holds. Needs a rebuild and restart of the editor. +- **A site built on the host right after another site no longer ships that site's channel list.** A site's **Build** (and Build all without containers) composes every site into the same folder, and the step that copies a site's summaries, stats and duplicates was skipped when that site's own data had not changed, even though another site had composed there since. The site then went out with the other site's channel list and stats. Those steps now run again whenever another site composed last. Needs a rebuild and restart of the editor. +- **The hub no longer ships the data of the last site built before it.** **Build hub** built from the folder a site's build had just filled, so the hub carried that site's summaries, transcripts, posts and other data, and served them. The hub's build now clears every site's data first and uses the global search aliases, and **Deploy hub** refuses a hub build that still carries a site's data. Needs a rebuild and restart of the editor, then a rebuild and deploy of the hub. - **The dashboard and `/jobs` keep answering while a channel's report is regenerated.** Regenerating a report walks every video of the channel inside the editor, and two regenerations of channels with a few thousand videos, running side by side, kept `/`, `/channels` and `/jobs` from loading for over an hour. Regenerations now run one at a time, on their own `refresh-report` queue on `/jobs` — a channel's own **Refresh report** included, which now waits its turn there too: it waits up to 15 seconds, then says where its job is instead ("Queued behind 3 report regenerations — the report updates when it finishes (job …).") and the page catches up when it runs, and a regeneration that fails now shows its reason under the button; a channel whose report is already waiting is not queued a second time, whether the request came from a finished job, **Refresh report** or **Update all reports**, and a change made while a channel's report is being regenerated queues one more regeneration after it rather than being missed; and the walk pauses between batches of videos so pages are served in between. **Update all reports** answers as soon as the regenerations are queued, and the reports land one after another; `pnpm ops refresh-report` answers with the job ids for both a single channel and `{"all":true}`, which `--wait` follows. Needs a rebuild and restart of the editor. - **The operations pages share one count of the lanes' pending work.** Every open operations page asks for the lanes' status every 3 seconds, and each request used to count every lane's pending videos afresh from every channel's report. That count is now made once and handed to every request in the next 3 seconds. Changing a lane's rules, a focus or a channel's priority counts again at once; otherwise a pending count can be up to 3 seconds behind a report that was just rewritten or a video a lane just picked. A lane's hold, its runner and its picks are still read fresh on every request. - **Jobs a stopped editor left "running" are closed when it starts again.** A job that was still running when the editor's process ended (killed, crashed, or shut down before the job had finished unwinding) kept "running" in its record for good, and `/jobs` listed it as archived. On start the editor now marks each one **cancelled**, with "interrupted: the process running it stopped before it finished" as the reason on the job's page, and its end time is the last time its log was written. Nothing is run again; **Retry** works as for any cancelled job. A job that another live process is running, such as `archilyzer run`, is left alone, and the same check now keeps the start-up pass from closing that process's queued jobs. Such leftover jobs never blocked a media move. diff --git a/editor/app/settings/components/XPostsVisibilityControl.tsx b/editor/app/settings/components/XPostsVisibilityControl.tsx @@ -0,0 +1,74 @@ +"use client"; + +// Where X posts appear — `social.x.visibility` (release 17 slice XP), inside the +// X account session section. "Public" builds an X channel's posts into every +// site that has the channel; "Private" keeps them out of every public site and +// builds them only into private sites (site.json `audience`). The rule is +// common/lib/postsVisibility.ts; nothing on disk changes and fetching does not. + +import { useEffect, useState, useTransition } from "react"; +import { setXPostsVisibilityAction } from "../xSessionActions"; +import type { XPostsVisibility } from "yt-dlp-transcript-common/social/xCookieSource"; + +export function XPostsVisibilityControl({ + initial, +}: { + initial: XPostsVisibility; +}) { + const [visibility, setVisibility] = useState<XPostsVisibility>(initial); + const [error, setError] = useState<string | null>(null); + const [note, setNote] = useState<string | null>(null); + const [saving, startSaving] = useTransition(); + + // Another tab's choice re-renders the page with a new value; take it. + useEffect(() => setVisibility(initial), [initial]); + + const choose = (choice: string) => + startSaving(async () => { + setError(null); + setNote(null); + const res = await setXPostsVisibilityAction(choice); + if (res.ok) { + setVisibility(res.visibility); + setNote("Saved. Published sites change on their next build and deploy."); + } else { + setError(res.error); + } + }); + + return ( + <div data-x-posts-visibility="" className="flex flex-col gap-1"> + <div className="flex flex-wrap items-center gap-2"> + <label htmlFor="x-posts-visibility" className="text-sm"> + Where X posts appear + </label> + <select + id="x-posts-visibility" + value={visibility} + onChange={(e) => choose(e.target.value)} + disabled={saving} + className="rounded-md border border-border bg-background px-2 py-1 text-sm disabled:opacity-50" + > + <option value="public">Public — every site that has the channel</option> + <option value="private">Private — private sites only</option> + </select> + </div> + <p className="text-xs text-muted-foreground"> + Private leaves every X channel and its posts out of every public site + and builds them only into sites whose audience is private, which are + never deployed; nothing is deleted and fetching goes on. Sites already + published change on their next build and deploy. + </p> + {note && ( + <p role="status" aria-label="x posts visibility saved" className="text-xs text-success"> + {note} + </p> + )} + {error && ( + <p role="alert" className="text-xs text-destructive"> + {error} + </p> + )} + </div> + ); +} diff --git a/editor/app/settings/components/XSessionSection.tsx b/editor/app/settings/components/XSessionSection.tsx @@ -26,14 +26,19 @@ import { resolveXCookieSource, xCookieSourceLabel, type XCookieSourceView, + type XPostsVisibility, } from "yt-dlp-transcript-common/social/xCookieSource"; +import { XPostsVisibilityControl } from "./XPostsVisibilityControl"; export function XSessionSection({ initial, initialSource, + initialVisibility, }: { initial: XSessionStatus; initialSource: XCookieSourceView; + // `social.x.visibility`, resolved (absent = "public"); release 17 slice XP. + initialVisibility: XPostsVisibility; }) { const [status, setStatus] = useState<XSessionStatus>(initial); const [source, setSource] = useState<XCookieSourceView>(initialSource); @@ -250,6 +255,10 @@ export function XSessionSection({ {error} </p> )} + + <div className="border-t border-border pt-3"> + <XPostsVisibilityControl initial={initialVisibility} /> + </div> </section> ); } diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx @@ -5,6 +5,7 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { readXSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker"; import { resolveXCookieSourceFor } from "yt-dlp-transcript-common/social/xBrowserLogin"; import { xCookieSourceView } from "yt-dlp-transcript-common/social/xCookieSource"; +import { xPostsVisibility } from "yt-dlp-transcript-common/lib/postsVisibility"; import { SettingsForm } from "./components/SettingsForm"; import { XSessionSection } from "./components/XSessionSection"; @@ -93,7 +94,11 @@ export default async function SettingsPage() { </section> <section className="flex flex-col gap-3 border-t border-border pt-6"> - <XSessionSection initial={xSession} initialSource={xSource} /> + <XSessionSection + initial={xSession} + initialSource={xSource} + initialVisibility={xPostsVisibility(settings)} + /> </section> <section className="flex flex-col gap-3 border-t border-border pt-6"> diff --git a/editor/app/settings/xSessionActions.ts b/editor/app/settings/xSessionActions.ts @@ -30,11 +30,25 @@ import { } from "yt-dlp-transcript-common/social/xBrowserLogin"; import { isXCookieSource, + isXPostsVisibility, xCookieSourceView, type XCookieSourceView, + type XPostsVisibility, + type XSocialSettings, } from "yt-dlp-transcript-common/social/xCookieSource"; +import { xPostsVisibility } from "yt-dlp-transcript-common/lib/postsVisibility"; import { saveSettings } from "./saveSettings"; +// `social.x` is ONE value to saveSettings, whose merge is one level deep: a +// patch naming `social: { x }` replaces the whole X block. So each X choice is +// written over the block as it is now, with only its own key changed — the +// login source keeps the visibility, and the visibility keeps the source. +function socialXPatch( + change: (x: XSocialSettings) => XSocialSettings, +): { social: { x: XSocialSettings } } { + return { social: { x: change({ ...getSettings().social.x }) } }; +} + // Every session action returns the source in use beside the profile's status: // connecting or forgetting a profile can move the read-time default. export type XSessionActionResult = @@ -124,12 +138,42 @@ export async function setXCookieSourceAction( return { ok: false, error: `Unknown X login source "${choice}".` }; } try { - await saveSettings({ - social: { x: choice === "auto" ? {} : { cookieSource: choice } }, - }); + await saveSettings( + socialXPatch(({ cookieSource: _was, ...rest }) => + choice === "auto" ? rest : { ...rest, cookieSource: choice }, + ), + ); } catch (e) { return { ok: false, error: (e as Error).message }; } revalidatePath("/settings"); return { ok: true, source: await sourceNow() }; } + +// WHERE X POSTS APPEAR — `social.x.visibility` (release 17 slice XP). "public" +// is the default and is written as no key; "private" keeps every X channel's +// posts out of every public site build (common/lib/postsVisibility.ts). Nothing +// on disk changes and fetching does not: a site already published changes on +// its next build and deploy. +export type XPostsVisibilityResult = + | { ok: true; visibility: XPostsVisibility } + | { ok: false; error: string }; + +export async function setXPostsVisibilityAction( + choice: string, +): Promise<XPostsVisibilityResult> { + if (!isXPostsVisibility(choice)) { + return { ok: false, error: `Unknown X post visibility "${choice}".` }; + } + try { + await saveSettings( + socialXPatch(({ visibility: _was, ...rest }) => + choice === "public" ? rest : { ...rest, visibility: choice }, + ), + ); + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + revalidatePath("/settings"); + return { ok: true, visibility: xPostsVisibility(getSettings()) }; +} diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -111,6 +111,12 @@ export async function saveSiteAction( // Listed on the homepage and hub by default: the same opt-out idiom as // archives below (an unchecked box sends no key → persisted as false). const listed = formData.get("listed") === "on"; + // Who the site is built for (release 17 slice XP): only "private" is kept. + const audienceRaw = String(formData.get("audience") ?? "public"); + if (audienceRaw !== "public" && audienceRaw !== "private") { + return { ok: false, error: `Unknown audience "${audienceRaw}".`, values }; + } + const isPrivate = audienceRaw === "private"; // Hub parent (per-site override of the family default) + PWA opt-in. const hubUrlRaw = String(formData.get("hubUrl") ?? "").trim(); @@ -248,6 +254,7 @@ export async function saveSiteAction( ...(siteUrl ? { siteUrl } : {}), // The Site is rebuilt from the form: a key missing here is dropped on save. ...(listed ? {} : { listed: false }), + ...(isPrivate ? { audience: "private" as const } : {}), ...(hubUrl ? { hubUrl } : {}), ...(pwa ? { pwa: true } : {}), ...(archives ? {} : { archives: false }), diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -16,7 +16,7 @@ import { toSocialRow, type SocialRow, } from "../../components/SocialLinksField"; -import { Field } from "../../components/forms/Field"; +import { Field, SeededSelect } from "../../components/forms/Field"; import { ControlledCheck, ControlledSelect, @@ -341,6 +341,25 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { defaultValue={initial.siteUrl ?? ""} hint="Absolute URL this site is served at (e.g. https://jeralyzer.com). Used so other sites can link to it in their footer. Leave blank to omit this site from cross-site lists." /> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Audience</span> + <SeededSelect + state={state} + name="audience" + aria-label="Audience" + initial={initial.audience === "private" ? "private" : "public"} + className="w-fit rounded border border-border bg-card px-2 py-1 text-sm" + > + <option value="public">Public — deployed and listed as configured</option> + <option value="private">Private — built for reading on this machine, never deployed</option> + </SeededSelect> + <span className="text-xs text-muted-foreground"> + A private site is never deployed (Build &amp; deploy and Deploy refuse + it; Build still builds it) and never listed on the homepage or the hub, + and publishes no hub URL. It is the one kind of site X posts are built + into while Settings keeps X posts private. + </span> + </label> <label className="flex items-center gap-2 text-sm"> <input type="checkbox" diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts @@ -16,6 +16,10 @@ import { } from "yt-dlp-transcript-common/controller/archiveLiveChat"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { + deployAudienceProblem, + siteDeployProblem, +} from "yt-dlp-transcript-common/lib/builtExport"; import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site"; import { runManagedFunction, @@ -132,6 +136,10 @@ export async function buildAndDeployAction( } const branch = previewBranch?.trim(); const site = getSite(id, paths); + // A private site is never deployed (site.json `audience`), so Build & deploy + // refuses before the build; its Build button still builds it. + const privateProblem = siteDeployProblem(site); + if (privateProblem) return { ok: false, error: `${privateProblem}.` }; if (!site.cloudflareProject) { return { ok: false, @@ -155,6 +163,14 @@ export async function buildAndDeployAction( if (buildCode !== 0) { throw new Error(`Build failed (exit ${buildCode}) — not deploying.`); } + // Asked again before the upload, over the site as it is now and the + // bundle just built: an audience switched to private while this job + // waited on the queue is not deployed. + const audienceProblem = deployAudienceProblem( + getSite(id, paths), + resolveOutDir(id, paths), + ); + if (audienceProblem) throw new Error(`${audienceProblem} — not deploying.`); onLog(branch ? `\n=== Deploy (preview "${branch}") ===\n` : "\n=== Deploy ===\n"); if (branch) onLog(PREVIEW_SHARES_ARCHIVES_NOTICE); // Push oversize archives to R2 before the Pages deploy (no-op when R2 @@ -401,6 +417,13 @@ async function basicBuildAndDeployAll( }); continue; } + // A private site is built and never deployed — skipped before the upload. + const audienceProblem = deployAudienceProblem(site, basicOut); + if (audienceProblem) { + onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`); + deploys.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem }); + continue; + } if (!site.cloudflareProject) { onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`); deploys.push({ diff --git a/editor/app/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts @@ -1,6 +1,10 @@ "use server"; -import { builtSiteProblem } from "yt-dlp-transcript-common/lib/builtExport"; +import { + builtAudienceProblem, + builtSiteProblem, + siteDeployProblem, +} from "yt-dlp-transcript-common/lib/builtExport"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { getSite } from "yt-dlp-transcript-common/lib/site"; @@ -38,6 +42,9 @@ export async function deployExportAction( } const branch = previewBranch?.trim(); const site = getSite(siteId.trim(), paths); + // A private site is never deployed (site.json `audience`): the first answer. + const privateProblem = siteDeployProblem(site); + if (privateProblem) return { ok: false, error: `${privateProblem}.` }; if (!site.cloudflareProject) { return { ok: false, @@ -58,6 +65,10 @@ export async function deployExportAction( const outDir = resolveOutDir(site.siteId, paths); const builtProblem = builtSiteProblem(outDir, site.siteId); if (builtProblem) return { ok: false, error: builtProblem }; + const builtPrivate = builtAudienceProblem(outDir); + if (builtPrivate) { + return { ok: false, error: `${builtPrivate}. Build ${site.siteId} again, then deploy.` }; + } return runManagedFunction({ kind: "deploy-export", queueKey: DEPLOY_QUEUE, diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -528,3 +528,51 @@ test("brand accent radio group + wordmark lead round-trip to site.json", async ( expect("wordmarkLead" in site).toBe(false); }).toPass({ timeout: 10_000 }); }); + +// Who a site is built for — `site.json` `audience` (release 17 slice XP). A +// private site is the operator's own reading copy: saved from the form, and +// never deployed — the deploy actions refuse it before a job exists, in words +// naming the audience. (The ops routes call the same actions the Publish tab's +// buttons do.) +test("a private site saves its audience, and every deploy refuses it before any job", async ({ + page, + request, +}) => { + await resetData("empty"); + await writeSite("privsite", { siteTitle: "Private Site", cloudflareProject: "never-real" }); + + await page.goto("/sites/privsite"); + const audience = page.getByLabel("Audience", { exact: true }); + await expect(audience).toHaveValue("public"); + await audience.selectOption("private"); + await page.getByRole("button", { name: /save site/i }).click(); + await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible(); + await expect(async () => { + const site = await readJson<{ audience?: string; cloudflareProject?: string }>( + "test-transcripts/sites/privsite/site.json", + ); + expect(site.audience).toBe("private"); + expect(site.cloudflareProject).toBe("never-real"); + }).toPass({ timeout: 10_000 }); + await page.reload(); + await expect(audience).toHaveValue("private"); + + const refusal = + 'Site "privsite" is private (audience: private): it is built for reading on this machine and is never deployed. Build it without deploying, or set its audience to public on its Settings tab.'; + for (const action of ["build-deploy", "deploy-site"]) { + const res = await request.post(`/api/ops/${action}`, { + headers: { authorization: "Bearer test-worker-token" }, + data: { siteId: "privsite" }, + }); + expect(res.status(), action).toBe(400); + expect(((await res.json()) as { error?: string }).error, action).toBe(refusal); + } + + // Back to public: the key is gone from the file (public is the default). + await audience.selectOption("public"); + await page.getByRole("button", { name: /save site/i }).click(); + await expect(async () => { + const site = await readJson<Record<string, unknown>>("test-transcripts/sites/privsite/site.json"); + expect("audience" in site).toBe(false); + }).toPass({ timeout: 10_000 }); +}); diff --git a/editor/e2e/x-session.spec.ts b/editor/e2e/x-session.spec.ts @@ -76,3 +76,44 @@ test("the login source select persists, and Check shows a status line", async ({ await expect(inUse).toHaveText(`In use: Browser login (${spec}) (automatic)`); expect((await readJson<{ social?: unknown }>("test-settings.json")).social).toEqual({ x: {} }); }); + +// Where X posts appear — `social.x.visibility` (release 17 slice XP). The two X +// choices share one settings block, and saveSettings replaces a nested block +// whole, so each is written over the other: choosing one keeps the other. +test("where X posts appear persists, beside the login source and without it", async ({ page }) => { + await resetData("empty"); + await page.goto("/settings"); + const visibility = page.getByLabel("Where X posts appear"); + const source = page.getByLabel("x cookie source", { exact: true }); + const social = async () => + (await readJson<{ social?: unknown }>("test-settings.json")).social; + + await expect(visibility).toHaveValue("public"); + await expect(page.locator("[data-x-posts-visibility]")).toContainText( + "Sites already published change on their next build and deploy.", + ); + + await source.selectOption("profile"); + await expect(page.getByLabel("x cookie source in use")).toHaveText("In use: Connected profile"); + + await visibility.selectOption("private"); + await expect(page.getByLabel("x posts visibility saved")).toHaveText( + "Saved. Published sites change on their next build and deploy.", + ); + expect(await social()).toEqual({ x: { cookieSource: "profile", visibility: "private" } }); + + await page.reload(); + await expect(visibility).toHaveValue("private"); + + // The login source back to automatic keeps the visibility… + await source.selectOption("auto"); + await expect(page.getByLabel("x cookie source in use")).toHaveText( + "In use: Connected profile (automatic)", + ); + expect(await social()).toEqual({ x: { visibility: "private" } }); + + // …and public is the default, written as no key. + await visibility.selectOption("public"); + await expect(page.getByLabel("x posts visibility saved")).toBeVisible(); + expect(await social()).toEqual({ x: {} }); +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.11.1] - 2026-10-01 - **Use with AI goes to the Archilyzer site's AI and MCP doc; the page on each site is gone.** The header's, the slide-out menu's, the footer's and Ask AI's **Use with AI** keep their label and open https://archilyzer.pages.dev/docs/ai-and-mcp/ in the same tab, on every site and the hub, where one block says how to run Claude Code against any archive (the source, `pnpm install`, `claude mcp add archilyzer`, `/ask`). `/use-with-ai/` is no longer built. `corpus.json`'s `useWithAi` names the doc; `llms.txt`'s Ask AI section lists the site's `/ask/` chat and the doc; the sitemap drops `/use-with-ai`. Needs a rebuild and deploy of each site and the hub. - **A search with a layer that has nothing to read finishes.** A "Posts" layer under a tag chip, or a "Live chat" layer where no video in the selection has live chat, read "searched N/M…" for ever and never said "No matching videos."; it now finishes at once, having matched nothing. Needs a rebuild and deploy of each site and the hub. - **A search reads what the visitor ticks under "Search in": Transcripts, Posts and Live chat.** The Filters panel has a new row, **Search in**, beside Type. **Transcripts** and **Posts** are ticked by default and **Live chat** is not; Posts is offered only on a site that has posts, and Live chat only on a site with live chat. The row decides what a plain query reads: with Posts ticked, a plain query now finds posts as well as videos (before, a post was found only by a layer whose scope was "Posts"); with Live chat ticked, it finds live-chat messages too, shown in the same video's card beside the transcript hits, each marked "live chat"; with Transcripts unticked it reads no transcripts. A layer whose scope is picked by name in the query builder ("Live chat", "Posts", "Title / channel", …) reads what it names, whatever the row says. An empty query still lists every video the Type row keeps. With nothing ticked, Search and Apply filters are disabled and the row says "Search in: pick at least one". The Posts box moved here from the Type row, and unticking it no longer empties a layer whose scope is "Posts". Under a tag chip a plain query reads no posts, since a post carries no tags. The row is remembered, and saved with a profile; a shared link does not carry it, so it opens with the reader's own row. A live-chat hit now wears its "live chat" badge wherever it is shown, and the hint under the search bar says to tick Live chat under Search in. Posts unticked is now also remembered after a reload and restored with a profile, which it was not. Needs a rebuild and deploy of each site and the hub. diff --git a/export/app/offline/page.tsx b/export/app/offline/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { postsVisibleTo } from "yt-dlp-transcript-common/lib/postsVisibility"; import { currentSite } from "../lib/site"; import { OfflineManager, type OfflineChannel } from "../components/OfflineManager"; @@ -15,12 +17,19 @@ export const metadata: Metadata = { export default async function OfflinePage() { const site = currentSite(); const paths = getPaths(); - const channels: OfflineChannel[] = await Promise.all( - site.channels.map(async ({ slug }) => { - const config = await readChannelConfig(paths, slug).catch(() => null); - return { slug, name: config?.name ?? slug }; - }), + const settings = getSettings(); + // The members this build publishes: an X channel is left out of a public + // site while X posts are private (lib/postsVisibility.ts, the rule the + // index build and compose narrow the site's data by). + const members = await Promise.all( + site.channels.map(async ({ slug }) => ({ + slug, + config: await readChannelConfig(paths, slug).catch(() => null), + })), ); + const channels: OfflineChannel[] = members + .filter(({ config }) => postsVisibleTo(site, config, settings)) + .map(({ slug, config }) => ({ slug, name: config?.name ?? slug })); channels.sort((a, b) => a.name.localeCompare(b.name)); return ( diff --git a/export/e2e/x-posts-private.spec.ts b/export/e2e/x-posts-private.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + POST_CHANNEL, + POST_CHANNEL_SLUG, + POST_REPLY_ID, + POST_ROOT_ID, + postsPage, +} from "./fixtures/data"; +import { installRoutes, openFilters } from "./helpers"; + +// X posts are private (release 17 slice XP): with `social.x.visibility` +// "private", a PUBLIC site's build carries no X channel and a PRIVATE site's +// build carries all of it. The build itself — the index build's per-site posts +// manifest and compose's posts tree — is pinned through the real code by +// common/bin/compose-site.postsVisibility.test.ts: this suite's data is +// route-mocked, never built. Here the two posts manifests that build writes +// are served, and the visitor's side is checked: the Search in row's Posts box +// and the post hits come and go with the manifest, with nothing special-cased. +// +// The fixture posts say "kappa" (two of them); no video does. + +const X_POST_SLUGS = [ + `${POST_CHANNEL_SLUG}/${POST_ROOT_ID}`, + `${POST_CHANNEL_SLUG}/${POST_REPLY_ID}`, +]; + +const fulfillJson = (body: unknown) => ({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), +}); + +// The site posts manifest compose writes: the X channel listed (a private +// site's build), or no channel at all (a public site whose only posts were X +// posts). Routed after installRoutes, so these answers win. +async function servePostsManifest(page: Page, built: "public" | "private") { + await page.route("**/posts/manifest.json", (route) => + route.fulfill( + fulfillJson({ + version: 1, + channels: + built === "private" + ? [{ name: POST_CHANNEL, slug: POST_CHANNEL_SLUG, postCount: 4, platform: "twitter" }] + : [], + totalCount: built === "private" ? 4 : 0, + generatedAt: new Date().toISOString(), + }), + ), + ); + await page.route(/\/posts\/[^/]+\/page-\d+\.json$/, (route) => + route.fulfill( + fulfillJson( + postsPage().map((p) => ({ + ...p, + platform: "twitter", + url: `https://x.com/tester/status/${p.id}`, + })), + ), + ), + ); +} + +const postsBox = (page: Page) => + page.getByTestId("search-in-row").getByRole("checkbox", { name: "Posts", exact: true }); + +async function search(page: Page, q: string) { + await page.locator('input[data-testid^="leaf-query-"]').first().fill(q); + await page.getByTestId("search-submit").click(); +} + +test.describe("X posts private", () => { + test.beforeEach(async ({ page }) => { + await installRoutes(page); + }); + + test("a public site built with X posts private has no Posts box and finds no X post", async ({ + page, + }) => { + await servePostsManifest(page, "public"); + await page.goto("/"); + await openFilters(page); + await expect(page.getByRole("checkbox", { name: "Transcripts", exact: true })).toBeVisible(); + await expect(postsBox(page)).toHaveCount(0); + await search(page, "kappa"); + await expect(page.getByText(/^searched \d+\/\d+$/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("results-summary")).toHaveText("Matching videos (0)"); + await expect(page.locator(`[data-result-slug^="${POST_CHANNEL_SLUG}/"]`)).toHaveCount(0); + }); + + test("a private site's build shows the X posts", async ({ page }) => { + await servePostsManifest(page, "private"); + await page.goto("/"); + await openFilters(page); + await expect(postsBox(page)).toBeChecked(); + await search(page, "kappa"); + const cards = page.locator("[data-card-header]"); + await expect(async () => { + const got = await cards.evaluateAll((els) => + els.map((e) => e.getAttribute("data-result-slug") ?? ""), + ); + expect(got.slice().sort()).toEqual(X_POST_SLUGS.slice().sort()); + }).toPass({ timeout: 15_000 }); + }); +}); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -165,7 +165,7 @@ Never name the curated field `tags`. Never assume a `tags.json` is the keyword l | --- | --- | --- | | A video's visibility | `common/lib/availability.ts` (the `"unlisted"` state, `isUnlisted`), `common/lib/transcripts{,-server}.ts`, `common/components/shareUrl.ts`, `common/controller/buildIndex.ts` | The platform's own "unlisted" (reachable by link, not listed on the channel). | | The hub's list has loaded | `export/app/components/hub/useHubSites.ts` — `listed` | `/hub-sites.json` has been answered and `/hub-summary.json` has settled. | -| **A site the family lists** | `site.json` `listed` (`common/lib/siteSchema.ts` — `isListedSite`, `channelsOnlyOnUnlistedSites`) | Absent = listed; `false` keeps the site off the homepage, the hub and the other sites' footers, and out of the public totals. | +| **A site the family lists** | `site.json` `listed` (`common/lib/siteSchema.ts` — `isListedSite`, `channelsOnlyOnUnlistedSites`) | Absent = listed; `false` keeps the site off the homepage, the hub and the other sites' footers, and out of the public totals. A PRIVATE site (`audience: "private"`, release 17 XP) is never listed, whatever `listed` says. | A grep for either word finds all three; read the file before assuming which. @@ -8251,6 +8251,26 @@ phase deletes from the destination. **The source, not `cookieMode`, governs the X fetchers**: the browser source passes the spec whatever the mode; the profile source keeps the old order (the jar, else the `"always"`-mode spec, else a guest run). +- **Where X posts may appear, `social.x.visibility`** (release 17 slice XP; `"public"` default | + `"private"`, kept by `sanitizeSocial` beside `cookieSource`). **saveSettings' merge is one level + deep, so a patch `{ social: { x } }` replaces the whole X block**: both X actions + (`xSessionActions.ts`) write over the block as it is (`socialXPatch`). The rule is + `common/lib/postsVisibility.ts` (`postsVisibleTo`, `publishedMemberSlugs`): while private, an X + channel (social, platform `twitter`) is built only into sites with `site.json` `audience: + "private"`; a public site leaves the channel out WHOLE (posts are all it holds) — its posts + manifest entry, posts tree, transcripts tree, channel list, `site.json` and `corpus.json` entry, + and `channel-sites.json`. Applied in `buildIndex`'s per-site loop (the site fingerprint names the + `withheld` members) and in `compose-site` (which prunes a previously shipped tree); the shared + posts tree and the LMDB posts sub-DB stay corpus-wide. A private site publishes no `hubUrl` + (`resolveHubUrl`), its `corpus.json` says `site.audience: "private"`, and every deploy path + refuses it or its bundle before any upload (`lib/builtExport.ts` `deployAudienceProblem`; the + bulk deploys skip it). Build & deploy all still BUILDS it. +- **The hub carries no site's data** (release 17 XP review): `public/` is shared, so a hub built after + a site's compose used to ship that site's data trees. `compose-hub` now removes every per-site entry + first (`SITE_ONLY_PUBLIC_ENTRIES`) and writes the global `search-aliases.json`; `builtHubProblem` + refuses a hub bundle carrying a data tree. **compose-site's own stages (summaries, stats, + duplicates) are trusted from its cache only when `public/site.json` names the site**; the + per-channel trees keep their signatures across sites. - **gallery-dl 1.32.9** takes `--cookies-from-browser BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]` (yt-dlp's syntax plus `/DOMAIN`) and reads every browser it supports, Chromium's encrypted store included, on each run. The spec is passed verbatim (`galleryDlCookieChoice`). diff --git a/plans/deck-posts.md b/plans/deck-posts.md @@ -125,3 +125,278 @@ Branch `deck/posts` from `main` 2cf43a69; slices merged `--no-ff` after review. Umtool-only, like the deck: a umtool rebuild and restart. Nothing under `export/`, `homepage/`, `common/` or editor code. + +## Room for posts (second pass) + +Rulings, on top of the above: + +- **Longer.** `posts.seconds` defaults to 4 (was 2). +- **Hold.** A clip that carries posts is held on its last frame, in silence, for `posts.hold` (default + 2.5 s) before its outgoing transition, so the last post can be read. The hold is part of the + segment's length in the CUT: `deckSchedule` adds it to the carrying segments (`segments[i].hold`, + present only when > 0) and every start, the total and the posts' timing are measured with it. + The segment FILES are unchanged; the hold is applied where the cut is joined (`tpad` clone + + `apad`), so `--chrome-only` changes it without rebuilding a clip. +- **Make room.** With `posts.shift` (default `{ scale: 0.86, seconds: 0.6 }`; `false` turns it + off), the footage eases from its box to `shiftedFootage(render)` — scaled, its far edge `inset` + from the frame edge away from the column, centred above the deck — as a clip's first post + appears, and stays there to the end of the segment; the next segment comes in at the normal box + through the transition. The posts column then sits at the FRAME's edge (`postsGeometry`). At + 1920×1080 the footage goes 1574×886 at (173,2) → 1354×762 at (24,64); the column is 600 wide at + x 1296, overlapping the moved footage by 82 px instead of 600. +- **More noticeable.** The cards themselves read as a highlighted interruption, not a caption. + +Core additions (`deck.mjs`): `shiftedFootage`, `postHolds`, `footageMoves` (the schedule's `moves`: +`[{segment, at, segmentAt, seconds, from, to}]`, present only when there are moves), `posts.hold` +and `posts.shift` settings and validation; `resolveDeck` fills `posts.shift` from its default. + +### R1, as built + +Branch `deck/room-r1` from 69cb37d7. + +| Commit | What | +|---|---| +| 8d86edc6 | The hold and the move joined on the carrying clip's input (`segmentJoins`, `joinInputChain`, `moveFilter`, `xfadeGraph`, `hardCutFilterArgs`, `cutOffsets`); every length under the deck is the schedule's; the hard-cut record names its joins; verify-build's freeze check; `deck-room.test.mjs` | +| f5067b04 | The cards: slide in from past the frame's edge, an accent flare that settles, an accent rail, a platform pill; text 24 px | +| 4fb19848 | README, quirks, the `[Unreleased]` entry | + +Where it differs from, or adds to, the rulings above: + +- **The move is one `perspective` filter** (`sense=destination`, `eval=frame`): the input frame's + corners are eased by smoothstep so the footage box goes from `from` to `to`. It resamples at + 1/256 px; `scale` + `overlay` and `zoompan` round to whole pixels. Before the move it is the + identity, copied bit for bit. It has no `t` and `in` counts from 1, so the clock is `(in-1)/fps`. + It fills the uncovered band by clamping to the input's edge, so `fillborders` pins the outer + 2 px to `palette.bg` first. Measured on c06: the box glides 172→22 px over 18 frames with no + stepping. +- **A hold shows `hold − transition` of still frame** under a crossfade, because the dissolve + out starts inside it (2.0 s at the defaults). +- **Text is 24 px, not more.** At 25 px the ferret cut's c06 stack (3 posts) measured 918 px + against an 838 px column, and the oldest would have slid out before the hold. 24 px with + tighter padding measures 829. +- **verify-build's freeze check** compares luma outside the deck and the posts column, because + the deck's progress fuse keeps moving during a hold. It allows a mean difference of 1.5; + ferret measured 0.01–1.02. + +Gates on 4fb19848: + +- Workspace tsc clean. `test:scripts` 318 pass, 2 skipped. Capped umtool `next build` with the + corpus linked exit 0 (58 s, under load from a concurrent encode), link removed, and the + server bundle carries `const postsCues = (` (2 files). +- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 + `6a92235fa12ca181bb81993129c9ee9d`. A deck manifest without posts gives a `schedule.json` + byte-equal to the base's, and the base and the branch give the same overlay chain, + `applyChromeArgs` and `previewFromSegmentsArgs` (`segmentJoins` returns null). +- Ferret, scratch copy, `--chrome-only` (crossfade): + - 360.2 s, holds on c03, c06, c12 and c17; + - posts and moves at the predicted seconds; + - verify-build ok, with all four freezes found; + - 17 chapters at the schedule's starts; + - QR 7/7 posts and 17/17 deck. +- Transition-0 copy through the concat-filter prerail: 368.2 s, verify-build ok, QR 7/7 posts. + The `.segments` record names each join. +- `--chrome-preview 126 14` gives 14.000 s (420 frames) from the segments: the dissolve in, + the move, the stack and the hold. + +Left for R2 (umtool): + +- `umtool/lib/report/export.mjs`'s fallback, used when there is no `chapters.ffmeta`, still sums + `segmentOffsets` without the holds. A build always writes the ffmeta, so it is reached only + for a cut that was never fully built. +- The preview's posts geometry is now the frame's edge (`postsGeometry`). The preview does not + show the footage move or the hold; both happen in ffmpeg, at the join. + +Both items above were done after R1: R2 (b584c129) shows the move and the hold in the preview, and +df062b37 reads a deck cut's offsets from its `schedule.json` when there is no `chapters.ffmeta`. + +## Finale B2, as built + +Branch `deck/finale-b2` from df062b37. It starts with the fixes from the read-only review of the +room work (SHIP AFTER FIXES). + +| Commit | What | +|---|---| +| 2f0e852b | Review fixes. The footage move now runs AFTER the hold, so a first post inside the hold still moves the frozen frame (before, it never moved or froze part-way). verify-build's freeze check samples only between the hold's start (or the move's landing) and the dissolve, and reports under three frames of still picture as not checked. `postHolds` rounds a hold to whole frames. The README and changelog say `--no-chrome` still holds and moves. | +| 2ea53246 | A clip's `muteFrom` and `render.endFade`, joined on that input's chain; the `<id>.cut.json` record beside each clip segment; validation; the QR host label fitted to the code's height | +| ebe10c84 | README, quirks, one `[Unreleased]` bullet | + +Where it adds to the rulings above: + +- **`muteFrom`** is in SOURCE seconds and must lie within the clip's `start`–`end`. The sound is + silent from that second, after a 40 ms `afade` that ENDS there, and it is digital zeros after + that. A hold on the clip stays silent. The mute is mapped to the segment's clock through + `<id>.cut.json`, which every clip build now writes: the source seconds the segment was really + cut from after snapping. A segment with no record, or one whose record does not match its + length, falls back to the unsnapped `playWindow` start, and the build says so in a note. +- **`render.endFade`** applies to the cut's last segment, whatever it is, hold included. The + picture reaches `palette.bg` and the sound reaches silence on the last frame. The picture fade + is a `geq` blend in yuv420p, enabled from its first frame. `fade=…:color=` would work too, but + only in RGB, and the concat filter would then convert every segment of the cut to rgb24 and back. +- Both are joins (`withCutEdits`, `cutJoins`), so they apply with or without the deck, on + crossfades and hard cuts, and `--chrome-only` changes them without rebuilding a segment. The + hard-cut record names them only when present. `validateCutEdits` (in `deck.mjs`) is what the + build refuses with, and `validateChrome` also checks `endFade`. +- **The QR's host label** is sized at load from the string's ink in the loaded face (canvas + `measureText`). Tracking counts between letters only, and the first side bearing is indented + away. On the ferret cut its ink covers rows 31–180, the same rows as the code. Before, it + covered 54–180. + +Gates on ebe10c84: + +- Workspace tsc clean. `test:scripts` 341 pass, 2 skipped. Capped umtool `next build` with the + corpus linked exit 0 (31 s), link removed. +- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 + `6a92235fa12ca181bb81993129c9ee9d`. The unchanged ferret deck manifest writes a `schedule.json` + byte-equal at df062b37 and at the tip. With no muteFrom or endFade, the crossfade graph, the + hard-cut graph and the record equal 2f0e852b's. Against df062b37 the only difference is the + move and hold order on the four carrying clips. +- Ferret, scratch copy with c20 `muteFrom: 24029.30` and `endFade: 1.0`, `--chrome-only` over + copied segments with no cut records: + - c20 muted 6.70 s into its segment, falling back to the unsnapped start, which the run notes; + - 360.2 s, verify-build ok, all four freezes found; + - QR 17/17 deck and 7/7 posts; + - the last sample that is not zero is at 359.613 s of the audio, and everything after it is + digital silence; + - the last frame above the deck is bg (mean 1.0, worst 3 levels over rows 0–881). + +Found and left: + +- **The crossfade concat drifted the sound ahead of the picture — fixed in 6771cfb8.** Encoded + segments' audio is routinely a few to ~20 ms off their video, and `acrossfade` joined the sound + by its own lengths while `xfade` used the picture's: the ferret cut's sound ran 0.3 s early by + the last clip (2.0 s on revision 2, with cards). `xfadeGraph` now pins each input's sound to its + length in the cut (`apad=whole_dur`, `atrim=end`) before the join, so a `muteFrom` lands on its + picture too. Every crossfaded cut's graph changed; `av-sync.test.mjs` holds it with real ffmpeg. + +## Bench B1, as built + +Branch `deck/bench-b1`, merged as f619d659. umtool's clip bench plays every bounded range exactly, +and sets the clip's `muteFrom` that B2 builds. + +| Commit | What | +|---|---| +| 5d7926f4 | `updateClip` takes `muteFrom` (source seconds inside the clip after the patch, rounded like an edge; null or empty deletes it), and a window save that would leave the mark outside the new extent is refused unless the same patch moves or clears it. `PUT /api/report/window` whitelists it and `/api/report/clip` returns it. `GET /api/report/audio` serves a cached window's sound, picked by `/api/report/raw`'s membership rule, decoded by ffmpeg to 16-bit PCM WAV over an absolute span of at most 120 s. `lib/report/playback.mjs` is the arithmetic both sides use (decode span, buffer schedule, playhead, mute ramp, the element fallback's stop test, the muteFrom rule, the WAV header), unit-tested | +| bf927658 | The bench: **play selection**, the edge auditions, the auto-audition and a transcript line play from the decoded window with an `AudioBufferSourceNode`, started at an exact buffer offset and stopped at a context time (exact at every speed); the picture follows muted and the playhead reads the audio clock. One decode per window (the whole window up to 120 s, else the selection plus 30 s each side). When the decode fails, the element plays, stopped per animation frame by remaining time, and the bench says the playback is approximate and why. The mute mark: `m` at the playhead, **pick on waveform**, `;`/`'` to nudge (shift for 0.5 s), `M`/**clear mute**; drafted like the edges, saved by **save window** or a confirmation. Each playback is recorded as `data-play-*` attributes, and the specs check the stop against an AudioWorklet tap of the bench's output | +| b9debbd7 | The audio-clock playhead paints at about 30 Hz; `m` reads it through a ref; the exact-stop spec checks the start against the schedule rather than the first non-zero frame | +| e74c7a90 | The muted picture is re-seeked when it drifts more than 0.25 s from the audio clock; the element's own pause at the end of a playback no longer moves the playhead | + +After the merge, on the main line: + +- 3e3f251f: the bench's mute preview is the build's — one `MUTE_FADE` (0.04 s, from `deck.mjs`), + a ramp that ends at the mark. B1 had faded over 0.05 s. +- 3c70c6d3: the posts and mute specs follow whole-frame holds and the build's mute fade. + +Gates (from the merge): the stop measured on the ferret c20 window, 0 of 20 trials off its +schedule, where the element it replaces overran by about 230 ms; the specs check the stop to within +one render quantum at 1× and 2×. umtool +e2e `clip-bench onscreen onscreen-posts build projects report-fetch-via-editor` 97/97. Reviewed. + +Found after the merge, by the finale review, and fixed in the fix pass below: the build refused a +`muteFrom` that `updateClip` accepted within its 0.02 s tolerance (LOW-1); `MUTE_FADE` pulled +`deck.mjs` into the bench's client bundle (LOW-5); the audio route spawned a bare `ffmpeg` and did +not stop the decode when the request was aborted (LOW-6). + +## Teaser T1, as built + +Branch `deck/teaser-t1` from 810e423e. A `teaser` timeline entry: a full-frame season-teaser card +after the last clip, its words the manifest's, a trailer hit under each pop. + +| Commit | What | +|---|---| +| 3c4ab080 | `deck.mjs`: `teaser` in `CARD_TYPES` (the deck hides over it whatever `overCards` says), `teaserLines`/`teaserTail`/`teaserTitle`, `validateTeaser`/`validateTeasers`, `TEASER_MOTION` + `teaserTimes` (the one copy of the timing) and `teaserHits`; `chrome-teaser.mjs` (the page, `teaserCues`); compose-chrome region `teaser` by dynamic import; build-video `buildTeaserSegment`, `teaserAudioGraph`, `teaserEncodeArgs`, `teaserSegmentKey`, the chapter, `--chrome-only` building teasers, `--fetch-only` a no-op on one; verify-build `verifyTeasers`; `chrome-teaser.test.mjs`, `teaser-audio.test.mjs` | +| a82aae0e | umtool: the report page's row and the export's fallback chapter name a teaser by its lines; `onscreen-fixture` carries a teaser and `onscreen.spec` checks the table, the preview's node and the row; the supporting lines a size up | +| c51925ae | README section, four quirks, one `[Unreleased]` bullet | + +Where it adds to, or differs from, the brief: + +- **A line is a string or `{ text, break }`** — `break` is the END of `text`, drawn as a smaller, + wide-tracked second tier 0.3 s after the rest; the whole `text` is what the chapter and umtool + show. There are no quotation marks and no `quote` field (the operator dropped them). +- **Roles follow position**: with three or more lines the first is the overline, the last the + kicker, the rest titles; two are overline + title; one is a title. +- **`hits`** (default true): a synthesised hit under each pop and a swell under the tail; false is + digital silence. The hits are placed by `teaserTimes`, the times the cues are built from, and + land on their pop's sample (a real-ffmpeg test differences the graph with and without each hit). +- **`--chrome-only` builds teaser segments** instead of refusing: a teaser is chrome (graphics made + from the manifest, nothing fetched). Its segment is re-encoded only when its key — the frames' + render key and the whole sound graph — differs from `<id>.teaser.json`'s. +- **The deck hides over a teaser even with `overCards: "show"`**: it is full frame, never framed + into the footage box. +- The fit (a line wider than 80 % of the frame shrinks) runs once the face is in, as the deck's + title fit does; it changes sizes, never a time. + +Gates on c51925ae: + +- Workspace tsc clean (102 s). `test:scripts` 355 pass, 1 skipped (356). Capped umtool + `next build` with the corpus linked exit 0 (58 s, under a concurrent encode), link removed; + Turbopack bundles the teaser page and its face as their own server chunk. +- Byte-identical: `--only c07 --skip-fetch` without `render.chrome` gives md5 + `6a92235fa12ca181bb81993129c9ee9d`. The ferret deck manifest without a teaser measures the same + schedule at 810e423e and at the tip (`measureChromeSchedule`, byte-equal); with the teaser, the + first 17 segments, the posts and the moves equal the ferret's built `schedule.json`. +- Ferret, scratch copy with the teaser after c20, `--chrome-only` over copied segments: + - the teaser rendered in 77–86 s (210 frames), the deck (11,001 frames) in 4 min 7 s; a + re-run after a design change re-rendered the teaser only (deck and posts `cached`, 573 s); + - 366.7 s (360.2 + 7 − 0.5), video and audio both 366.700 s; verify-build ok, every hold + frozen, `teaser fin: 210/210 frame(s), segment encoded from them`; + - QR 17/17 deck and 7/7 posts; 18 chapters, the last "Pirate Software — The Largest Ferret + Rescue in the United States — February 2027 ?" at 360.2 s; + - the last frame is bg (Y′CbCr 30/132/128, uniform, against 31/132/128); the sound is digital + silence for its last 39 ms; + - loudness: the cut −17.7 LUFS integrated, the teaser's 7 s −19.7 LUFS, sample peak −6.0 dBFS. +- umtool e2e `onscreen onscreen-posts build projects`: 43 passed, 1 failed (2.9 min). The failure + is `onscreen-posts.spec.ts:264`, and it predates this branch: 2f0e852b rounds a hold to whole + frames, and at the posts fixture's 15 fps 2.5 s is 38 frames (2.533 s), while the spec still + expects 2.5. No teaser is in that fixture. + +Found and left: + +- umtool cannot edit a teaser's lines. It needs a writer (`updateTeaser` through + `withManifestLock` and `validateTeaser`), a route, a form (a field per line with a break + picker) and a still of the composition (compose-chrome's `--still`, as the deck's still route + does). +- `onscreen-posts.spec.ts`'s timings at 15 fps (above) — resolved: 3c70c6d3 on the main line made + the posts and mute specs follow whole-frame holds and the build's mute fade, and the merge + carries it. + +## Fix pass after the finale review + +The read-only review of 1aecf54f (everything after df062b37: B2, the A/V pin, B1, T1) said SHIP +AFTER FIXES. A capped umtool `next build` with the corpus linked passed at 1aecf54f: exit 0, 26 s. + +| Commit | What | +|---|---| +| 3465184d | FIX-A: an `endFade` longer than the last segment is the segment. `endFadeFrames` clamps the fade's frames to `lastFrame`, and the sound fades over the same frames, so the last frame is bg and the sound silent together (a 3 s segment under `endFade: 5` was 59 % of the way to bg) | +| 3d9d5afd | FIX-B: "Bench B1, as built" above; one `[Unreleased]` bullet | +| 083ee2d6 | LOW-5: `MUTE_FADE` lives in the dependency-free `report-to-video/mute.mjs` (`umtool-report-to-video/mute`), re-exported by `deck.mjs`; the bench's `playback.mjs` imports it from there | +| ece9c818 | LOW-6: `/api/report/audio` spawns the build's `FFMPEG_BIN`, and the request's abort signal kills the decode | +| 46a4d417 | LOW-1: a window save clamps a mute mark within the writer's 0.02 s of the moved edge onto it and stores it. Chosen over loosening the build: the build's check stays strict for every writer, and the clamp is what a patched mark already got | +| 0ffed4f2 | LOW-2: a `muteFrom` at or past its segment's length (inside the extent, past `cutEnd`) is noted as "will not be heard", not "muted from" | +| c56f1a74 | LOW-3: the teaser segment's key hashes `encodeArgs(render)` (crf, preset, audio bitrate, rate, channels) | +| 964585a1 | LOW-4: `TEASER_LIMITS.fit` — title 34, kicker 56, overline 64, second tier 66 characters per row, the tail and its gap counted on its row; `validateTeaser` refuses a longer row with a sentence | +| 07d1fa08 | LOW-7: the records | + +- **LOW-4, rendered.** A teaser whose title line is the 80 characters the limit allowed, still at + 6.5 s through `compose-chrome --region teaser --still`: shrunk to its 56 px floor, the line ran + past both edges of the frame (ink in columns 0–1919). Measured in the face at each role's floor + on ordinary headline words in capitals, a row holds: title 36–37, kicker 58–60, overline 65–67, + second tier 69–71. Each limit is a little under that. Stills at the limits, the tail included + where it sits, keep their ink within columns 114–1804. A row of only wide capitals (M, W) can + still spill at these counts, and the README says so. +- **LOW-5, the client bundle.** Client chunks (`umtool/.next/static`) at 1aecf54f: `crypto-browserify` + in 1 chunk (3 hits), `createHash` 1, 1,505,165 bytes in all. After: 0, 0, 1,043,591 bytes. + `chromeCacheKey` is 0 both times, because the minifier renames it. +- **LOW-7.** The changelog no longer says a cut without the deck, posts, `muteFrom` or `endFade` + "builds exactly as before" (the pin changed every crossfaded graph). The end fade falls on + whatever the last segment is, a closing teaser included. The README's "what it was" line is + corrected. The teaser's loudness is the segment's measured −19.7 LUFS in both. The 15 fps spec + item is marked resolved. `quirks.md` has the acrossfade-by-sound vs xfade-by-picture drift. + +Gates at 07d1fa08: + +- Workspace tsc clean (49 s). `test:scripts` 368 pass, 2 skipped (370). +- Capped umtool `next build` with the corpus linked: exit 0 (23 s), link removed. +- Byte-identical: `--only c07 --skip-fetch` on the unchanged ferret manifest copy without + `render.chrome` gives md5 `6a92235fa12ca181bb81993129c9ee9d`. +- umtool e2e `onscreen-posts onscreen clip-bench build projects report-fetch-via-editor`: 97 + passed (4.9 min). diff --git a/plans/release-17.md b/plans/release-17.md @@ -263,6 +263,7 @@ one short Transcribe (the hook on a relocated channel), `/storage`, `df`; then n | **T3** migration + records | `r17/media-tier-migrate` | `common/bin/migrate-media-tier.ts`, `archilyzer.ts` wiring, fixture tests (tmp "platter"), FACTS "A channel's media is tiered", AGENTS.md's six things → seven, SETTINGS.md/CHANNEL.md regen, the release record, changelog | T1, T2 | dry run; resume from each phase; idempotent rerun; `--reclaim`; refusal on a marker; the free-space stop | | **U1** umtool roots + `out/` | `r17/umtool-media-root` | `paths.mjs` (`MEDIA_ROOT`, `CACHE_DIR`), `lib/report/storage.mjs`, `driver.mjs`, `build-video.mjs:2615`, `export.mjs`, `kinds.mjs`, `umtool doctor`, `umtool storage move-out`, the e2e env | — (∥ T1) | `test:scripts` (+ mover tests), `next-build-trace.test.mjs`, the capped umtool build with the corpus linked, umtool e2e | | **U2** deliverables switch | `r17/umtool-deliverables` | manifest `storage` field, `deliverableDir`, `cut.mjs:96`, `deliver.mjs:362`, `umtool storage deliverables`, bench "Move deliverables", `umtool check` | U1 | umtool unit + e2e: cut and share through a linked `clips/` | +| **XP** X posts are private (operator-requested, beside the media tier) | `r17/x-posts-private` | `common/lib/postsVisibility.ts` (new) + test, `settingsSchema.ts` + `social/xCookieSource.ts` (`social.x.visibility`) + SETTINGS.md, `siteSchema.ts` + `site.ts` (`audience`, `isListedSite`, `resolveHubUrl`) + SITE.md, `buildIndex.ts` (the per-site loop only), `bin/compose-site.ts` + an integration test, `lib/corpus.ts`, `lib/builtExport.ts`, `publish/build.ts` + tests, `controller/poolSummary.ts` + test, `docker/publish-site.sh`, `export/app/offline/page.tsx`, editor `settings/{xSessionActions.ts,page.tsx,components/{XSessionSection,XPostsVisibilityControl}.tsx}`, `sites/{actions.ts,components/SiteForm.tsx,lib/{buildAction,deployAction}.ts}`, e2e `x-session`, `sites-crud`, export `x-posts-private` | — (∥ all) | the compose integration test (public vs private site, flip back, an X-only public site); deploy refusals before wrangler and before the upload | Order: 0a → D0 ∥ T1 ∥ U1 → T2 ∥ U2 → T3 → parent: records, ONE editor rebuild + restart, umtool rebuild + restart (the restart is the operator's: the permission layer refuses the `0.0.0.0` bind) → the migration @@ -321,8 +322,497 @@ hand; a dirent `isFile()` filter over a video dir hides it."** The `.relocating. - The `en` track → 0 cues bug (index prefers `en` over `en-orig`; some `en` VTTs parse to 0 cues). - A channel export/import **bundle** built on `mediaTier.ts`'s classifier — the slice after this release. +## Slice XP — the ruling (2026-10-01) + +- **Every X post is hidden from the public, for now; the data is kept, and stays readable by the MCP + and umtool for the operator's own questions and tasks.** Fetching is not changed by this slice. +- **A setting, `social.x.visibility`: `"public"` (default) | `"private"`**, beside + `social.x.cookieSource`, chosen on `/settings` in the X account session section as "Where X posts + appear", with one sentence saying what private means and that sites already published change on + their next build and deploy. `"private"`: every X channel's posts (`sourceKind: "social"`, + `platform: "twitter"`) are left out of every PUBLIC site build and built only into PRIVATE sites. + Nothing on disk changes; flipping back is a rebuild. +- **A site audience, `site.json` `audience`: `"public"` (default, absent) | `"private"`**, on the + site's form with a sentence. A private site is **never deployed** — every deploy path refuses it + with a sentence naming the audience, before any upload, where the release 13 W3 wrong-site guard + runs; a build-only still works — and **never listed**: no `hubUrl`, in no homepage or hub listing. + Its `corpus.json` says `"audience": "private"`. +- **One predicate, `postsVisibleTo(site, channelConfig, settings)`**, pure and tested in + `common/lib`, called from the index build and from compose. The Search in row's Posts toggle keeps + working from what the build shipped (a public site whose only posts were X posts has no posts + corpus and no Posts toggle) — verified, not special-cased. The MCP needs no change; umtool's report + pipeline is checked for where it reads posts. +- Not in scope: stopping fetches; a per-platform toggle for Bluesky; deleting anything; editing + `transcripts/**` (the rollout — a private site holding every channel, the setting flipped, the + public sites rebuilt — is the parent's, through the editor's own writers). + ## Record +### Slice U1, as shipped — umtool's render scratch goes to a media root (2026-10-01) + +Branch `r17/umtool-media-root` off `main` `7f4901f1`, `main` `90bd8384` (the deck/posts-room merge) +merged in mid-slice, worktree `~/Projects/homepage-social-visible` (`pnpm wt list` block #11: editor +4101, test 4111, export 4110), one Opus implementer. Scratch files `U1-*` in the job's `tmp`. The +ruling is the plan's: render scratch (`out/`) goes to a media root by default; deliverables move per +project by a switch (slice U2); manifests, `revisions/`, the caches and the cue cache stay put. + +**What it does.** +- **Two roots, one new knob.** `umtool/lib/paths.mjs`: `MEDIA_ROOT = UMTOOL_MEDIA_DIR || REPORTS_ROOT`, + `MEDIA_TIERED` (they differ), `mediaMirror(abs, roots?)` (a path under `REPORTS_ROOT` → the same + relative path under `MEDIA_ROOT`, null outside; pure). `MEDIA_ROOT` joins `READ_ROOTS`, never + `WRITE_ROOTS`. Unset, nothing changes: `out/` is a directory in the project, and `READ_ROOTS` dedupes + it away. Every path op carries `turbopackIgnore`. +- **The cache leaves `SONG_DATA`.** `CACHE_DIR = UMTOOL_CACHE_DIR || $XDG_CACHE_HOME/archilyzer/umtool` + (an empty `XDG_CACHE_HOME` is unset, as `common/lib/paths.ts` reads it; default `~/.cache`). + `INDEX_DIR`, `MIX_CACHE`, the posters, loudness and clip audio follow it. `OLD_CACHE_DIR` + (`<SONG_DATA>/.cache/umtool`) is named only for the doctor. Nothing is migrated: the index is + rebuilt by `umtool index` and everything else is remade on demand. The cue cache + (`REPORT_CACHE_DIR`, `report-to-video/cues.mjs`) is untouched. +- **`umtool/lib/report/storage.mjs`** (new; modelled on `common/controller/relocateDir.ts`, not + importing it): + - `ensureOutDir(projectDir, roots?)`: a real `out/` → kept; a link to a directory → kept; a + **dangling link → refused** ("… is a link to …, which is not there — is the media drive mounted? + Nothing was written, and nothing was created in its place."); absent and tiered → + `mkdir -p <mirror>/out` and an absolute `symlink`; absent and not tiered → `mkdir` as before. The + media root itself is **stat'd and never created** (`mediaRootProblem`: missing, not a directory, or + inside/around `REPORTS_ROOT`). A project outside `REPORTS_ROOT` is never tiered. EEXIST from a + concurrent first writer is accepted when the winner resolves. + - `ensureWriteDir(dir)`: a directory a pipeline step writes into; when it is a project's `out` or up + to four levels under one, that `out` goes through `ensureOutDir` first, then `mkdir -p`. + - `moveDirToMedia(projectDir, name, opts)` / `moveDirToLocal(...)`: by NAME (`out` now; `clips`, + `share-*` for U2). Copy (`rsync -a --partial`), mirror toward the copy only (`-a --delete + --info=del`; refused when source and copy contain one another), verify (`--dry-run + --itemize-changes --delete` empty, one more mirror pass on a difference, a second refuses; equal + counts/bytes), then park (`<name>.moved-<ts>`), link, delete the parked copy. Space check on the + destination's volume (bytes + 1 GB). Every state is dispatched on the disk, so a cut run is + finished by running it again: a link to the mirror → `already` (a leftover parked copy removed); + absent with one parked copy → link and delete it; the reverse uses `<name>.incoming`, and after + the rename deletes the media copy and every directory above it the move left empty, never the + root. `dryRun` measures and changes nothing. + - `outDirState`, `pathState`, `measureTree` for readers and the CLI. +- **Call sites.** `build-video.mjs` (`outRoot`, before any fetch), `check-availability.mjs` (its one + write), `render-cards.mjs` (CLI `--out`), `compose-chrome.mjs` (its `out/<variant>` base), + `lib/report/onscreen.mjs` (`deckStill`'s scratch) all make `out/` through `ensureWriteDir`. + `lib/report/export.mjs` reads only: it now says "out/ is a link to …, which is not there — is the + media drive mounted?" instead of "no build" when the link dangles. tmp-then-rename sites (`cut.mjs`, + the clip route) are untouched. +- **The walk.** `kinds.mjs` `SKIP_DIRS` adds `clips` (`out` was already there) and `SKIP_PREFIXES = + ["share-"]`, read through `skipsDir(name)` by `walk.mjs`, so the project walk never stats a link + into a drive that is not there. The mix picker (`lib/media.ts`) follows a project's `out` link when + it points INTO the media root (so a tiered deliverable stays in the picker under its project) and + does not walk `MEDIA_ROOT` as a root of its own (it would list every tiered file twice). +- **CLI.** `umtool doctor` adds `roots` (JSON) / a "roots" block: reports, media (tiered or "= + reports"), cache (and whether an index exists), and the old cache with its size while it is there; + it exits 1 when the media root is set and missing (the tools' `ok` keeps its meaning). `umtool + storage [<project>]` lists every project's `out` (dir, link, DANGLING, none); `umtool storage + move-out|move-back <project>|--all [--dry-run] [--json]` runs the movers, one line per project and a + total; move-out without `UMTOOL_MEDIA_DIR` refuses once. +- **e2e env.** The app server and the specs' CLIs get `UMTOOL_CACHE_DIR=<fixture>/cache` + (`playwright.config.ts`, `projects.spec.ts`, `report-longform.spec.ts`; the index-deletion spec + now removes `cache/index`), so no run writes `~/.cache`. `make-fixture.mjs` adds + `storage-fixture` (a cached window, buildable offline), `storage-fresh-fixture` (no `out/`) and the + media root `umtool/.e2e-song-media/`, a sibling of the fixture (inside it would be inside + `REPORTS_ROOT`, which is refused), reset every run; `.gitignore` and umtool's trace excludes name + it. `storage.spec.ts` (new, 5): move-out (dry run first; the index's state and facts unchanged + through the link; again → already); **a build the app runs writes through the link and leaves it a + link** (the app has no `UMTOOL_MEDIA_DIR` at all); the root renamed away → `storage` says + dangling, `check-availability` refuses with the drive sentence on the moved project and with "is + not there" on the fresh one, no `out` made, the root not recreated, `doctor` exits 1; the first + writer of the fresh project makes the link; move-back → a real `out/`, the project's mirror gone, + the root and the other project's mirror kept. + +**Commits** + +| Commit | What | +|---|---| +| `65a3d146` | `umtool:` `MEDIA_ROOT`, `MEDIA_TIERED`, `mediaMirror`; `MEDIA_ROOT` in `READ_ROOTS`; `CACHE_DIR` from `UMTOOL_CACHE_DIR` / `XDG_CACHE_HOME`; `OLD_CACHE_DIR` | +| `47d6d1b4` | `umtool:` `lib/report/storage.mjs`; the five writers through `ensureWriteDir`; export's dangling sentence; `clips` + `share-*` skips; the picker follows `out` links into the media root; `doctor` roots; `umtool storage` | +| `4c2cd0c7` | merge of `main` `90bd8384` (the deck/posts-room branch: `build-video.mjs`, `make-fixture.mjs` and more) — one conflict, `export.mjs`'s imports, both kept | +| `cb08e57a` | `umtool:` `storage.test.mjs` (20); the e2e cache in the fixture; the storage fixtures, media root and `storage.spec.ts`; `umtool storage <project>` status | +| `efef56b3` | `umtool:` `docs/folders.md` (`MEDIA_ROOT`, `CACHE_DIR`), `docs/cli.md`; two `[Unreleased]` bullets in `editor/CHANGELOG.md` | +| this commit | `plans:` this section | + +#### Gates (logs `$T/U1-*`) + +- **tsc** (all workspaces) clean at `47d6d1b4`, at the merge `4c2cd0c7` and at `cb08e57a`. +- **common:** 2,484/2,484 (300 s, under load). **Editor unit:** 109/109. Neither touched; run on the + merged tree. +- **test:scripts:** 390 tests (the merged `main`'s 370 + `storage.test.mjs`'s 20): 386 passed, 1 + skipped, 3 failed, then 385/2/3 on a rerun — the three are `queue-lock.test.mjs` timing cases, a + different three each time, at a load average of 27–47 (other implementers' suites and builds); + `node --test scripts/queue-lock.test.mjs` alone: **11/11**. `storage.test.mjs` **20/20**. + `next-build-trace.test.mjs` is in it: **10/10** after each capped build below (its second skip on + the rerun is the staleness rule: the baseline run's `git checkout` of `main`'s umtool, below, gave + the modules new mtimes after the build). +- **The capped umtool build with the corpus linked** (`ln -sT <primary>/transcripts transcripts`, + 76 channels visible through it; `systemd-run --scope -p MemoryMax=5G -p MemorySwapMax=0`, `timeout + -s KILL 240`, the link removed after), at `cb08e57a`: **exit 0, 53 s, 0.83 GB peak**; and once more + with `UMTOOL_MEDIA_DIR` set to a scratch directory: **exit 0, 53 s, 0.83 GB**. The two builds' + `.nft.json` entries (39,658 each, every route) are **identical** (`diff` empty); none names + `transcripts`, the scratch media root or `.e2e-song`. (The worktree carries an old `transcripts/` + directory — an `index.mdb` — which `ln -sT` refuses to replace: the script sets it aside for the + build and puts it back. A first attempt that did not was stopped before it counted.) The capped + editor build was not run: no editor code changed (only `editor/CHANGELOG.md`). +- **Numbers tool:** none. +- **umtool e2e** (`SONG_DIR=~/reports/quartering-uh-song/data pnpm --filter umtool run e2e …` from + the worktree root; the fixture found song data, `cand2`, `wav48` and `media`, no `asr`, no face + detector, so `find.spec`'s 14 skip): + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 | `efef56b3` | `storage`, `projects`, `report-longform`, `dashboard` | 37 passed, 6 failed, 10.3 min — `storage.spec` **5/5**; the six (`dashboard` ×3, `projects` ×3) are `page.goto: net::ERR_ABORTED` and 30 s timeouts at a load average of 47, and all six pass in run 2 | + | 2 | `efef56b3` | the full suite (21 files) | **214 passed**, 17 failed, 14 skipped, 13.8 min of tests (48 min with 34 min in the queue) — `faces` ×4 (`/api/face/detect` 503: no detector here), `triage` ×9 (no `asr` here), `browse:241`, `mix:166`, `mix:201`, `usage:112` | + | 3 | `main` `90bd8384`'s umtool, checked out into the worktree and restored after | `browse`, `faces`, `mix`, `triage`, `usage` | 48 passed, 15 failed, 7.2 min — the same `faces` ×4 and `triage` ×9, plus `browse:15`/`:34` (30 s timeouts) | + | 4 | `efef56b3` | the same five | 49 passed, 14 failed, 3.2 min — `faces` ×4 and `triage` ×9 as on `main`; `browse:241`, `mix:166`, `mix:201` pass; `usage:112` fails again | + | 5 | `efef56b3` | `usage` | **7 passed**, 0 failed, 19.6 s | + + So against `main` on this machine: the `faces` and `triage` failures are the machine's (both + missing capabilities fail rather than skip — on `main` too); `mix:166`/`:201` and `browse:241` + fail only after the whole suite (the corpus window an earlier spec fetched for `vid1` wins the + picker's lookup), and pass in isolation on both; `usage:112` ("confirming the drop writes it + through") failed twice when it ran right after the failing `triage` specs on this branch, passed + once in that position on `main`, and passes alone — the verdict path it drives reads no cache and + no `out/`. Left to the reviewer as an order/timing question, not changed. + +#### Found and left + +- **Open question 2 — the four `*.mp4` near the project roots** (measured in `~/reports`, depth ≤ 2, + outside any `out/`): `kirsche-pippa/latest-contact-2026-06-20.mp4` (7.4 MB) is a cited clip fetched + through the MCP's `fetch_clip`, with its `.provenance.json` beside it — the sweep report's evidence, + a deliverable of a project that has no `out/`; `quartering-uh-song/jer-metalslug-bg.mp4` (51.5 MB) + and `quartering-uh-song/pokemon-no-music-recording.mp4` (3.1 MB) are song-project INPUTS (a song + spec's `background.path` names such a file relative to a media root, `song/spec.mjs`); `clips/ + tim-pool-…mp4` (23.6 MB) is a loose cut at the reports root, in no project. None is render scratch: + all four are left untouched, and none is under U2's `clips/` or `share-*/`. +- **What move-out would move today:** `umtool storage move-out --all --dry-run` against `~/reports` + (a scratch media root): **10 projects, 10.6 GB** (quartering-diet 5.0 GB, ferret-rescue 1.5 GB, + quartering-employee-count 1.2 GB, elfpire-eva 1.1 GB, …) — less than the plan's "≈ 18 of the 20 + GB": the rest of `~/reports` is song data and loose files, not project `out/`s. +- **Rollout, once:** set `UMTOOL_MEDIA_DIR` (the live umtool's environment) to a directory that + exists on the media drive, outside `~/reports`; restart umtool; run `umtool index` (the index is + rebuilt under `~/.cache/archilyzer/umtool`; until then everything works, slower); `umtool doctor` + shows the roots and the old cache (8.7 MB here), which can then be deleted; `umtool storage + move-out --all` (when nothing is building) moves the existing `out/`s. +- **A dangling `out` reads as "no build" to the summary readers** (`lib/projects/report.mjs`'s + `stat0(out)`, `readAvailability`): only `export` and the writers say "is the media drive mounted?". + `umtool storage` and `umtool doctor` name it. `umtool check` learning it is U2's (plan: "`umtool + check` learns the two values"). +- **For U2:** `lib/report/deliver.mjs` `listBatches` filters `isDirectory()` on the project's dirents, + so a `share-*` that is a link would vanish from it; `sharedIdsIn`'s walk likewise does not follow a + link. The movers take any one-segment name and return `{ state, src, dest|from, bytes, files }`. +- **A CLI move cannot see the app's jobs** (they live in its memory): the verify refuses when the tree + keeps changing, but a write in the instant between the verify and the park would be deleted with + the parked copy. The CLI says "run when nothing is building"; U2's bench button runs in the app and + can check. +- **The worktree's stray `transcripts/`** (an `index.mdb` from 2026-09-28) is the trap the rules + describe; left in place. + +#### Deviations from the plan + +- `ensureOutDir` is not called in `driver.mjs`: its step builders are synchronous, are unit-tested with + a fake project directory, and only build argv; the call is in the scripts those steps run + (`build-video.mjs`, `check-availability.mjs`) through `ensureWriteDir`, which also covers a + hand-run script and the three other writers the plan did not list (`render-cards.mjs`, + `compose-chrome.mjs`, `onscreen.mjs`). +- `export.mjs` writes nothing under `out/`, so it does not create it; it reports a dangling link instead. +- `umtool storage move-back` and the plain `umtool storage [<project>]` listing were added beside + `move-out`: the e2e needs the way back, and an operator needs to see which projects moved. +- `lib/media.ts` (not in the plan) follows `out` links into the media root and skips the root as its + own: without it every moved deliverable fell out of the mix picker. + +`[Unreleased]` (`editor/CHANGELOG.md`): "umtool can keep each report's render folder on a media +drive." and "umtool's cache moves to `~/.cache/archilyzer/umtool`". + +#### Review (SHIP AFTER FIXES) and the fixes + +| Finding | Fix | +|---|---| +| F1 — the e2e app and the spec CLIs spread the shell's environment, so a shell exporting `UMTOOL_MEDIA_DIR` would put every fixture build's `out/` on the real media drive | `683e0a0f`: `UMTOOL_MEDIA_DIR=` (empty = unset under `\|\|`) in the webServer command; `UMTOOL_MEDIA_DIR: ""` in the `projects`, `report-longform` and `dashboard` CLI envs (`dashboard`'s doctor also gets the fixture cache); `storage.spec` keeps its own | +| L1 — `doctor --json`'s `ok` was the tools' verdict while the exit status also counted the roots | `683e0a0f`: `ok = tools && roots`, `toolsOk` = the tools alone, `roots.ok` kept | +| L2 — a CLI move cannot see the app's jobs | `683e0a0f`: `move-out`/`move-back` (one project or `--all`) skip, as `busy` with the pids, any project a running pipeline script (`build-video`, `check-availability`, `render-cards`, `compose-chrome`, `verify-build`, `fetch-via-editor`, `resolve-windows`, `cut-from-cache`, `share-batch`) names on its command line (`/proc/*/cmdline`; none elsewhere). It does not see the app's in-process deck previews; U2's in-app button can ask the app's jobs | +| L3 — `dropMediaCopy` deleted any target inside "the media root", which untiered is the reports root | `a6926e17`: deletes only the project's own mirror, only when tiered and only when that is what came home; anything else is left and returned as `mediaCopyLeft` (the CLI prints "left in place … remove it by hand once checked"). A resumed move-back (the link already gone) reports the mirror, never deletes it | +| L4 — a move-back cut after its rename orphaned the media copy silently | `a6926e17`: "already" reports the project's mirror as `mediaCopyLeft` while it exists | +| L5 — a cut move's leftovers were not a guard | `a6926e17`: while `out.moved-*` or `out.incoming` exists, `ensureOutDir` (absent `out`) and both movers' directory branches refuse, naming the leftover and the move that finishes it; move-out refuses a lone `.incoming`, move-back a parked copy. `683e0a0f`: `folders.md` — the media root is a directory inside the drive, never the mountpoint; the leftovers rule | +| N1 — the `paths.mjs` comment named the wrong reason for `READ_ROOTS` | `683e0a0f`: it names `/api/mix/{media,track}` and the lexical write check | +| N2 — the walk did not skip `*.moved-*`/`*.incoming` | `683e0a0f`: `skipsDir` does | +| N3 — `isMediaLink` realpathed every link it met | `683e0a0f`, `45cf9946`: only an entry named `out` (U2 adds its names to `MEDIA_LINKS`) | +| N4 — the changelog bullet | `683e0a0f`: "in umtool's environment (restart umtool after setting it), to a directory inside that drive" | +| N5 — an empty mirror for a project that does not exist | `a6926e17`: `ensureOutDir` checks the project directory first | + +`683e0a0f` left `report-to-video`'s tsc red (the `isMediaLink` parameter type); `45cf9946` restored it. + +**Re-review (SHIP AFTER FIXES, no further round):** +- R1 — a real `out/` beside an `out.moved-*`/`out.incoming` sent each move to the other, which refused again → `4862ec4c`: both movers say both exist, that the leftover holds the moved data, to keep one and remove the other by hand, then run the move; `folders.md` says the same; the leftovers unit case asserts it for both movers. +- R2 — `ensureOutDir`'s project check used `lstat`, refusing a project directory that is itself a link → `4862ec4c`: `stat`; a unit case links a project in. +- N6 — the walk's leftover skip matched any `*.incoming`/`*.moved-*` folder → `4862ec4c`: only `out`, `clips` or `share-*` followed by one. +- Gates: umtool and `report-to-video` tsc clean, all workspaces clean; `storage.test.mjs` 24/24; `test:scripts` 394: 392 passed, 2 skipped (LIVE, and `next-build-trace`'s staleness skip — `storage.mjs` changed after the last build; its path ops gained one `stat`, with `turbopackIgnore`), 0 failed. + +**Gates after the fixes:** tsc clean at `45cf9946`. `storage.test.mjs` 24/24 (+4: the untiered +hand-made link, a tiered foreign link, the leftovers guard both ways, the ghost project; the +resumed-move-back case now expects the mirror reported and kept). `test:scripts` **394: 393 passed, 1 +skipped (LIVE), 0 failed**, `next-build-trace` passing against a fresh build. Capped umtool build with +the corpus linked (76 channels; the fixes touch `storage.mjs`'s path ops): exit 0, 39 s, 0.83 GB. +umtool e2e at `45cf9946`: `storage`, `projects`, `report-longform`, `dashboard` — **42 passed**, 1 failed, 2.3 min (after 45 min in the queue; `storage.spec` 5/5) — the one is `dashboard:14`, the run's first test, a 30 s timeout on the cold first page; `dashboard.spec.ts` alone right after: **7 passed**, 0 failed, 42 s. + +**Still left (follow-ups):** the project summary (`lib/projects/report.mjs`) stats `out/<slug>.mp4` +and reads `out/availability.json` through the link, so a **stalled** media drive blocks those reads, +libuv's threadpool and the project list, and `availability.json` — small hot text — now lives on the +media tier; a dangling link still reads as "no build" there (`umtool check` learning it is U2's). The +`usage.spec:112` order question (after the `triage` specs, at load) is for a quiet-machine run of +`triage.spec.ts usage.spec.ts` at integration. If a song project ever grows an `out/` and is moved, a +mix render into it lands on the media root through the link (the write check is lexical; that matches +the semantics). + +### Slice XP, as shipped — X posts are private (2026-10-01) + +Branch `r17/x-posts-private` off `main` `90bd8384`, worktree `~/Projects/r13-lows-export` (editor 5501, +test 5511, export 5510), one Opus implementer, beside the media-tier slices. Scratch files `XP-*` in the +job's `tmp`. The ruling is above ("Slice XP — the ruling"). + +**The listing side is release 14 slice HS's.** HS shipped `site.json` `listed` (absent = listed), the +one predicate `isListedSite`, and every listing that reads it: the homepage summary, +`channel-sites.json`, the pooled stats, the hub's `hub-sites.json` (and so its `corpus.json` and +`llms.txt`), every footer, and the form's **List on the Archilyzer homepage and hub** checkbox. This +slice adds no listing plumbing of its own: `isListedSite` gains one clause — a private site is never +listed, whatever `listed` says — and the checkbox stays as it is. What is new is the deploy refusal, +the private site's empty `hubUrl` and its `corpus.json` word, and "private content is built only into +private sites". + +**What it does.** +- **`social.x.visibility`: `"public"` (default, absent) | `"private"`**, beside `social.x.cookieSource` + (`XSocialSettings` and `sanitizeSocial` in `common/social/xCookieSource.ts`, the social block's home; + its doc in `settingsSchema.ts`; SETTINGS.md regenerated). On `/settings`, at the foot of the X account + session section, **Where X posts appear** (`XPostsVisibilityControl.tsx`): "Public — every site that + has the channel" | "Private — private sites only", with: "Private leaves every X channel and its posts + out of every public site and builds them only into sites whose audience is private, which are never + deployed; nothing is deleted and fetching goes on. Sites already published change on their next build + and deploy." Written by `setXPostsVisibilityAction` through `saveSettings`; "public" is written as no + key. +- **`site.json` `audience`: `"public"` (default, absent) | `"private"`** (`siteSchema.ts`, only + `"private"` written, `isPrivateSite` the one predicate; SITE.md regenerated). The site form has an + **Audience** select with a sentence; `saveSiteAction` keeps only `"private"`. +- **The rule, `common/lib/postsVisibility.ts`** (pure, tested): `postsVisibleTo(site, config, settings)` + — an X channel (`sourceKind: "social"`, `platform: "twitter"`) goes only to a private site while the + setting is private; every other channel is untouched — and `publishedMemberSlugs`, a site's members + narrowed by it. **A public site leaves the X channel out whole**: posts are all a social channel holds, + so without them it would be an empty checkbox and a name in `corpus.json`. Narrowed by the same call in + `buildIndex`'s **per-site loop** (the summaries, subs, posts and digests manifests; the site + fingerprint gains `withheld`, only when non-empty, so flipping the setting or the audience rebuilds + the site and nothing else moves) and in `compose-site` (every shared tree it copies, so a tree a + public site shipped before is pruned). The shared posts tree and the LMDB posts sub-DB stay + corpus-wide. `channel-sites.json` maps a channel only to the sites whose build carries it + (`channelSitesOf` takes the narrowing) and the export's `/offline` page lists the same members. +- **A private build says so and belongs under no hub**: `corpus.json`'s `site.audience` is `"private"`; + `resolveHubUrl` gives a private site no `hubUrl` (absent from its `site.json` and `corpus.json`). +- **Never deployed.** `lib/builtExport.ts`: `siteDeployProblem(site)` — `Site "x" is private (audience: + private): it is built for reading on this machine and is never deployed. Build it without deploying, + or set its audience to public on its Settings tab` — `builtAudienceProblem(outDir)` (a bundle whose + `corpus.json` says private, so a site switched back to public cannot ship its private build) and + `deployAudienceProblem`, both. Asked first, before any upload, where release 13 W3's + `builtBundleProblem` is asked: `runDeployIntoLog` (the last word before wrangler), + `runDockerDeployAllPhase` (skipped with the sentence, not failed, so Build & deploy all is not red + while a private site exists — the site is still built), `deploySite` (`archilyzer deploy site`, before + the R2 upload), the editor's `deployExportAction` and `buildAndDeployAction` (before a job exists; + Build & deploy asks again before its upload), the host Build & deploy all fallback + (`basicBuildAndDeployAll`, skipped before the upload), and `docker/publish-site.sh` (before building, + from `site.json`, and over the built `corpus.json` before publishing). The ops routes `build-deploy` + and `deploy-site` answer the actions' sentence (400). **Build** still builds a private site. +- **The Search in row's Posts toggle**: no code changed. `hasPostsCorpus` is "the site posts manifest + lists a channel", so a public site whose only posts were X posts ships `channels: []` and no Posts + box — pinned by the integration test (no `postScheme` either) and the export spec. + +**Where the rule lives — one deviation.** The prompt named the social-channel branch of `scanSource` +(`buildIndex.ts:333-341`). That branch is corpus-wide: it feeds the shared posts tree that every site, +and the MCP over a private build, read, so it must keep building X posts. The rule sits in the per-site +loop (`buildIndex.ts` ~1943 and the fingerprint), a different hunk from T1's `scanSource` guard. + +**Found on the way, fixed here.** +- **`saveSettings` merges one level deep, so a patch `{ social: { x } }` replaced the whole X block**: + choosing a login source would have erased the visibility, and the reverse. Both X actions now write + over the block as it is (`socialXPatch`); the e2e case pins both directions. +- **compose-site trusted its per-site cache after ANOTHER site's compose.** `public/` is one directory + every site composes into in turn (the basic build); the cache is per site, and a stage whose source + had not changed was skipped. So composing site B, then site A again with no new data, shipped B's + summaries — its whole channel list — as A's: a public site composed after a private one holding every + channel would have listed the private site's channels. The site's own stages (summaries, stats, + duplicates) are now trusted only when `public/site.json` names the site; `site.json` is cleared at + the start of a compose and written at its end, so a compose cut short leaves nothing to trust. The + per-channel trees keep their signatures (copies of the shared trees, the same bytes whichever site + copied them). Docker builds have a per-site `public/` and were not affected. +- **compose never ships a posts tree the site's posts manifest does not list** (the index build's + word), so a channel config compose fails to read — read as visible — does not ship X posts. + +**The MCP and umtool.** +- **The MCP needs no change**: it reads a composed export (`mcp/src/sources.ts`, `--local <dir>` | + `TRANSCRIPT_LOCAL_DIR`). A private site is composed into a directory of its own, without touching + `export/public`, from the checkout root: + ```sh + pnpm archilyzer index # or any editor build: the index build writes the per-site manifests + BUILD_ARCHIVES=0 EXPORT_PUBLIC_DIR="$HOME/archives/<private-id>" \ + EXPORT_INDEX_DIR="$PWD/export/.export-index" pnpm archilyzer compose site <private-id> + claude mcp remove archilyzer -s local # the name must be free; use the scope it was added in + claude mcp add archilyzer \ + --env ARCHILYZER_EDITOR_URL=http://localhost:3001 \ + --env WORKER_TOKEN=… \ + -- pnpm --silent -C "$PWD" archilyzer mcp --local "$HOME/archives/<private-id>" + ``` + The two `--env` lines are what `fetch_clip` needs (the editor's own `WORKER_TOKEN`, from + `editor/.env`); the name stays `archilyzer` for `/ask` and `/sweep` (AGENTS.md). + (`EXPORT_PUBLIC_DIR` must be absolute — the command runs in `common/`; the compose cache lands beside + it, in `$HOME/archives/.compose-cache/`.) The site's editor **Build** works too: it composes into + `export/public` and builds into `export/out`. **The current registration, `--local + <checkout>/export/public`, reads whatever site was composed there last**: after a public site's build + it has no X posts, after the private site's it has them. +- **umtool is unaffected**: the report pipeline's posts (the deck's posts room) are carried whole in the + report manifest — `posts[]` with `platform`, `date`, `text`, `url` (`validatePosts`, + `umtool/report-to-video/deck.mjs`), "added by editing the manifest" — and its cues come from the local + corpus or the archive `provenance.siteOrigin` names (videos only). It reads no public build's posts. A + sweep that looks posts up for a manifest goes through the MCP, pointed at the private build as above. + +**Commits** + +| Commit | What | +|---|---| +| `3ea76853` | `common:` `postsVisibility.ts` + test; `social.x.visibility`; `site.json` `audience` (`isListedSite`, `resolveHubUrl`); the per-site loop and compose narrowed; `corpus.json` `audience`; the deploy refusals (`builtExport`, `publish/build`) + tests; `channel-sites.json`; `/offline`; `publish-site.sh`; SETTINGS.md, SITE.md; the compose integration test | +| `0195d52a` | `editor:` Where X posts appear; the X block written whole; the Audience select; the deploy actions refuse a private site | +| `75ccbef3` | `editor(e2e), export(e2e):` `x-session` and `sites-crud` cases; `export/e2e/x-posts-private.spec.ts` | +| `30c14aa0` | `common:` compose never ships a posts tree the index withheld; the cache trusted only over the site's own last compose | +| `63002de3` | `common:` the per-channel trees keep their signatures across sites | +| `4ed7d410` | `plans:` this section, the ruling, the slices row; FACTS; the editor changelog | +| `6466a68e` | `common:` the hub carries no site's data; `builtHubProblem` refuses one that does (review HIGH 1) | +| `385e4eb1` | `common:` a compose over a stale index lists no withheld channel; the comments (LOW 3, NIT 7) | +| `8e448fde` | `editor:` the changelog (LOW 5) | +| `da5c2912` | `plans:` the review, its record, the rollout steps and the gates after it | +| `837d630a` | merge of `main` `bb877f93` (slice U1: umtool, `.gitignore`, and the two shared records — both sides kept, U1's section before this one). Re-gated on the merged tree: tsc clean; common **2,501/2,501**; export unit 98/98; homepage unit 23/23; editor unit 109/109; test:scripts 392 passed, 0 failed, 2 skipped (394). The merge touched no file the editor, export or hub e2e lists cover (umtool only), so they were not re-run | +| this commit | `plans:` the merge in this table | + +#### Gates (logs `$T/XP-*.log`) + +- **tsc** (all workspaces) clean before every commit; last at `63002de3`'s tree (`XP-tsc5.log`). +- **common:** **2,499/2,499** at `63002de3`, 161 s (`main`'s count + the new `postsVisibility.test.ts` + 7, `compose-site.postsVisibility.test.ts` 4, `build.test.ts` +3, `poolSummary.test.ts` +1); 2,498 at + `75ccbef3`. `archilyzer docs files --check` and `settings example --check` exit 0. **Editor unit:** + 109/109. **Export unit:** 98/98. **Homepage unit:** 23/23. **mcp:** 271/271 (no change there). +- **test:scripts:** 367 passed, 1 failed, 2 skipped of 370 (`XP-scripts2.log`; the first run had 2 + failed). The failures are `scripts/queue-lock.test.mjs`'s "prints a banner naming the holder while + waiting" (and once "serves waiters in arrival order"): timing cases (200 ms and 150 ms staggers) run + at a load average of 23–30 while other suites held the e2e queue; alone, the banner case still fails + under that load (10/11). This slice does not touch `scripts/` (`git diff 90bd8384 -- scripts/` is + empty). +- **Builds** at `75ccbef3` (the later commits touch only `compose-site`, a bin no Next app bundles): the + capped editor build with the corpus linked (`ln -sT`, `systemd-run --scope -p MemoryMax=6G`, the link + removed after) exit 0, 172 s; `pnpm --filter export exec next build` exit 0, 83 s (the `export/public` + links made, 0 dangling); `pnpm --filter homepage run build:nodata` exit 0, 47 s. +- **Numbers tool:** none. **Privacy gate:** `git diff main --name-only | xargs grep -lc …` names one + file, `plans/FACTS.md`, whose 3 matches are all on `main` already; 0 in this slice's added lines. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 (editor) | `75ccbef3` | `sites-crud`, `settings`, `x-session`, `forms-keep-input`, `deploy-page`, `site-publish-preview`, `sites-homepage` | **62 passed**, 0 failed, 4.0 min (after 35 min in the queue) | + | 2 (export) | `63002de3` | `x-posts-private` (new, 2), `search-in`, `posts-search` | **20 passed**, 0 failed, 3.0 min (after 7 min in the queue) | + + New cases: `x-session` "where X posts appear persists, beside the login source and without it" (the + whole-block write both ways); `sites-crud` "a private site saves its audience, and every deploy + refuses it before any job" (the form, the file, `build-deploy` and `deploy-site` answering the + sentence with 400, back to public with the key gone); export `x-posts-private` — the posts manifest a + public site built with X private ships (no channel: no Posts box, no X post for "kappa") and the one a + private site ships (the Posts box, both X posts). **The export suite's data is route-mocked, never + built**, so the build rule itself is proved by `common/bin/compose-site.postsVisibility.test.ts` + through the real `buildIndex` and compose: a public and a private site over one video, one X and one + Bluesky channel; the flip back; a public site whose only posts were X posts (empty manifest, no + `postScheme`); a config compose cannot read; a public site composed after the private one. + +#### Found and left + +- **An X channel's manifest-only transcripts tree** (pageCount 0) would still be copied into a public + site when compose fails to read the channel's config: a directory named for the channel, holding no + content. The posts tree, the posts manifest, the channel list and `corpus.json` follow the index build + and do not carry it. +- The `/sites` list does not mark a private site; its form and every deploy refusal do. +- **The posts reconcile ships only the channels the site's posts manifest lists, on every build**: a + social channel with 0 posts no longer gets a posts folder. Nothing advertised that folder (no posts + manifest entry, no `corpus.json` posts link), so nothing reads the difference. +- **A private site changes the homepage's and the hub's totals.** A private site is unlisted, and + `channelsOnlyOnUnlistedSites` keeps a channel only unlisted sites expose out of every public total. A + private site holding every channel turns every pool-only channel (on no public site) into "only on + unlisted sites", so the homepage's and the hub's instance-wide totals drop by those channels at the + next homepage and hub build. Channels a public site also has are unaffected. +- **Cloudflare Pages keeps old builds reachable.** A production redeploy replaces what the production + URL serves, nothing else: every earlier deployment stays live at its own + `<hash>.<project>.pages.dev`, and a preview alias (`<branch>.<project>.pages.dev`) keeps its last + build. The review found `tags-exclude.anilyzer.pages.dev/posts/manifest.json` still listing an X + channel. So after the rollout, X posts are gone from the production URLs only; whether to delete + the old deployments is the operator's call (rollout step 5). +- `queue-lock.test.mjs`'s two timing cases failed under the machine's load (below); not this slice's file. + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| A public site leaves an X channel out WHOLE (posts, posts manifest, channel list, `site.json`, `corpus.json`, `channel-sites.json`) | Keep the channel listed with no posts: an empty checkbox and a name in `corpus.json` | +| Build & deploy all builds a private site and SKIPS its deploy, with the sentence | Fail its deploy: the run goes red every time while a private site exists | +| A bundle whose `corpus.json` says private is refused even when the site is public now | Trust the site's current audience only (a stale private build could ship) | +| `docker/publish-site.sh` refuses a private site (the `site` service is the host's public face) | Let it publish locally behind Caddy | +| `social.x.visibility` lives in `xCookieSource.ts` with the rest of the social block | A module of its own | +| The hub's `search-aliases.json` is the global dictionary (it was whichever site composed last) | Ship none: hub-wide search in a reader (`reader-hub.ts`) would have no aliases | + +#### Rollout for this slice (the parent's, through the editor's own writers) + +1. Rebuild and restart the editor (the setting, the Audience field, the deploy refusals, the compose + and hub fixes). +2. Create the private site (Sites → New, **Audience: Private**, every channel), then set **Where X posts + appear: Private** on `/settings`. +3. Rebuild and deploy every public site that has an X channel (each by name; Build & deploy all skips the + private site's deploy and says why). +4. **Rebuild and deploy the hub** — the live hub serves the last-built site's data, X posts included, + until it is rebuilt from this branch (the review's HIGH 1). Then the homepage, for the totals. +5. **Old deployments** (the operator decides): every earlier production deployment and every preview + alias of a site that had X posts still serves them. To remove them: the Cloudflare dashboard → + Workers & Pages → the project → Deployments → a deployment's ⋯ menu → Delete deployment (a preview + alias's branch deployments are listed there too); or `pnpm dlx wrangler pages deployment list + --project-name <project>` then `pnpm dlx wrangler pages deployment delete <deployment-id> + --project-name <project>` (check `--help` for the force flag an aliased deployment needs). The + current production deployment cannot be deleted, and needs none. +6. Build the private site (its **Build**, or the compose-to-a-directory commands above) and point the + MCP at it. + +#### Review + +**Verdict: SHIP AFTER FIXES** (`XP-review.md` in the job's scratch): two Highs, four Lows, three nits. + +| Finding | Where | +|---|---| +| HIGH 1: the hub bundle carried the last-composed site's data trees (the live hub serves jeralyzer's posts manifest, two X channels), through no gate | `6466a68e`: `compose-hub` removes every per-site entry from `public/` first (`SITE_ONLY_PUBLIC_ENTRIES`: summaries, transcripts, subs, posts, digests, stats, archives, `site.json`, `tags.json`, `duplicates.json`, `search-aliases.json`, `chart-templates.json`, `sitemap.xml`; a worktree link by the link only) and writes the global alias dictionary as the hub's; `builtHubProblem` refuses a hub bundle that still carries a data tree, so Deploy hub refuses one. Tests: `compose-hub.test.ts` +1, `builtExport.test.ts` extended. Rollout step 4 | +| HIGH 2: Pages preview aliases and old deployments keep serving X posts | Record: "Found and left" and rollout step 5 (the operator decides; the dashboard and wrangler paths) | +| LOW 3: a compose over an index built before the flip listed the X channel's name and count | `385e4eb1`: the served posts manifest and summaries manifest are narrowed to the published members (`site.json`, `corpus.json` follow); a narrowed summaries copy is not trusted by the next compose. Test: "a compose over an index built before the setting flipped lists no X channel anywhere" | +| LOW 4: the MCP line lost `fetch_clip`'s env, and `add` fails over a registered name | This commit: `claude mcp remove archilyzer -s local` first, the two `--env` lines kept (`WORKER_TOKEN=…`) | +| LOW 5: the changelog missed the compose-cache fix and the restart notes | `8e448fde`: a bullet for the compose-cache fix, one for the hub, and the restart/redeploy notes | +| LOW 6: a private site holding every channel moves the homepage's and hub's totals | Record: "Found and left" | +| NIT 7: the deploy-all comment said the bundle check runs first | `385e4eb1`: the comment says the audience check runs first and what that means for a private wrong-site bundle | +| NIT 8: the posts reconcile drops a 0-post social channel's folder | Record: "Found and left" | +| NIT 9: `/sites` does not mark a private site | Already listed as left | + +#### Gates after the review + +- **tsc** (all workspaces) clean at `385e4eb1`'s tree (`XP-tsc6.log`). +- **common:** 2,500 passed, 1 failed of 2,501 (`XP-common4.log`; +2 since the first gates: the hub + clearing and the stale-index compose). The one failure is `relocateChannelMedia.test.ts`'s "reconcile: + an extra and a changed file on the destination are settled" (its diff listing counted `./` as + changed — a directory mtime); 3/3 alone, and the file is not this slice's. **Export unit:** 98/98. + **Homepage unit:** 23/23. **Editor unit:** 109/109. +- **No rebuild:** the fixes touch two bins (`compose-hub`, `compose-site`), `builtExport.ts` and + comments in `publish/build.ts`; no Next app bundles a changed module beyond `builtExport` (the + editor's hub action reads `builtHubProblem`, a pure function, tsc-checked). + + | Run | At | Specs | Result | + |---|---|---|---| + | 3 (editor) | `8e448fde` | the run-1 list | **62 passed**, 0 failed, 3.0 min (after 45 min in the queue) | + | 4 (export) | `8e448fde` | the run-2 list | **20 passed**, 0 failed, 1.1 min (after 50 min in the queue) | + | 5 (hub) | `8e448fde` | `e2e:hub`, the whole suite | **39 passed**, 0 failed, 1.6 min (after 12 min in the queue) | + + The hub suite runs `next dev` in hub mode over `export/public` and never composes, so it shows the + hub app is unchanged; the clearing itself is pinned by `compose-hub.test.ts`. + ### Slice D0, as shipped — the dashboard answers while a snapshot regenerates (2026-10-01) Branch `r17/dashboard-answers` off `main` `7f4901f1`, worktree `~/Projects/r13-lows-editor` (editor 5401, diff --git a/umtool/app/api/report/audio/route.ts b/umtool/app/api/report/audio/route.ts @@ -0,0 +1,119 @@ +import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { absOf, pickWindow, resolveClip, windowsFor } from "@/lib/report/serve.mjs"; +import { MAX_DECODE_SPAN, wavHeader } from "@/lib/report/playback.mjs"; +import { FFMPEG_BIN } from "umtool-report-to-video/build-video"; + +export const dynamic = "force-dynamic"; + +// A cached source window's SOUND, decoded, for the bench to play exactly. +// +// The bench plays every bounded range -- a selection, an edge, a line -- from +// a decoded buffer with Web Audio, because an <video> element stopped from +// `timeupdate` overran the end by up to a quarter of a second. This is that +// buffer: the same file /api/report/raw serves (picked by the same membership +// rule), as 16-bit PCM WAV, which every browser decodes without a codec. +// +// DECODED BY FFMPEG, NOT BY THE BROWSER. The build cuts with ffmpeg, so a cut +// set by ear has to be set against ffmpeg's timeline: a browser's own AAC +// decode may or may not honour the file's edit list, and the encoder's +// priming samples are tens of milliseconds -- the size of the error this +// exists to remove. +// +// `from`/`to` are ABSOLUTE source seconds, clamped to the file. A span longer +// than MAX_DECODE_SPAN is refused rather than served: a whole recording in the +// saved-video store is hours, and the bench asks for a window around the +// selection instead (decodeSpan). + +/** Matches the build's `render.audioRate` default; the browser resamples anyway. */ +const RATE = 48000; +const CHANNELS = 2; + +export async function GET(request: Request) { + const url = new URL(request.url); + const r = await resolveClip(url.searchParams.get("project") ?? "", url.searchParams.get("clip") ?? ""); + if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); + + const windows = await windowsFor(r.project, r.clip); + const win = pickWindow(windows, url.searchParams.get("file")); + if (!win) return Response.json({ error: "no cached window for this clip" }, { status: 404 }); + const abs = absOf(win); + if (!abs) return Response.json({ error: "outside the roots" }, { status: 400 }); + const st = await stat(abs).catch(() => null); + if (!st) return Response.json({ error: "gone" }, { status: 404 }); + + const num = (k: string, dflt: number) => { + const v = url.searchParams.get(k); + const n = v == null || v === "" ? dflt : Number(v); + return Number.isFinite(n) ? n : Number.NaN; + }; + const from = Math.max(win.from, num("from", win.from)); + const to = Math.min(win.to, num("to", win.to)); + if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) { + return Response.json({ error: "bad span" }, { status: 400 }); + } + if (to - from > MAX_DECODE_SPAN + 0.5) { + return Response.json( + { error: `asked for ${Math.round(to - from)} s; the bench decodes at most ${MAX_DECODE_SPAN} s at once` }, + { status: 400 }, + ); + } + + // Input seeking with a decode is sample-accurate in ffmpeg: it seeks to the + // keyframe before and discards up to the requested time. The build's ffmpeg + // (FFMPEG_BIN), so an override reaches this decode too; killed when the + // request is aborted (the bench moved on to another window or left). + const chunks: Buffer[] = []; + let err = ""; + const code = await new Promise<number>((resolve) => { + const ff = spawn(FFMPEG_BIN, [ + "-nostdin", "-v", "error", + "-ss", (from - win.from).toFixed(6), + "-i", abs, + "-t", (to - from).toFixed(6), + "-map", "a:0", + "-ac", String(CHANNELS), "-ar", String(RATE), + "-f", "s16le", "-acodec", "pcm_s16le", "-", + ], { signal: request.signal }); + ff.stdout.on("data", (b: Buffer) => chunks.push(b)); + ff.stderr.on("data", (b: Buffer) => { + err += b.toString(); + }); + ff.on("error", (e) => { + err += String(e); + resolve(-1); + }); + ff.on("close", (c) => resolve(c ?? -1)); + }); + // Nobody is waiting for it: the decode was killed, say so and stop. + if (request.signal.aborted) return new Response(null, { status: 499 }); + const pcm = Buffer.concat(chunks); + const frameBytes = CHANNELS * 2; + const dataBytes = pcm.length - (pcm.length % frameBytes); + if (code !== 0 || dataBytes === 0) { + // Said in the bench, which then plays through the element: the message is + // the reason the playback is approximate. + const why = /matches no streams|does not contain any stream/i.test(err) + ? "this file has no audio track" + : err.trim().split("\n").pop() || `ffmpeg exited ${code}`; + return Response.json({ error: why }, { status: 422 }); + } + + const body = new Uint8Array(44 + dataBytes); + body.set(wavHeader({ channels: CHANNELS, sampleRate: RATE, dataBytes }), 0); + body.set(pcm.subarray(0, dataBytes), 44); + return new Response(body, { + headers: { + "content-type": "audio/wav", + // The window is in the file's name, so the same request is the same + // sound: it may be cached for as long as the raw file is. + "cache-control": "private, max-age=3600, immutable", + // What was actually decoded, in absolute source seconds: the client + // places the buffer on the source clock from these, not from what it + // asked for. + "x-audio-from": String(from), + "x-audio-to": String(from + dataBytes / frameBytes / RATE), + "x-window": win.name, + }, + }); +} diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts @@ -23,6 +23,11 @@ export const dynamic = "force-dynamic"; // `draft` -- unsaved rows, id → { title?, subtitle? } | null, the shape PUT // /api/report/onscreen takes -- is applied on top. // +// The schedule carries the posts' holds (a carrying clip's `hold`, part of its +// length) and the footage's `moves` when `posts.shift` is on; the page eases +// its backdrop along them, and the posts region sits at `postsGeometry`, at +// the frame's edge when the footage makes room. +// // The posts region is composed beside it, one project per window // (postWindows: a clip that carries posts, from its first post's appearance to // the end of their leave), each loaded in its own iframe at @@ -87,6 +92,8 @@ export async function POST(request: Request) { src: `${deckPreviewSrc(r.project.id, r.variant)}?v=${stamp}`, variant: r.variant, geometry: deckGeometry(render), + // The ground a footage moved aside for the posts (schedule.moves) leaves showing. + background: typeof (render.palette as { bg?: unknown } | undefined)?.bg === "string" ? (render.palette as { bg: string }).bg : null, layout: deckLayout(render), schedule, posts: { diff --git a/umtool/app/api/report/clip/route.ts b/umtool/app/api/report/clip/route.ts @@ -59,6 +59,7 @@ export async function GET(request: Request) { cutStart: clip.cutStart ?? null, cutEnd: clip.cutEnd ?? null, lockCut: !!clip.lockCut, + muteFrom: clip.muteFrom ?? null, }, view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts @@ -47,6 +47,10 @@ export async function PUT(request: Request) { "cutStart", "cutEnd", "lockCut", + // Where the sound fades out for the rest of the clip while the picture + // plays on, in source seconds; empty or null clears it. Checked by the + // writer against the clip's window, like the cut. + "muteFrom", // Whether the walk has looked at this clip: "confirmed", or empty to clear // it. A non-empty `correction` is the other answer and needs no value. "verdict", diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs @@ -27,11 +27,15 @@ // umtool new <slug> [--kind report-video] [--from <report.md>|<share URL>|<channel>/<id>] // [--site-origin URL] [--seed chapters] [--brand archilyzer-media] // umtool doctor [--json] exit 1 if the report pipeline is missing a tool +// or the media root is set and not there +// umtool storage [move-out|move-back <project>|--all] [--dry-run] [--json] +// where each project's out/ lives; move it // umtool snapshot <project> [--label L] copy the manifest into revisions/ // umtool diff <project> <snapshot> what changed since that snapshot // umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V] // umtool check-sources [<project>…] prints the re-check chain import process from "node:process"; +import { readdirSync, readFileSync } from "node:fs"; import { PROJECT_KINDS, REPORTS_ROOT, @@ -60,6 +64,8 @@ import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs import { openIndex, signRecord } from "../lib/projects/index-db.mjs"; import { probeTools } from "../lib/tools.mjs"; import { scaffoldReportVideo } from "../lib/projects/scaffold.mjs"; +import { CACHE_DIR, INDEX_DIR, MEDIA_ROOT, MEDIA_TIERED, OLD_CACHE_DIR } from "../lib/paths.mjs"; +import { measureTree, mediaRootProblem, moveDirToLocal, moveDirToMedia, outDirState, pathState } from "../lib/report/storage.mjs"; const argv = process.argv.slice(2); const cmd = argv.find((a) => !a.startsWith("-")) ?? "help"; @@ -338,11 +344,37 @@ function cmdKinds() { } } +/** + * The roots, as the doctor reports them: where projects are read, where their + * out/ goes, where the cache is -- and a cache left where it used to live + * (under SONG_DATA, before release 17), which is derived and safe to delete + * once `umtool index` has rebuilt the new one. + */ +async function rootsReport() { + const isDir = async (p) => (await pathState(p)).kind === "dir"; + const problem = await mediaRootProblem(); + const oldPresent = OLD_CACHE_DIR !== CACHE_DIR && (await isDir(OLD_CACHE_DIR)); + return { + ok: !problem, + reports: { path: REPORTS_ROOT, present: await isDir(REPORTS_ROOT) }, + media: { path: MEDIA_ROOT, tiered: MEDIA_TIERED, present: await isDir(MEDIA_ROOT), problem }, + cache: { path: CACHE_DIR, present: await isDir(CACHE_DIR), index: await isDir(INDEX_DIR) }, + oldCache: oldPresent + ? { path: OLD_CACHE_DIR, present: true, bytes: (await measureTree(OLD_CACHE_DIR)).bytes } + : { path: OLD_CACHE_DIR, present: false }, + }; +} + +const mb = (n) => `${(n / 1024 ** 2).toFixed(1)} MB`; + async function cmdDoctor() { // The one command that shells out on purpose. Seven version flags, ~100 ms. const r = await probeTools(); + const roots = await rootsReport(); if (json) { - out(r); + // `ok` is what the exit status says (a script gates on either); the tools' + // own verdict stays readable as toolsOk. + out({ ...r, ok: r.ok && roots.ok, toolsOk: r.ok, roots }); } else { for (const t of r.tools) { const mark = t.present ? "ok " : t.required ? "MISSING" : "absent"; @@ -356,8 +388,134 @@ async function cmdDoctor() { ? "\nthe report pipeline can build here" : "\nthe report pipeline is MISSING a tool it cannot run without", ); + console.log("\nroots"); + console.log(` reports ${roots.reports.path}${roots.reports.present ? "" : " (not there)"}`); + console.log( + roots.media.tiered + ? ` media ${roots.media.path} — every project's out/ is linked here (UMTOOL_MEDIA_DIR)` + + (roots.media.problem ? `\n MISSING ${roots.media.problem}` : "") + : ` media = reports (UMTOOL_MEDIA_DIR unset): out/ stays in each project`, + ); + console.log( + ` cache ${roots.cache.path}` + + (roots.cache.index ? "" : " (no index yet — `umtool index` builds it; everything works without)"), + ); + if (roots.oldCache.present) { + console.log( + ` old cache ${roots.oldCache.path} ${mb(roots.oldCache.bytes ?? 0)} — the cache's old place, ` + + "no longer read; derived, safe to delete", + ); + } + } + process.exit(r.ok && roots.ok ? 0 : 1); +} + +// --------------------------------------------------------------------------- +// storage: where each project's out/ lives, and moving it (release 17). +// +// umtool storage every project's out/: dir | link | DANGLING | none +// umtool storage move-out <p>|--all out/ to the media root, a link left in its place +// umtool storage move-back <p>|--all out/ back to a real directory in the project +// --dry-run measure and say; change nothing +// +// The movers are lib/report/storage.mjs's, which `umtool` and the app share. +// Run a move when nothing is building: the app's jobs live in its memory, so +// this cannot see them -- the verify refuses when the tree keeps changing, but +// a write in the last instant before the swap would be lost with the parked copy. +// --------------------------------------------------------------------------- +/** + * The pids of report-pipeline processes whose command line names this project + * (its manifest, its out/, or the directory itself). Linux /proc; elsewhere, + * none. Cheap and coarse: it sees the pipeline's scripts, not the app's + * in-process deck previews. + */ +function pipelineProcessesFor(projectDir) { + const SCRIPTS = /(build-video|check-availability|render-cards|compose-chrome|verify-build|fetch-via-editor|resolve-windows|cut-from-cache|share-batch)\.mjs/; + const pids = []; + let entries = []; + try { + entries = readdirSync("/proc").filter((n) => /^\d+$/.test(n)); + } catch { + return pids; + } + for (const pid of entries) { + if (Number(pid) === process.pid) continue; + let args; + try { + args = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0"); + } catch { + continue; + } + if (!args.some((a) => SCRIPTS.test(a))) continue; + if (args.some((a) => a === projectDir || a.startsWith(projectDir + "/"))) pids.push(Number(pid)); + } + return pids; +} + +async function cmdStorage() { + const sub = positional[0]; + const dryRun = has("--dry-run"); + const log = (m) => (json ? console.error(m) : console.log(m)); + + if (sub !== "move-out" && sub !== "move-back") { + // `umtool storage`, `umtool storage <project>`, `umtool storage status [<project>]`. + const one = sub === "status" ? positional[1] : sub; + const refs = one ? [await pick(one)] : await projectRefs(); + const rows = []; + for (const p of refs) rows.push({ id: p.id, ...(await outDirState(p.dir)) }); + if (json) return out({ media: { path: MEDIA_ROOT, tiered: MEDIA_TIERED }, projects: rows }); + console.log(MEDIA_TIERED ? `media root ${MEDIA_ROOT}` : "media root unset (UMTOOL_MEDIA_DIR): out/ stays in each project"); + for (const r of rows) { + const what = { absent: "none", dir: "dir", link: "link", dangling: "DANGLING", other: "OTHER" }[r.state] ?? r.state; + console.log(`${what.padEnd(9)} ${r.id}${r.target ? ` -> ${r.target}` : ""}`); + } + return; + } + + const move = sub === "move-out" ? moveDirToMedia : moveDirToLocal; + if (sub === "move-out" && !MEDIA_TIERED) { + die("UMTOOL_MEDIA_DIR is not set: there is no media root to move out/ to. Set it to a directory on the media drive (outside the reports root)."); + } + const all = has("--all"); + if (!all && !positional[1]) die(`which project? \`umtool storage ${sub} <project>\` or --all`); + const refs = all ? await projectRefs() : [await pick(positional[1])]; + + const results = []; + let failed = 0; + for (const p of refs) { + // The app's jobs live in its memory, but the pipeline runs as processes: + // one whose command line names this project is building it now. + const busy = pipelineProcessesFor(p.dir); + if (busy.length) { + results.push({ id: p.id, state: "busy", pids: busy }); + if (!json) console.log(`${"busy".padEnd(12)} ${p.id} — a pipeline process is writing it (pid ${busy.join(", ")}); skipped`); + continue; + } + try { + const r = await move(p.dir, "out", { dryRun, log }); + results.push({ id: p.id, ...r }); + if (!json && r.state !== "absent") { + const size = r.bytes !== undefined ? ` ${r.files} file(s), ${mb(r.bytes)}` : ""; + console.log(`${r.state.padEnd(12)} ${p.id}${size}`); + if (r.mediaCopyLeft) console.log(` left in place: ${r.mediaCopyLeft} (not deleted; remove it by hand once checked)`); + } + } catch (e) { + failed += 1; + results.push({ id: p.id, state: "failed", error: e?.message ?? String(e) }); + if (!json) console.log(`${"FAILED".padEnd(12)} ${p.id}\n ${e?.message ?? e}`); + } + } + const bytes = results.reduce((n, r) => n + (r.bytes ?? 0), 0); + if (json) out({ ok: failed === 0, dryRun, bytes, results }); + else { + const moved = results.filter((r) => r.state === "moved" || r.state === "would-move").length; + console.log( + `\n${moved} project(s) ${dryRun ? "would move" : "moved"}, ${mb(bytes)}` + + (failed ? `; ${failed} FAILED` : "") + + (dryRun ? " — dry run, nothing changed" : ""), + ); } - process.exit(r.ok ? 0 : 1); + if (failed) process.exit(1); } async function cmdSnapshot() { @@ -462,6 +620,10 @@ function usage() { " umtool new <slug> [--from <report.md>|<share URL>|<channel>/<id>] [--site-origin URL] [--seed chapters]", " [--brand archilyzer-media] render.brand: the report-to-video brand preset", " umtool doctor [--json] exit 1 if the report pipeline is missing a tool", + " or the media root is set and not there; the roots, and a leftover old cache", + " umtool storage [<project>] where each project's out/ lives (dir, link, DANGLING)", + " umtool storage move-out|move-back <project>|--all [--dry-run]", + " out/ to the media root (UMTOOL_MEDIA_DIR) and back; run when nothing is building", " umtool snapshot <project> [--label L] copy the manifest into revisions/", " umtool diff <project> <snapshot> what changed since that snapshot", " umtool export <project> --format toc-bbcode|toc-markdown|description|chapters [--variant V]", @@ -488,6 +650,7 @@ const COMMANDS = { folders: cmdFolders, kinds: cmdKinds, doctor: cmdDoctor, + storage: cmdStorage, snapshot: cmdSnapshot, diff: cmdDiff, export: cmdExport, diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -11,6 +11,16 @@ import { buttonVariants } from "@/components/ui/button"; // would only find out twenty minutes into a build. import { attributionLine } from "umtool-report-to-video/attribution"; import { + MUTE_FADE, + START_LEAD, + bufferSchedule, + covers, + decodeSpan, + elementShouldStop, + muteRamp, + playheadAt, +} from "@/lib/report/playback.mjs"; +import { DeckFrame, NeutralFrame, composePreview, @@ -100,6 +110,12 @@ type Clip = { cutEnd: number | null; /** The cut is deliberate; `resolve-windows --cut-to-quote` leaves it alone. */ lockCut: boolean; + /** + * The MUTE MARK, in source seconds: from here to the end of the clip the + * sound fades out and the picture plays on. Set by ear, at the last silence + * before a finale's ending sound. Absent means the clip plays with its sound. + */ + muteFrom: number | null; /** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */ verdict: "confirmed" | "incorrect" | null; /** What the on-screen panel says over this clip. Absent means the auto text. */ @@ -180,6 +196,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({ cutStart: e.cutStart == null ? null : Number(e.cutStart), cutEnd: e.cutEnd == null ? null : Number(e.cutEnd), lockCut: !!e.lockCut, + muteFrom: e.muteFrom == null ? null : Number(e.muteFrom), verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null, onscreen: (e.onscreen as Onscreen | undefined) ?? null, }); @@ -303,6 +320,62 @@ const clock = (t: number) => { return `${Math.floor(m / 60) > 0 ? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}` : m}:${String(s % 60).padStart(2, "0")}`; }; +/** What `save window` (and a confirmation that moved something) writes. */ +type WindowPatch = { + start: number; + end: number; + cutStart?: string; + cutEnd?: string; + /** A number to set the mark, "" to clear it; absent leaves it alone. */ + muteFrom?: number | string; +}; + +/** A decoded span of the cached window, placed on the source clock. */ +type Decoded = { name: string; span: { from: number; to: number }; buf: AudioBuffer }; + +/** + * What is playing, and what the last playback measured. + * + * On the page as data attributes (`data-play-*`), because "did it stop where + * the selection ends" is the claim this bench now makes, and a spec has to be + * able to check it against the audio clock rather than against a feeling. + */ +type PlayInfo = { + engine: "webaudio" | "element"; + state: "playing" | "stopped"; + from: number; + /** The scheduled end, in source seconds. */ + to: number; + rate: number; + /** Context clock: when the source starts and when it is told to stop. */ + ctxStart: number | null; + ctxStop: number | null; + /** + * Where the playback had reached, in source seconds, when the page heard it + * end: the element's own position at its pause, or the context clock at + * `ended` -- which reaches the page a task later than the sound stopped, so + * for Web Audio it is an upper bound and `ctxStop` is the stop itself. + */ + endedAt: number | null; +}; + +/** The playback in flight. One at a time: a new one stops the last. */ +type Session = { + gen: number; + engine: "webaudio" | "element"; + node: AudioBufferSourceNode | null; + gain: GainNode | null; + raf: number; + timers: number[]; + from: number; + to: number; + rate: number; + t0: number; + ctxStop: number; + /** The element's own mute, put back when the picture stops following. */ + mutedBefore: boolean; +}; + export default function ClipBench({ data }: { data: ClipBenchData }) { const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); @@ -310,6 +383,13 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [proposed, setProposed] = useState(data.proposed); const [view, setView] = useState(data.view); const [sel, setSel] = useState({ from: data.clip.start, to: data.clip.end }); + // The mute mark as drafted. Like the edges it is unsaved until `save window` + // or `y`, and it is what the bench's own playback mutes at -- so a mark is + // heard before it is written. + const [mute, setMute] = useState<number | null>(data.clip.muteFrom); + // Armed: the next click on the waveform places the mark instead of moving + // an edge. + const [mutePick, setMutePick] = useState(false); const [peaks, setPeaks] = useState<Peaks | null>(null); // The cues AROUND the cached window: what is coming, read before paying for // the media. One request, widened only when somebody asks. @@ -319,6 +399,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const [cutScore, setCutScore] = useState<number | null>(null); const [peekPad, setPeekPad] = useState(PEEK_STEP); const [playhead, setPlayhead] = useState<number | null>(null); + const playheadRef = useRef<number | null>(null); + useEffect(() => { + playheadRef.current = playhead; + }, [playhead]); const [note, setNote] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null); const [dirty, setDirty] = useState(false); @@ -356,7 +440,6 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const token = useRef(data.token); // And they must not interleave: same read-modify-write, one clip. const saving = useRef<Promise<boolean>>(Promise.resolve(true)); - const stopAt = useRef<number | null>(null); // `x` answers "no" by putting the cursor in the note, which is the answer. const correctionBox = useRef<HTMLTextAreaElement | null>(null); const segVideo = useRef<HTMLVideoElement | null>(null); @@ -532,20 +615,366 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }, [playback, segment, cached, renderedOpen, deckPreview]); // ---- audition ----------------------------------------------------------- - const play = useCallback( - (from: number, to: number) => { + // + // EVERY BOUNDED RANGE PLAYS FROM DECODED AUDIO. A selection, an edge, a line + // of the transcript, the auto-audition: each is an AudioBufferSourceNode + // started at an exact offset and stopped at an exact time on the audio + // clock, so what you hear ends where the selection ends, to the sample. The + // <video> used to play these itself and was stopped from `timeupdate`, which + // fires every 15-250 ms -- the stop overran by up to a quarter of a second, + // and by a different amount every time, which is exactly what makes a cut + // between two words impossible to judge by ear. (The old HTML chooser had + // this right; it is the same technique.) + // + // The picture FOLLOWS, muted, and the playhead is read off the audio clock. + // When the decode fails the element plays instead, stopped per animation + // frame by remaining time, and the bench says so. + + /** The decoded audio for the cached window: one decode per window. */ + const decoded = useRef<Decoded | null>(null); + const decoding = useRef<{ key: string; p: Promise<Decoded> } | null>(null); + const actx = useRef<AudioContext | null>(null); + const session = useRef<Session | null>(null); + const playGen = useRef(0); + // Pauses the bench asked for itself and has not yet heard the event of. A + // `pause` event is a task, so it arrives AFTER the next playback has started: + // without the count, stopping one playback to start the next would read as + // somebody pausing the picture, and stop the new one. + const selfPauses = useRef(0); + const pauseVideo = useCallback((el: HTMLVideoElement) => { + if (el.paused) return; + selfPauses.current += 1; + el.pause(); + }, []); + // Read by play() at the moment it schedules, so a mark moved since the last + // render is the one you hear -- without re-creating play() on every nudge. + const muteRef = useRef<number | null>(data.clip.muteFrom); + const [audio, setAudio] = useState<{ state: "idle" | "loading" | "ready" | "failed"; why?: string }>({ + state: "idle", + }); + const [playInfo, setPlayInfo] = useState<PlayInfo | null>(null); + + /** + * The cached window's sound over (at least) `want`, decoded once. + * + * Through /api/report/audio -- ffmpeg's decode of the same file the player + * loads, as PCM -- and decoded here on an OfflineAudioContext, so no audio + * output is opened before somebody asks to hear something. A buffer belongs + * to no context and plays in the real one. + */ + const ensureDecoded = useCallback( + async (want: { from: number; to: number }): Promise<Decoded> => { + if (!cached) throw new Error("nothing is cached for this clip"); + const have = decoded.current; + if (have && have.name === cached.name && covers(have.span, want.from, want.to)) return have; + const span = decodeSpan({ from: cached.from, to: cached.to }, want); + const key = `${cached.name}|${span.from}|${span.to}`; + if (decoding.current?.key === key) return decoding.current.p; + const p = (async () => { + setAudio({ state: "loading" }); + const r = await fetch( + `/api/report/audio?project=${encodeURIComponent(data.project)}&clip=${encodeURIComponent(clip.id)}` + + `&file=${encodeURIComponent(cached.name)}&from=${span.from}&to=${span.to}`, + ); + if (!r.ok) { + const j = (await r.json().catch(() => null)) as { error?: string } | null; + throw new Error(j?.error ?? `the audio route answered ${r.status}`); + } + const from = Number(r.headers.get("x-audio-from") ?? span.from); + const bytes = await r.arrayBuffer(); + const Offline = + typeof window !== "undefined" ? (window.OfflineAudioContext ?? null) : null; + if (!Offline) throw new Error("this browser has no Web Audio"); + const buf = await new Offline(2, 1, 48000).decodeAudioData(bytes); + const d: Decoded = { name: cached.name, span: { from, to: from + buf.duration }, buf }; + decoded.current = d; + setAudio({ state: "ready" }); + return d; + })(); + decoding.current = { key, p }; + p.catch((e: unknown) => { + if (decoding.current?.p === p) decoding.current = null; + setAudio({ state: "failed", why: e instanceof Error ? e.message : String(e) }); + }); + return p; + }, + [cached, data.project, clip.id], + ); + + // A different window -- a wider one after "fetch more", or the first after a + // fetch -- is different audio: drop the old decode and start the new one + // now, so the first play after it does not wait. + const cachedName = cached?.name ?? null; + useEffect(() => { + decoded.current = null; + decoding.current = null; + setAudio({ state: "idle" }); + if (!cachedName) return; + ensureDecoded({ from: clip.start, to: clip.end }).catch(() => { + /* said in the bench; play() falls back to the element */ + }); + // ensureDecoded changes identity with `cached`, which is this effect's + // whole subject; keying on the NAME is what stops a refresh() that returns + // the same window from decoding it again. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cachedName]); + + /** Stop whatever is playing, Web Audio or element, and let go of it. */ + const stopPlayback = useCallback(() => { + const s = session.current; + if (!s) return; + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + if (s.node) { + s.node.onended = null; + try { + s.node.stop(); + } catch { + /* never started, or already stopped */ + } + s.node.disconnect(); + s.gain?.disconnect(); + } + const el = video.current; + if (el) { + pauseVideo(el); + el.muted = s.mutedBefore; + } + setPlayInfo((pi) => (pi && pi.state === "playing" ? { ...pi, state: "stopped" } : pi)); + }, [pauseVideo]); + + /** + * The fallback: the element, stopped per animation frame by REMAINING TIME. + * + * `timeupdate` was the old stop and is the overrun this replaces; a frame + * tick that stops once the end is under half a frame away is the best the + * element can do, and the bench says that this is what is playing. + */ + const playElement = useCallback( + (from: number, to: number, gen: number) => { const el = video.current; if (!el || !cached) return; + const rate = playback.rate; el.currentTime = Math.max(0, from - fetchStart); - el.playbackRate = playback.rate; - stopAt.current = to; + el.playbackRate = rate; + const s: Session = { + gen, + engine: "element", + node: null, + gain: null, + raf: 0, + timers: [], + from, + to, + rate, + t0: 0, + ctxStop: 0, + mutedBefore: el.muted, + }; + session.current = s; + setPlayInfo({ engine: "element", state: "playing", from, to, rate, ctxStart: null, ctxStop: null, endedAt: null }); + const stopNow = () => { + if (session.current !== s) return; + pauseVideo(el); + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + el.muted = s.mutedBefore; + setPlayInfo((pi) => (pi ? { ...pi, state: "stopped", endedAt: el.currentTime + fetchStart } : pi)); + }; + const tick = () => { + if (session.current !== s) return; + const t = el.currentTime + fetchStart; + setPlayhead(t); + // The mark, as near as a frame tick gets it. + const m = muteRef.current; + el.muted = s.mutedBefore || (m != null && t >= m); + if (!el.paused && elementShouldStop(t, to, rate)) { + stopNow(); + return; + } + // Inside the last few frames, a timer for the REMAINING time: it lands + // between frames, where the next tick would land up to a frame late. + const remaining = (to - t) / rate; + if (!el.paused && remaining < 0.1 && !s.timers.length) { + s.timers.push(window.setTimeout(stopNow, remaining * 1000)); + } + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); // A rejected play() is normal, not a bug: Chrome refuses unmuted audio on // a document nobody has interacted with (a typed URL, a fresh tab), and // an unhandled rejection in that case would be noise. The seek has // already happened either way. void el.play().catch(() => {}); }, - [cached, fetchStart, playback.rate], + [cached, fetchStart, playback.rate, pauseVideo], + ); + + const play = useCallback( + async (from: number, to: number) => { + const gen = (playGen.current += 1); + stopPlayback(); + if (!cached || !(to > from)) return; + const rate = playback.rate; + // The context is opened HERE, inside the click or the key that asked: + // that gesture is what lets it start. + let ac = actx.current; + if (!ac && typeof window !== "undefined" && window.AudioContext) { + ac = new window.AudioContext(); + actx.current = ac; + } + const resumed = ac && ac.state !== "running" ? ac.resume().catch(() => {}) : null; + let d: Decoded; + try { + if (!ac) throw new Error("this browser has no Web Audio"); + d = await ensureDecoded({ from, to }); + } catch { + if (gen === playGen.current) playElement(from, to, gen); + return; + } + if (gen !== playGen.current) return; + // Longer than one decode: the element, rather than a second decode in + // the middle of a click. + if (!covers(d.span, from, to)) { + playElement(from, to, gen); + return; + } + if (resumed) await Promise.race([resumed, new Promise((res) => setTimeout(res, 300))]); + // No gesture has ever reached this document, so the context may not + // start: the same refusal a muted-autoplay policy gives the element, and + // just as silent. + if (gen !== playGen.current || ac!.state !== "running") return; + const ctx = ac!; + const sch = bufferSchedule(d.span, d.buf.duration, from, to, rate); + if (!sch) return; + + const node = ctx.createBufferSource(); + node.buffer = d.buf; + node.playbackRate.value = rate; + const gain = ctx.createGain(); + node.connect(gain); + gain.connect(ctx.destination); + // Ahead of now, so start and stop stay on the clock they were computed + // on: a start in the past begins late at the SAME offset. + const t0 = ctx.currentTime + START_LEAD; + const ctxStop = t0 + sch.wall; + const m = muteRamp(muteRef.current, from, from + sch.duration, rate); + if (m) { + if (m.at <= 0) gain.gain.setValueAtTime(0, t0); + else { + gain.gain.setValueAtTime(1, t0 + m.at); + gain.gain.linearRampToValueAtTime(0, t0 + m.at + m.fade); + } + } + // The stop is a TIME on the context's clock, not start()'s duration + // argument: that one is buffer content, and a clock time means the same + // thing at every speed. + node.start(t0, sch.offset); + node.stop(ctxStop); + + const el = video.current; + const s: Session = { + gen, + engine: "webaudio", + node, + gain, + raf: 0, + timers: [], + from, + to: from + sch.duration, + rate, + t0, + ctxStop, + mutedBefore: el?.muted ?? false, + }; + session.current = s; + setPlayInfo({ + engine: "webaudio", + state: "playing", + from, + to: s.to, + rate, + ctxStart: t0, + ctxStop, + endedAt: null, + }); + + // The picture follows, muted. It is not what is being judged; a few + // milliseconds of drift between the two is not worth a sync loop. + if (el) { + el.muted = true; + el.playbackRate = rate; + el.currentTime = Math.max(0, from - fetchStart); + s.timers.push( + window.setTimeout(() => { + if (session.current === s) void el.play().catch(() => {}); + }, START_LEAD * 1000), + ); + } + + node.onended = () => { + if (session.current !== s) return; + // How late the context clock reads at `ended`, mapped back onto the + // source: what the playback measured, for the bench's own record. + const late = ctx.currentTime - ctxStop; + session.current = null; + cancelAnimationFrame(s.raf); + for (const t of s.timers) clearTimeout(t); + node.disconnect(); + gain.disconnect(); + if (el) { + pauseVideo(el); + el.muted = s.mutedBefore; + } + setPlayhead(s.to); + setPlayInfo((pi) => (pi ? { ...pi, state: "stopped", endedAt: s.to + Math.max(0, late) * rate } : pi)); + }; + + // The playhead at ~30 Hz, not every frame: each update re-renders the + // bench, and the line moving smoothly is not worth that at 60. + // + // And the picture is pulled back to the sound when it drifts by more + // than a quarter second -- which it does after a seek into a file that + // is only partly loaded, where the element starts late by however long + // the bytes took. + let painted = -1; + let checked = t0; + const tick = () => { + if (session.current !== s) return; + const now = ctx.currentTime; + const pos = playheadAt(from, t0, now, rate, sch.duration); + if (now - painted >= 1 / 30) { + painted = now; + setPlayhead(pos); + } + if (el && now - checked >= 0.5 && now > t0) { + checked = now; + const want = pos - fetchStart; + if (!el.seeking && Math.abs(el.currentTime - want) > 0.25) el.currentTime = want; + } + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); + }, + [cached, fetchStart, playback.rate, ensureDecoded, playElement, stopPlayback, pauseVideo], + ); + + // A speed change mid-playback would leave the picture and the sound at two + // rates: stop, and the next play is at the new one. + useEffect(() => { + stopPlayback(); + }, [playback.rate, stopPlayback]); + + // Let go of the audio output with the bench. + useEffect( + () => () => { + stopPlayback(); + void actx.current?.close().catch(() => {}); + actx.current = null; + }, + [stopPlayback], ); // ---- auto-audition ------------------------------------------------------- @@ -579,20 +1008,40 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }; }, [playback.auto, cached, clip.id, clip.start, clip.end, play]); + // The element's own controls: unbounded "play from here", which stays on the + // element. Its clock moves the playhead only while nothing bounded is + // playing -- during a Web Audio playback the muted picture would otherwise + // fight the audio clock for it. Pausing the picture by hand stops the sound + // it is following. useEffect(() => { const el = video.current; if (!el) return; + // While it plays by itself. A paused element's `timeupdate` is the one its + // own pause fires -- after a Web Audio playback, that is the muted picture + // stopping wherever it had got to, which is not where the sound stopped. const tick = () => { - const t = el.currentTime + fetchStart; - setPlayhead(t); - if (stopAt.current != null && t >= stopAt.current) { - el.pause(); - stopAt.current = null; + if (!session.current && !el.paused) setPlayhead(el.currentTime + fetchStart); + }; + // A scrub on the paused element's own bar. + const seeked = () => { + if (!session.current) setPlayhead(el.currentTime + fetchStart); + }; + const paused = () => { + if (selfPauses.current > 0) { + selfPauses.current -= 1; + return; } + if (session.current) stopPlayback(); }; el.addEventListener("timeupdate", tick); - return () => el.removeEventListener("timeupdate", tick); - }, [fetchStart]); + el.addEventListener("seeked", seeked); + el.addEventListener("pause", paused); + return () => { + el.removeEventListener("timeupdate", tick); + el.removeEventListener("seeked", seeked); + el.removeEventListener("pause", paused); + }; + }, [fetchStart, stopPlayback, cachedName]); // ---- the selection ------------------------------------------------------ // @@ -655,6 +1104,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { setSel({ from: next.start, to: next.end }); setDirty(false); } + // The mark goes back to what was STORED -- rounded, or cleared because + // the window no longer held it. + if (patch.muteFrom !== undefined) { + setMute(next.muteFrom); + muteRef.current = next.muteFrom; + } // Re-sync only the fields this save carried, and from what the writer // actually stored -- which is trimmed, rounded, or gone. setDraft((d) => { @@ -745,13 +1200,13 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // longer contains, so the rule lives here rather than in each of them. const windowMoved = Math.abs(round2(sel.from) - clip.start) > 0.02 || Math.abs(round2(sel.to) - clip.end) > 0.02; + // The mark moved, set or cleared since the last save. + const muteMoved = + (mute == null) !== (clip.muteFrom == null) || + (mute != null && clip.muteFrom != null && Math.abs(round2(mute) - clip.muteFrom) > 0.005); + const unsaved = dirty || muteMoved; - const windowPatch = useCallback((): { - start: number; - end: number; - cutStart?: string; - cutEnd?: string; - } => { + const windowPatch = useCallback((): WindowPatch => { const start = round2(sel.from); const end = round2(sel.to); const cutOutside = @@ -761,8 +1216,16 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // The extent is the judgement being made right now; the cut was derived // from a wider one and is no longer inside it. Clearing it in the SAME // patch is what keeps the writer's rule and the screen agreeing. - return cutOutside ? { start, end, cutStart: "", cutEnd: "" } : { start, end }; - }, [sel.from, sel.to, clip.cutStart, clip.cutEnd]); + const out: WindowPatch = cutOutside + ? { start, end, cutStart: "", cutEnd: "" } + : { start, end }; + // The mute mark rides the same patch, by the same rule: a mark the new + // extent does not hold is cleared rather than refused. + const markOutside = mute != null && (mute < start - 0.02 || mute > end + 0.02); + if (markOutside) out.muteFrom = ""; + else if (muteMoved) out.muteFrom = mute == null ? "" : round2(mute); + return out; + }, [sel.from, sel.to, clip.cutStart, clip.cutEnd, mute, muteMoved]); // ---- the walk's verdict --------------------------------------------------- // @@ -776,7 +1239,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // is a judgement about THAT window, and the advance would otherwise walk // away from it -- so the edges go in the SAME patch as the verdict rather // than needing `save window` pressed first. One write, one token. - const win = windowMoved ? windowPatch() : null; + const win = windowMoved || muteMoved ? windowPatch() : null; // A note survives a confirmation. It stops being a complaint and becomes // what it now says it is: why this clip is here in the shape it is in. const ok = await save({ verdict: "confirmed", ...(win ?? {}) }); @@ -788,7 +1251,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { : "saved — the window you moved was saved with it", ); if (ok && data.next) router.push(`/browse/${data.project}/clip/${data.next}`); - }, [save, router, data.project, data.next, windowMoved, windowPatch]); + }, [save, router, data.project, data.next, windowMoved, muteMoved, windowPatch]); const rejectClip = useCallback(() => { setNeedNote(true); @@ -807,6 +1270,76 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { el.setSelectionRange(el.value.length, el.value.length); }, [clip, save]); + // ---- the mute mark --------------------------------------------------------- + // + // A finale's last clip plays its picture to the end, but its ending sound is + // not wanted: `muteFrom` fades the sound out from a source second to the end + // of the clip. It is set BY EAR, at the last silence before that sound, so it + // is placed the ways an edge is -- at the playhead, by a click on the + // waveform, by a nudge -- and every playback here mutes at it, so a mark is + // heard before it is saved. + + /** Re-aim the playback in flight at a new mark, from now. */ + const retargetMute = useCallback((t: number | null) => { + const s = session.current; + const ctx = actx.current; + if (!s || s.engine !== "webaudio" || !s.gain || !ctx) return; + const now = ctx.currentTime; + const g = s.gain.gain; + g.cancelScheduledValues(now); + const pos = playheadAt(s.from, s.t0, now, s.rate, s.to - s.from); + const m = t == null ? null : muteRamp(t, pos, s.to, s.rate); + if (!m) { + g.setValueAtTime(1, now); + return; + } + if (m.at <= 0) { + // Already past the mark: fade out from here. + g.setValueAtTime(g.value, now); + g.linearRampToValueAtTime(0, now + MUTE_FADE / s.rate); + return; + } + g.setValueAtTime(1, now); + g.setValueAtTime(1, now + m.at); + g.linearRampToValueAtTime(0, now + m.at + m.fade); + }, []); + + /** + * Put the mark at `t`, inside the selection. `audition` plays across it -- + * three seconds of sound and two of what should now be silence -- the way a + * moved edge plays the edge it moved. + */ + const placeMute = useCallback( + (t: number, audition: boolean) => { + const at = round2(Math.min(sel.to, Math.max(sel.from, t))); + setMute(at); + muteRef.current = at; + setMutePick(false); + if (audition) void play(Math.max(sel.from, at - 3), Math.min(sel.to, at + 2)); + else retargetMute(at); + }, + [sel.from, sel.to, play, retargetMute], + ); + + const clearMute = useCallback(() => { + setMute(null); + muteRef.current = null; + setMutePick(false); + retargetMute(null); + }, [retargetMute]); + + /** `m`: where you are listening. Anywhere outside the selection is refused. */ + const muteAtPlayhead = useCallback(() => { + // Through a ref: the playhead moves every frame while something plays, + // and a callback keyed on it would re-bind the keyboard every frame. + const at = playheadRef.current; + if (at == null || at < sel.from - 0.02 || at > sel.to + 0.02) { + setNote("the playhead is not inside the selection — play to the spot, or pick it on the waveform"); + return; + } + placeMute(at, false); + }, [sel.from, sel.to, placeMute]); + // ---- keyboard ----------------------------------------------------------- useEffect(() => { const nudge = (which: "from" | "to", by: number) => @@ -841,12 +1374,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case ">": nudge("to", step); break; case " ": e.preventDefault(); - play(sel.from, sel.to); + void play(sel.from, sel.to); break; case "r": case "R": setSel({ from: clip.start, to: clip.end }); setDirty(false); + setMute(clip.muteFrom); + muteRef.current = clip.muteFrom; break; // Walking the cut. Reviewing a whole video is nineteen clips in a row, // and going back to the project page between each one is nineteen round @@ -882,6 +1417,28 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { case "A": choosePlayback((pb) => ({ ...pb, auto: !pb.auto })); break; + // The mute mark: `m` at the playhead, `M` clears it, and `;` `'` move + // it like an edge (shift for 0.5 s), each playing across it. + case "m": + muteAtPlayhead(); + break; + case "M": + clearMute(); + break; + case ";": + case ":": + case "'": + case '"': + if (mute == null) { + setNote("no mute mark yet — m sets one at the playhead"); + break; + } + placeMute(mute + (e.key === ";" || e.key === ":" ? -step : step), true); + break; + case "Escape": + if (!mutePick) return; + setMutePick(false); + break; default: return; } @@ -893,6 +1450,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { sel, clip.start, clip.end, + clip.muteFrom, onSel, play, router, @@ -902,6 +1460,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { confirmClip, rejectClip, choosePlayback, + mute, + mutePick, + muteAtPlayhead, + clearMute, + placeMute, ]); const refresh = useCallback(async (): Promise<ClipBenchData | null> => { @@ -1129,10 +1692,14 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const saveWindow = useCallback(() => { const patch = windowPatch(); void save(patch).then((ok) => { - if (ok && patch.cutStart !== undefined) - setNote("saved — the cut no longer fitted this window and was cleared"); + if (!ok) return; + const cleared = [ + patch.cutStart !== undefined ? "the cut" : null, + patch.muteFrom === "" && mute != null ? "the mute mark" : null, + ].filter(Boolean); + if (cleared.length) setNote(`saved — ${cleared.join(" and ")} no longer fitted this window and ${cleared.length > 1 ? "were" : "was"} cleared`); }); - }, [windowPatch, save]); + }, [windowPatch, save, mute]); // ---- the warnings -------------------------------------------------------- const endCue = cues.find((c) => sel.to >= c.start - 0.02 && sel.to <= c.end + 0.02) ?? null; @@ -1255,10 +1822,12 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { const osOver = osShown.title.length > osMax; // The strip follows the player when the player is inside what the cut // plays, mapped onto the cut's clock; anywhere else it holds mid-segment. + // A clip that carries posts is held on its last frame in the cut (`hold`, + // part of its duration): the source plays only the rest. const playFrom = clip.cutStart ?? clip.start; const stripT = !deckSeg ? 0 - : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration + : playhead != null && playhead >= playFrom - 0.05 && playhead <= playFrom + deckSeg.duration - (deckSeg.hold ?? 0) ? deckSeg.start + Math.max(0, playhead - playFrom) : midOf(deckSeg); const overlayT = deckSeg ? deckSeg.start + Math.min(segT ?? deckSeg.duration / 4, deckSeg.duration) : 0; @@ -1371,29 +1940,38 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { unsaved — was {hms(clip.start)} – {hms(clip.end)} </span> )} + {muteMoved && ( + <span data-mute-unsaved="" className="num text-[var(--color-dirty)]"> + mute mark unsaved — was {clip.muteFrom == null ? "none" : hms(clip.muteFrom)} + </span> + )} <span className="flex flex-wrap items-center gap-1.5"> <button type="button" + data-save-window="" className={buttonVariants({ variant: "primary", size: "sm" })} - disabled={!dirty || !!busy} + disabled={!unsaved || !!busy} onClick={saveWindow} > save window </button> <button type="button" + data-play-selection="" className={buttonVariants({ size: "sm" })} - onClick={() => play(sel.from, sel.to)} + onClick={() => void play(sel.from, sel.to)} > play selection </button> <button type="button" className={buttonVariants({ size: "sm" })} - disabled={!dirty} + disabled={!unsaved} onClick={() => { setSel({ from: clip.start, to: clip.end }); setDirty(false); + setMute(clip.muteFrom); + muteRef.current = clip.muteFrom; }} > reset @@ -1444,7 +2022,41 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { > auto-audition {playback.auto ? "on" : "off"} </button> + {/* What is playing, and what it measured. The attributes are the + bench's record of its last playback, on the audio clock -- + the claim is "stops where the selection ends", and this is + where it can be checked. */} + <span + data-playback="" + data-audio-state={audio.state} + data-play-engine={playInfo?.engine ?? ""} + data-play-state={playInfo?.state ?? ""} + data-play-from={playInfo?.from ?? ""} + data-play-to={playInfo?.to ?? ""} + data-play-rate={playInfo?.rate ?? ""} + data-play-ctx-start={playInfo?.ctxStart ?? ""} + data-play-ctx-stop={playInfo?.ctxStop ?? ""} + data-play-ended-at={playInfo?.endedAt ?? ""} + className="micro" + title={ + audio.state === "failed" + ? undefined + : "every bounded playback is the decoded audio, started and stopped on the audio clock: it ends where the selection ends, to the sample" + } + > + {audio.state === "ready" + ? "exact playback" + : audio.state === "loading" + ? "decoding the audio…" + : null} + </span> </span> + {audio.state === "failed" && ( + <span data-playback-fallback="" className="text-[11px] text-[var(--color-dirty)]"> + exact playback unavailable — {audio.why}. The video plays instead, stopped by the + frame: up to half a frame either side of the end. + </span> + )} {/* ---- EXTENT above, CUT here ---- The window row says how much of the recording is worth having. This says what will actually play, and offers to derive it @@ -1491,10 +2103,60 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </button> )} </span> + {/* ---- the MUTE MARK ---- + Beside the cut because it is the same kind of decision -- what + the finished clip does with its seconds -- and drafted like the + edges: heard at once, written by `save window` or `y`. */} + <span + data-mute={mute ?? ""} + className="flex flex-wrap items-center gap-1.5" + title="From the mark to the end of the clip the sound fades out and the picture plays on. Set it at the last silence before the ending sound." + > + {mute != null ? ( + <span data-mute-from={mute} className="num text-[var(--color-text)]"> + mute from {hms(mute)}{" "} + <span className="text-[var(--color-dim)]"> + ({Math.max(0, sel.to - mute).toFixed(2)}s silent to the end) + </span> + </span> + ) : ( + <span className="text-[var(--color-dim)]">sound to the end</span> + )} + <button + type="button" + data-mute-set="" + className={buttonVariants({ size: "sm" })} + title="put the mute mark at the playhead" + onClick={muteAtPlayhead} + > + mute from here + </button> + <button + type="button" + data-mute-pick={mutePick ? "armed" : ""} + aria-pressed={mutePick} + className={buttonVariants({ variant: mutePick ? "primary" : "outline", size: "sm" })} + title="the next click on the waveform places the mute mark (Esc cancels)" + onClick={() => setMutePick((v) => !v)} + > + {mutePick ? "click the waveform…" : "pick on waveform"} + </button> + {mute != null && ( + <button + type="button" + data-mute-clear="" + className={buttonVariants({ size: "sm" })} + onClick={clearMute} + > + clear mute + </button> + )} + </span> <span className="micro"> <kbd>[</kbd> <kbd>]</kbd> start · <kbd>,</kbd> <kbd>.</kbd> end — each plays the edge it moved · <kbd>space</kbd> the whole selection · <kbd>R</kbd> reset · <kbd>-</kbd>{" "} - <kbd>=</kbd> speed · <kbd>a</kbd> auto — shift for 0.5s + <kbd>=</kbd> speed · <kbd>a</kbd> auto · <kbd>m</kbd> mute from the playhead,{" "} + <kbd>;</kbd> <kbd>&apos;</kbd> move it, <kbd>M</kbd> clear — shift for 0.5s </span> {busy && <span className="text-[var(--color-meter)]">{busy}</span>} {note && ( @@ -1505,19 +2167,58 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { </div> {/* ---- the instrument ---- */} - <Waveform - view={view} - sel={sel} - cand={{ from: clip.start, to: clip.end }} - words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} - peaks={peaks} - playhead={playhead} - height={104} - onSel={onSel} - onReachEdge={() => { - /* widening is a FETCH here, not a redraw -- see the button below */ - }} - /> + <div className="relative"> + <Waveform + view={view} + sel={sel} + cand={{ from: clip.start, to: clip.end }} + words={cues.map((c) => ({ start: c.start, end: c.end, w: c.text.slice(0, 24) }))} + peaks={peaks} + playhead={playhead} + height={104} + onSel={onSel} + onReachEdge={() => { + /* widening is a FETCH here, not a redraw -- see the button below */ + }} + /> + {/* The mute mark over the waveform: a line where the sound stops + and a hatch over what is silent to the end of the selection. + In the interaction colour, like the handles -- it is an edit, + not a reading -- and never in the way of a drag. */} + {mute != null && mute >= view.from && mute <= view.to && ( + <> + <div + data-mute-span="" + className="pointer-events-none absolute top-0 h-full [background:repeating-linear-gradient(135deg,color-mix(in_srgb,var(--color-dim)_22%,transparent)_0_3px,transparent_3px_7px)]" + style={{ + left: pct(mute), + width: `calc(${pct(Math.max(mute, sel.to))} - ${pct(mute)})`, + }} + /> + <div + data-mute-marker={mute} + className="pointer-events-none absolute top-0 h-full border-l-2 border-dashed border-[var(--color-sel)]" + style={{ left: pct(mute) }} + > + <span className="absolute left-1 top-0.5 rounded bg-[var(--color-panel-2)] px-1 font-mono text-[9px] leading-tight text-[var(--color-sel)]"> + mute + </span> + </div> + </> + )} + {/* Armed: the next click places the mark rather than an edge. */} + {mutePick && ( + <div + data-mute-pick-layer="" + className="absolute inset-0 cursor-crosshair rounded-md outline outline-1 outline-[var(--color-sel)]" + onPointerDown={(e) => { + const r = e.currentTarget.getBoundingClientRect(); + const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / Math.max(1, r.width))); + placeMute(view.from + frac * span, true); + }} + /> + )} + </div> {/* Offered at the edge of the cache, as always -- and also whenever the rail has something to read past it. Having just read the next @@ -1765,7 +2466,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { if (!atMaxPad) void fetchMore(padsForCue(c)); return; } - play(c.start, Math.min(c.end + 4, view.to)); + void play(c.start, Math.min(c.end + 4, view.to)); }} className={`flex w-full gap-2 px-2 py-1 text-left hover:bg-[color-mix(in_srgb,var(--color-sel)_10%,transparent)] ${ inSel ? "bg-[color-mix(in_srgb,var(--color-sel)_14%,transparent)]" : "" diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -88,6 +88,11 @@ export default async function ClipBenchPage({ cutStart: entry.cutStart ?? null, cutEnd: entry.cutEnd ?? null, lockCut: !!entry.lockCut, + // The mute mark, from the manifest's own entry: readClipDetail's rows + // carry the fields it reads, and this is not one of them. + muteFrom: + ((manifest.timeline ?? []) as { id: string; muteFrom?: number }[]).find((e) => e.id === clipId) + ?.muteFrom ?? null, verdict: entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null, // From the manifest's own entry: what the on-screen panel says over this diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; +import { backdropTransform, footageAt } from "@/lib/report/footage-move.mjs"; // --------------------------------------------------------------------------- // ON-SCREEN: the persistent panel under the footage of a report cut. @@ -51,7 +52,11 @@ export type DeckSegment = { subtitle: string; qrUrl: string | null; hideDeck: boolean; + /** Seconds this segment is held on its last frame for its posts (part of `duration`); absent when none. */ + hold?: number; }; +/** The footage moving aside for a clip's posts (`posts.shift`), as the schedule says. */ +export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect }; export type DeckSchedule = { estimated?: boolean; fps: number; @@ -59,6 +64,7 @@ export type DeckSchedule = { total: number; multiChannel: boolean; segments: DeckSegment[]; + moves?: FootageMove[]; }; export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] }; /** One posts window's preview composition: `src` when it composed, `error` when it did not. */ @@ -67,6 +73,8 @@ export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; + /** The palette's background: the ground a moved footage leaves showing. */ + background?: string | null; schedule: DeckSchedule & { posts?: PostSlot[] }; /** The posts region: where it sits in the frame, and one composition per window. */ posts?: { geometry: Rect; windows: PostsWindow[] }; @@ -383,14 +391,26 @@ type DeckSettings = { qr: { show: boolean; size: number }; overCards: string; motion: { out: number; in: number; pip: number }; - posts: { show: boolean; seconds: number; position: string; width: number; qrSize: number; maxLines: number; inset: number }; + posts: { + show: boolean; + seconds: number; + hold: number; + position: string; + width: number; + qrSize: number; + maxLines: number; + inset: number; + shift: false | { scale: number; seconds: number }; + }; }; -type Field = +/** `when`: the bool field that must be on for this one to apply (and be shown). */ +type Field = { when?: string } & ( | { key: string; label: string; kind: "int" | "num"; step?: number; hint: string } | { key: string; label: string; kind: "select"; options: string[]; hint: string } | { key: string; label: string; kind: "bool"; hint: string } - | { key: string; label: string; kind: "parts"; hint: string }; + | { key: string; label: string; kind: "parts"; hint: string } +); /** Grouped as the form shows them. The hints are deck.mjs's ranges. */ const GROUPS: { name: string; fields: Field[] }[] = [ @@ -437,11 +457,15 @@ const GROUPS: { name: string; fields: Field[] }[] = [ fields: [ { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" }, { key: "posts.seconds", label: "seconds", kind: "num", step: 0.5, hint: "s, 0.5–10: each post alone before the next stacks on" }, - { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the corner of the footage the column hangs from" }, - { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900, inside the footage" }, + { key: "posts.hold", label: "hold", kind: "num", step: 0.5, hint: "s, 0–10: the clip that carries posts is held on its last frame, silent, so the last post can be read; part of the cut's length" }, + { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the side the column hangs from: the frame's edge when making room, else the footage's" }, + { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900" }, { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" }, { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" }, - { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the footage's edges" }, + { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the edges" }, + { key: "posts.shift", label: "make room", kind: "bool", hint: "the footage moves aside while a clip's posts are up; off leaves it in its box under the column" }, + { key: "posts.shift.scale", label: "scale", kind: "num", step: 0.01, when: "posts.shift", hint: "0.5–1: the footage's size while it is aside" }, + { key: "posts.shift.seconds", label: "move", kind: "num", step: 0.1, when: "posts.shift", hint: "s, 0–3: the move aside" }, ], }, { @@ -460,26 +484,57 @@ type Form = Record<string, string | boolean>; const getPath = (o: unknown, key: string): unknown => key.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o); -const formOf = (deck: DeckSettings): Form => +/** + * The form for a deck. A field under a switch that is off (`posts.shift: + * false` has no scale) shows the default, so turning the switch on starts + * from it. + */ +const formOf = (deck: DeckSettings, defaults: DeckSettings): Form => Object.fromEntries( FIELDS.map((f) => { - const v = getPath(deck, f.key); + const own = getPath(deck, f.key); + const v = f.kind === "bool" ? own : own ?? getPath(defaults, f.key); if (f.kind === "bool") return [f.key, !!v]; if (f.kind === "parts") return [f.key, Array.isArray(v) ? v.join(", ") : String(v ?? "auto")]; return [f.key, v == null ? "" : String(v)]; }), ); +/** Set `o.a.b.c` from "a.b.c", making the objects on the way. */ +const setPath = (o: Record<string, unknown>, key: string, v: unknown) => { + const ks = key.split("."); + let at = o; + for (const k of ks.slice(0, -1)) { + const next = at[k]; + if (!next || typeof next !== "object") at[k] = {}; + at = at[k] as Record<string, unknown>; + } + at[ks[ks.length - 1]] = v; +}; + +/** Fields that are a switch over an object setting: on is the object (its fields), off is `false`. */ +const SWITCHES = new Set(FIELDS.filter((f) => FIELDS.some((g) => g.when === f.key)).map((f) => f.key)); + /** * The form, back into a `render.chrome.deck` block: ONLY what differs from * the defaults, so a manifest says what somebody chose and a default that * moves later still reaches it. Anything that does not parse is sent as typed * -- the validator's sentence is a better answer than a silent clamp here. + * + * A switch over an object setting (`posts.shift`) is written as `false` when + * off -- never dropped, since absent means the default, which is on -- and as + * the fields under it that differ when on; the fields under a switch that is + * off are not written at all. */ const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => { const out: Record<string, unknown> = {}; for (const f of FIELDS) { const raw = form[f.key]; + if (SWITCHES.has(f.key)) { + if (!raw) setPath(out, f.key, false); + continue; + } + if (f.when && !form[f.when]) continue; let v: unknown; if (f.kind === "bool") v = !!raw; else if (f.kind === "parts") { @@ -492,9 +547,7 @@ const deckOf = (form: Form, defaults: DeckSettings): Record<string, unknown> => v = Number.isFinite(Number(s)) ? Number(s) : s; } if (JSON.stringify(v) === JSON.stringify(getPath(defaults, f.key))) continue; - const [a, b] = f.key.split("."); - if (b) out[a] = { ...((out[a] as Record<string, unknown>) ?? {}), [b]: v }; - else out[a] = v; + setPath(out, f.key, v); } return out; }; @@ -643,7 +696,7 @@ export default function OnscreenSection({ } setDoc(j); token.current = j.token; - setForm(formOf(j.deck ?? j.defaults)); + setForm(formOf(j.deck ?? j.defaults, j.defaults)); setFormDirty(false); setErrors(j.errors ?? []); return j; @@ -998,6 +1051,24 @@ export default function OnscreenSection({ const current = schedule ? segmentAt(schedule, t) : null; const postsWin = preview?.posts ? postsWindowAt(preview.posts.windows, t) : null; const backdropId = current && segmentOf.has(current.id) ? current.id : null; + // The footage moving aside for the posts (`posts.shift`): the backdrop -- + // the built segment or the neutral frame, both a whole frame with the + // footage in its box -- is clipped to that box and carried to where the + // build puts it at this moment, on the build's curve. + const move = schedule ? footageAt(schedule, t) : null; + const backdropStyle: React.CSSProperties | undefined = + move && preview + ? (() => { + const { W, H } = preview.geometry; + const f = move.from; + const pc = (v: number, of: number) => `${(v / of) * 100}%`; + return { + transform: backdropTransform(move.from, move.rect, preview.geometry), + transformOrigin: "0 0", + clipPath: `inset(${pc(f.y, H)} ${pc(W - f.x - f.width, W)} ${pc(H - f.y - f.height, H)} ${pc(f.x, W)})`, + }; + })() + : undefined; // The backdrop follows the scrubber: the built segment of whichever clip is // on screen, at the same offset into it. @@ -1404,20 +1475,29 @@ export default function OnscreenSection({ <div className="min-w-0 space-y-2"> {preview ? ( <DeckFrame preview={preview} t={t} texts={texts} testid="onscreen-preview"> - {backdropId ? ( - <video - ref={backdrop} - key={backdropId} - data-testid="onscreen-backdrop" - src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`} - muted - playsInline - preload="auto" - className="absolute inset-0 h-full w-full object-contain" - /> - ) : ( - <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> - )} + {move && <div className="absolute inset-0" style={{ background: preview.background ?? "#000" }} />} + <div + className="absolute inset-0" + data-testid="onscreen-backdrop-frame" + data-move={move ? move.segment : ""} + data-move-progress={move ? String(Math.round(move.progress * 1000) / 1000) : ""} + style={backdropStyle} + > + {backdropId ? ( + <video + ref={backdrop} + key={backdropId} + data-testid="onscreen-backdrop" + src={`/api/report/segment?project=${encodeURIComponent(project)}&clip=${encodeURIComponent(backdropId)}`} + muted + playsInline + preload="auto" + className="absolute inset-0 h-full w-full object-contain" + /> + ) : ( + <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> + )} + </div> {postsWin && preview.posts && ( <PostsOverlay key={`${postsWin.segment}:${postsWin.src ?? "none"}`} @@ -1448,10 +1528,11 @@ export default function OnscreenSection({ <button key={s.id} type="button" - title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}`} + title={`${s.id}${s.hideDeck ? " · panel hidden" : ""}${s.hold ? ` · held ${s.hold} s for its posts` : ""}`} data-seg-jump={s.id} + data-hold={s.hold ?? 0} onClick={() => setT(midOf(s))} - className={`h-full border-r border-[var(--color-ink)] ${ + className={`relative h-full border-r border-[var(--color-ink)] ${ current?.id === s.id ? "bg-[var(--color-sel)]" : s.hideDeck @@ -1461,7 +1542,22 @@ export default function OnscreenSection({ : "bg-[var(--color-line)]" }`} style={{ width: `${(s.duration / schedule.total) * 100}%` }} - /> + > + {s.hold ? ( + // The held tail: the clip's last frame, frozen for its posts. + <span + aria-hidden + data-testid="onscreen-segment-hold" + data-seg-hold={s.id} + className="pointer-events-none absolute inset-y-0 right-0" + style={{ + width: `${(s.hold / s.duration) * 100}%`, + backgroundImage: + "repeating-linear-gradient(135deg, rgba(0,0,0,0.55) 0 2px, rgba(255,255,255,0.18) 2px 4px)", + }} + /> + ) : null} + </button> ))} </div> {(preview?.posts?.windows.length ?? 0) > 0 && ( @@ -1649,7 +1745,7 @@ export default function OnscreenSection({ className={buttonVariants({ size: "sm" })} disabled={!!busy} onClick={() => { - setForm(formOf(doc.defaults)); + setForm(formOf(doc.defaults, doc.defaults)); setFormDirty(true); }} title="Fill the form with the defaults; nothing is written until you save" @@ -1662,8 +1758,10 @@ export default function OnscreenSection({ {GROUPS.map((group) => ( <div key={group.name} className="contents"> <span className="micro pt-1">{group.name}</span> - <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1" data-setting-group={group.name}> {group.fields.map((f) => { + // Under a switch that is off: nothing to set, so nothing shown. + if (f.when && !form[f.when]) return null; const v = form[f.key]; const set = (nv: string | boolean) => { setForm((prev) => ({ ...prev, [f.key]: nv })); diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md @@ -25,7 +25,9 @@ decisions inbox cannot disagree about what is wrong with one. | `build <project> [--preset preview\|fast\|final] [--only ID]` | **prints** the chain | | `index [--rebuild] [--prune] [--since MS] [--json]` | the cache | | `new <slug> [--from <report.md>\|<share URL>\|<channel>/<id>] [--site-origin URL] [--seed chapters]` | scaffold | -| `doctor [--json]` | which tools are on this machine; **exit 1** if the report pipeline is missing one | +| `doctor [--json]` | which tools are on this machine and where the roots are; **exit 1** if the report pipeline is missing one, or `UMTOOL_MEDIA_DIR` is set and not there | +| `storage [<project>] [--json]` | where each project's `out/` lives: dir, link, DANGLING, none | +| `storage move-out\|move-back <project>\|--all [--dry-run] [--json]` | `out/` to the media root (a link left behind) and back — copied, mirrored, verified first; run when nothing is building | | `snapshot <project> [--label L]` | copy the manifest into `revisions/` | | `diff <project> <snapshot>` | added / removed / moved / window / retyped, by entry id | | `export <project> --format toc-bbcode\|toc-markdown\|description\|chapters [--variant V]` | the posting artifacts, from the build's chapter offsets | @@ -36,7 +38,8 @@ projects answering to one name is reported, never resolved by picking one. ## Environment -`REPORTS_DIR`, `SONG_REPORTS_DIR`, `SONG_DIR`, `CHANNELS_DIR`, `UMTOOL_INDEX_DIR` +`REPORTS_DIR`, `SONG_REPORTS_DIR`, `SONG_DIR`, `CHANNELS_DIR`, `UMTOOL_INDEX_DIR`, +`UMTOOL_CACHE_DIR`, `UMTOOL_MEDIA_DIR` — which is how it is tested against the e2e fixture. The path defaults (`lib/paths.mjs`, `song/paths.mjs`): @@ -45,6 +48,8 @@ projects answering to one name is reported, never resolved by picking one. | `SONG_DIR` | `~/.local/share/archilyzer/song`, through its realpath — a symlink there is the supported way to keep the data where it is | | `SONG_REPORTS_DIR` | `~/reports/quartering-uh-song` | | `REPORTS_DIR` | `~/reports` (the parent of `SONG_REPORTS_DIR` when that is set) | +| `UMTOOL_MEDIA_DIR` | unset = `REPORTS_DIR`: `out/` stays in each project. Set, each project's `out` is a link to the same path under it ([folders.md](folders.md)) | +| `UMTOOL_CACHE_DIR` | `$XDG_CACHE_HOME/archilyzer/umtool`, else `~/.cache/archilyzer/umtool` (it was `<SONG_DIR>/.cache/umtool`) | | `CHANNELS_DIR` | `$TRANSCRIPTS_DIR/channels`, else the checkout's `transcripts/channels` (found by walking up from the cwd to `pnpm-workspace.yaml`) | | `VIDEO_ROOT` (`song/spec.mjs`, `song/video-dir.mjs`) | `~/reports/quartering-uh-song/videos` | diff --git a/umtool/docs/folders.md b/umtool/docs/folders.md @@ -9,6 +9,42 @@ environment variable. `SONG_REPORTS` (the um-song deliverables) keeps its exact previous default and is now a *subdirectory* of `REPORTS_ROOT` rather than the widest root there is. +## `MEDIA_ROOT` + +Where a project's render scratch lives (release 17). `UMTOOL_MEDIA_DIR`, else +`REPORTS_ROOT` — and then nothing is different: `out/` is a directory in the +project. Set, a project's `out` is an absolute link to the same project-relative +path under it (`<REPORTS_ROOT>/a/b/out -> <MEDIA_ROOT>/a/b/out`), made by the +first writer (`lib/report/storage.mjs` `ensureOutDir`) or by +`umtool storage move-out`. The manifest, `revisions/`, notes and sources stay put. + +- It must already exist, outside `REPORTS_ROOT`: umtool never creates it, so an + unmounted drive is a loud refusal ("is the media drive mounted?"), never a new + tree on the main disk. A dangling `out` link refuses the same way. +- Make it a directory **inside** the drive (`<mount>/umtool`), never the + mountpoint itself: a mountpoint that stays behind as an empty directory when + the drive is unmounted passes the check, and the first build of a new project + would make its tree on the main disk. +- A cut move leaves `out.moved-<stamp>` or `out.incoming` beside the project's + `out`. While one exists, no writer makes a new `out/` and both moves refuse, + naming it: run the move it names again to finish it. When a real `out/` exists + beside the leftover too (a writer made a fresh one after the cut), the moves + refuse and say so: the leftover holds the moved data — keep one, remove the + other by hand, then run the move. +- It is a READ root (a realpath through the link lands under it), never a write root. +- The walk skips `out`, `clips` and `share-*`, so it never stats a link into a + drive that is not there. +- `umtool storage` lists every project's `out` (dir, link, DANGLING, none); + `umtool doctor` names the roots and exits 1 when this one is set and missing. + +## `CACHE_DIR` + +`UMTOOL_CACHE_DIR`, else `$XDG_CACHE_HOME/archilyzer/umtool`, else +`~/.cache/archilyzer/umtool`: the project index, posters, the mix bench's +analyses, sliced audio. Derived, safe to delete. Until release 17 it was +`<SONG_DIR>/.cache/umtool`; `umtool doctor` reports a leftover one, and the first +`umtool index` rebuilds the index in the new place. + ## The walk Two rules do almost all the work. diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md @@ -54,6 +54,18 @@ find and silently does nothing. The clip bench says so explicitly rather than leaving you to wonder why "extend to sentence end" is inert; set the edge by ear and lock it. +**`xfade` places a segment by its picture's length, `acrossfade` by its sound's — +and they are not the same length.** An encoded segment's audio routinely runs a +few to ~20 ms shorter or longer than its video (AAC frames, priming, the encoder's +own rounding). Chained, the two filters each add their own lengths, so the sound +drifts away from the picture clip by clip and nothing errors: 0.30 s early by the +end of a 17-clip cut, 2.0 s on one with cards. Every frame and every sample is +there; only the sync is wrong, and it is worst at the end, where nobody spot-checks. +Pin each input's sound to its picture's length before the join +(`apad=whole_dur=<d>,atrim=end=<d>,asetpts=PTS-STARTPTS`, `d` the length the +xfade offsets use); `xfadeGraph` does (6771cfb8), and `av-sync.test.mjs` shows +the unpinned graph drifting so the test is known to discriminate. + ## Manifests **`resolve-windows.mjs` is a fixed point, and that was not free.** The manifest @@ -232,6 +244,79 @@ first and filling it later an error (`gsap_timeline_registered_before_async_buil The renderer awaits `document.fonts.ready` before its first seek, so every frame sees the built timeline. +**`perspective` has no `t`, and its `in` counts from 1.** The footage move +for posts animates one `perspective` filter (`eval=frame`), whose expressions +see only `W`, `H`, `in` and `on`. Measured: the first frame has `in = 1`, and +the count runs on through frames a timeline `enable` passed by. The segment's +own clock is therefore `(in-1)/fps`; written as `in/fps` the move starts a +frame late. An identity map (every corner at its default) copies the frame bit +for bit, so the frames before the move need no `enable` at all. + +**`perspective` fills what a shrink uncovers by CLAMPING to the input's edge.** +It does not paint a colour: the band the footage leaves behind is the input's +outermost row or column, smeared. The deck framing puts `palette.bg` there, +but a coded frame's edge pixels are only approximately that colour, so +`fillborders=…:mode=fixed:color=<bg>` pins the outer 2 px first. Without the +deck framing (footage to the frame's edge) the same filter would smear footage +across the gap. + +**A hold no longer than the crossfade is never seen.** The hold is appended to +the outgoing clip and the dissolve into the next one starts `transition` +seconds before that clip's end, so with `transition: 0.5` a 2.5 s hold shows +2.0 s of still frame and then dissolves out of it; a 0.5 s hold is consumed +entirely. The real-ffmpeg test holds 1 s for this reason. verify-build's +freeze check samples only between the hold's start (or the move's landing) +and the dissolve, and skips a hold with under three frames of still picture +there, saying so. + +**A footage move placed BEFORE the hold never reaches the held frames.** The +move's clock is `perspective`'s `in`, which counts the frames that reach it. +Before `tpad` that is the clip's own frames only: a first post that appears +inside the hold (`posts.seconds: 2` with the 2.5 s hold puts it exactly at +the clip's last frame) never moved the footage, and one in the clip's last +half-second froze part-way, while umtool's preview showed it moving. The +move runs after `tpad`, so its clock counts the clones and the frozen frame +glides too. + +**`tpad` holds whole frames; `apad` holds exact seconds.** `stop_duration=2.5` +at 25 fps clones 63 frames (2.52 s) while `apad=pad_dur=2.5` adds exactly +2.5 s, and the concat filter pads the short stream to the long one, so each +held clip in a hard cut grew by up to half a frame and several of them failed +the length check. `postHolds` rounds a hold to whole frames before either +filter sees it. + +**A coloured `fade` converts the whole hard cut to RGB.** `fade=t=out:…:color=<bg>` +accepts RGB formats only (a fade to black also takes YUV), so ffmpeg inserts a +yuv420p→rgb24 scale before it -- and the concat filter, which needs every +segment in one format, then negotiates EVERY other segment to rgb24 too. The +cut's untouched frames came out different (framemd5) from the same graph +without the fade. The end fade is a `geq` blend toward bg's limited-range +BT.601 Y′CbCr instead (`#12101a` is 31/132/128, what `pad` wrote into the +segments), enabled only from its first frame; `geq` truncates, so each plane +adds 0.5 to round. + +**`afade` out writes digital silence after its fade, and copies every sample +before it.** That makes it the mute for `muteFrom`: samples before the fade are +the clip's own bit for bit (A/V sync cannot move), and samples after it are +zeros, not a quiet signal. A fade that ENDS at the mute point keeps a sound +that starts there out entirely. + +**A label fitted to a length is measured as ink, not as a box.** CSS +`letter-spacing` is added after the LAST letter too, and a box's length +includes each end glyph's side bearing, so sizing the deck's QR host by its +element's length leaves it short of the code by the trailing tracking and the +bearings. `fitHost` measures the string's ink in the loaded face with canvas +`measureText` (`actualBoundingBoxLeft + actualBoundingBoxRight`), adds the +tracking between letters only, scales the font size (everything in it is in em) +and indents the first bearing away. Measured on the ferret cut: ink rows 31–180, +the code's 31–180. + +**`xfade` hands on its own pixel format.** Even the frames before its offset, +which are the first input's, come out as yuv444 rather than the input's +yuv420p, so a framemd5 of the crossfade's output never equals the segment's +own. "Untouched" for a crossfaded input means equal to the graph the build ran +before (the test compares the two graphs), not equal to the file. + **`build-video.mjs` must not import a chrome PAGE module statically.** umtool's server bundles build-video into every report route, and Turbopack turns `chrome-deck.mjs`'s `new URL("./assets/gsap.min.js", import.meta.url)` @@ -245,6 +330,36 @@ not a wall around the page modules: umtool's preview helper posts, and its routes build. A capped umtool build is the gate that catches it — the unit tests run in plain Node and pass either way. +**A teaser's page module is reached by a dynamic import from compose-chrome +too.** `chrome-teaser.mjs` carries the display face as `new URL(…, +import.meta.url)`, the same kind of asset URL as the deck's GSAP. compose-chrome +is imported statically by umtool's preview helper, so it loads the teaser page +only inside the `teaser` region's branch; nothing umtool bundles evaluates that +URL unless a teaser is composed. The face's file name has brackets +(`Archivo[wdth,wght].ttf`), so the copy in the project is +`assets/TeaserDisplay.ttf`, and the page never puts the vendored name in a URL. + +**`random()` in an ffmpeg expression advances only where it is evaluated.** +Its state is a variable that each call updates, and `if()` evaluates one +branch: a noise term gated to a hit's span draws its numbers only inside that +span, so adding or removing one hit changes the noise of every hit after it. +The teaser's noise is a hash of the sample number +(`fract(sin(n·12.9898+78.233)·43758.5453)`), the same whatever else is in the +graph, which is also what lets the onset test difference the graph with and +without one hit. + +**`alimiter` auto-levels and delays by default.** `level` is on by default and +normalises the output upward toward the limit, so a quiet card comes out loud. +`latency` is off by default, which leaves the output late by the lookahead +(the attack). The teaser's limiter is `level=0:latency=1`: measured, every +hit's onset is then on its pop's sample. + +**Sub-bass barely registers in LUFS.** K-weighting rolls off below ~100 Hz, so +a boom at 40 Hz that peaks at −6 dBFS measures far quieter than dialogue at the +same peak. The teaser's hits carry an octave for body and a band-passed noise +punch, and the limiter takes their transients, so the card can sit within +about 2 LU of the cut and still peak at −6 dBFS. + ## Rail strips and rolling counters **A slab that slides moves text that did not change.** The tally used to be four diff --git a/umtool/e2e/clip-bench.spec.ts b/umtool/e2e/clip-bench.spec.ts @@ -57,6 +57,7 @@ const readClipIn = (project: string, id: string) => { verdict?: string; cutStart?: number; cutEnd?: number; + muteFrom?: number; }[]; }; return m.timeline.find((e) => e.id === id)!; @@ -804,13 +805,266 @@ test("moving the end auditions the END", async ({ page }) => { await page.locator("body").press("."); // Clamped to the cached file, which ends at 9.00 -- a drag never downloads. const to = Math.min(before.end + 0.05, 9); - const t = await page - .getByTestId("clip-video") - .evaluate((el: HTMLVideoElement) => el.currentTime); - // vid1_0.00-9.00 starts at 0.00, so file time IS source time here. The four - // seconds ENDING on the new edge, not the four after the start. - expect(t).toBeGreaterThan(to - 4 - 0.4); - expect(t).toBeLessThan(to - 4 + 2); + // The four seconds ENDING on the new edge, not the four after the start -- + // read from the bench's own record of what it scheduled, on the audio clock. + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-play-engine", "webaudio", { timeout: 15_000 }); + expect(Number(await playback.getAttribute("data-play-to"))).toBeCloseTo(to, 2); + expect(Number(await playback.getAttribute("data-play-from"))).toBeCloseTo(to - 4, 2); + // And the picture follows it there, muted: vid1_0.00-9.00 starts at 0.00, + // so file time IS source time. + await expect + .poll(() => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => el.currentTime)) + .toBeGreaterThan(to - 4 - 0.4); +}); + +// --------------------------------------------------------------------------- +// Exact playback. +// +// Every bounded range plays from the DECODED audio with Web Audio, started and +// stopped on the audio clock. The element it replaces was stopped from +// `timeupdate`, which overran the end by up to a quarter of a second -- the +// difference between a cut between two words and one into the next. +// +// The proof is the signal, not the bench's word for it: TAP (below) is an +// AudioWorklet put between the bench's audio and the speakers that records the +// first and last NON-ZERO frame it is handed, on the context's own frame +// clock. The bench publishes when it scheduled the stop (`data-play-ctx-stop`); +// the two must agree to within one render quantum (128 frames). +// +// vid1 is a 440 Hz tone with silences at 2.9-3.1 and 5.9-6.1, so a selection +// ending at 5.00 ends INSIDE the tone: the last sound is the stop, not a +// silence that happened to come first. +// --------------------------------------------------------------------------- + +const TAP = `(() => { + const code = \`class Tap extends AudioWorkletProcessor { + constructor() { super(); this.first = -1; this.last = -1; + this.port.onmessage = (e) => { + if (e.data === "reset") { this.first = -1; this.last = -1; } + if (e.data === "read") this.port.postMessage({ first: this.first, last: this.last, sr: sampleRate }); + }; + } + process(inputs) { + const ch = inputs[0] && inputs[0][0]; + if (ch) for (let i = 0; i < ch.length; i += 1) if (ch[i] !== 0) { + const f = currentFrame + i; if (this.first < 0) this.first = f; this.last = f; + } + return true; + } + } + registerProcessor("tap", Tap);\`; + const url = URL.createObjectURL(new Blob([code], { type: "application/javascript" })); + const Orig = window.AudioContext; + if (!Orig) return; + const conn = AudioNode.prototype.connect; + window.__tapRead = () => new Promise((res) => { + const n = window.__tapNode; + if (!n) return res(null); + n.port.onmessage = (e) => res(e.data); + n.port.postMessage("read"); + }); + window.__tapReset = () => window.__tapNode && window.__tapNode.port.postMessage("reset"); + window.AudioContext = class extends Orig { + constructor(...a) { + super(...a); + const ctx = this; + ctx.audioWorklet.addModule(url).then(() => { + const n = new AudioWorkletNode(ctx, "tap", { outputChannelCount: [1] }); + conn.call(n, ctx.destination); + ctx.__tap = n; + window.__tapNode = n; + for (const src of ctx.__pending || []) conn.call(src, n); + ctx.__pending = []; + }); + } + }; + AudioNode.prototype.connect = function (dest, ...rest) { + const r = conn.call(this, dest, ...rest); + const tap = this.context && this.context.__tap; + if (dest === this.context.destination && this !== tap) { + if (tap) conn.call(this, tap); + else (this.context.__pending = this.context.__pending || []).push(this); + } + return r; + }; +})();`; + +type Tap = { first: number; last: number; sr: number } | null; +const tapRead = (page: import("@playwright/test").Page) => + page.evaluate(() => (window as unknown as { __tapRead: () => Promise<Tap> }).__tapRead()); +const tapReset = (page: import("@playwright/test").Page) => + page.evaluate(() => (window as unknown as { __tapReset: () => void }).__tapReset()); + +/** The bench's record of its last playback, as numbers. */ +const lastPlay = async (page: import("@playwright/test").Page) => { + const p = page.locator("[data-playback]"); + const n = async (k: string) => Number(await p.getAttribute(`data-play-${k}`)); + return { + engine: await p.getAttribute("data-play-engine"), + from: await n("from"), + to: await n("to"), + rate: await n("rate"), + ctxStart: await n("ctx-start"), + ctxStop: await n("ctx-stop"), + }; +}; + +/** Start a playback with `go` and wait until the bench says it has stopped. */ +const playThrough = async (page: import("@playwright/test").Page, go: () => Promise<void>) => { + const p = page.locator("[data-playback]"); + await tapReset(page); + await go(); + await expect(p).toHaveAttribute("data-play-state", "playing", { timeout: 10_000 }); + await expect(p).toHaveAttribute("data-play-state", "stopped", { timeout: 20_000 }); + // `ended` reaches the page a task after the audio thread stopped; the tap's + // reply is one more message behind it. + await page.waitForTimeout(100); + return { play: await lastPlay(page), tap: await tapRead(page) }; +}; + +/** Put c01 back to its fixture window with no mute mark. */ +const resetC01 = async (request: import("@playwright/test").APIRequestContext) => { + const { token: t } = await token(request, "c01"); + const r = await request.put("/api/report/window", { + data: { project: PROJECT, clip: "c01", start: 3, end: 6, muteFrom: "", token: t }, + }); + expect(r.ok()).toBeTruthy(); +}; + +test("a play-selection stops within one audio render quantum of its end, at any speed", async ({ + page, + request, +}) => { + await resetC01(request); + await page.addInitScript(TAP); + await page.goto(bench("c01")); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-audio-state", "ready", { + timeout: 15_000, + }); + await keyboardLive(page); + // 6.00 -> 5.00 (shift-, is half a second): the end now lies in the tone. + // Each press auditions the end it moved, which also opens the audio output + // and puts the tap in the path. + await page.locator("body").press("Shift+Comma"); + await page.locator("body").press("Shift+Comma"); + await expect + .poll(async () => Number(await page.locator("[data-playback]").getAttribute("data-play-to")), { + timeout: 10_000, + }) + .toBeCloseTo(5, 3); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-play-state", "stopped", { + timeout: 10_000, + }); + + for (const rate of ["1", "2"]) { + await page.locator("[data-playback-rate]").selectOption(rate); + const { play, tap } = await playThrough(page, () => page.locator("[data-play-selection]").click()); + expect(play.engine).toBe("webaudio"); + expect(play.from).toBeCloseTo(3, 3); + expect(play.to).toBeCloseTo(5, 3); + expect(play.rate).toBe(Number(rate)); + expect(tap, "the tap saw the bench's audio").not.toBeNull(); + const { first, last, sr } = tap!; + expect(last).toBeGreaterThan(first); + const quantum = 128; + // The STOP: the last sound is the scheduled stop, to within a quantum. + const stopFrame = Math.round(play.ctxStop * sr); + expect(Math.abs(last + 1 - stopFrame), `stopped ${last + 1 - stopFrame} frames from the end at ${rate}x`).toBeLessThanOrEqual(quantum); + // And nothing sounded before the scheduled start. (Not "the first sound + // IS the start": 3.00 is inside the silence at 2.9-3.1, so the first + // non-zero frame is the tone coming back.) + expect(first).toBeGreaterThanOrEqual(Math.round(play.ctxStart * sr) - quantum); + } +}); + +test("the mute mark: picked on the waveform, heard at once, saved, shown, cleared", async ({ + page, + request, +}) => { + await resetC01(request); + await page.addInitScript(TAP); + await page.goto(bench("c01")); + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-audio-state", "ready", { timeout: 15_000 }); + await expect(page.locator("[data-mute]")).toHaveAttribute("data-mute", ""); + await expect(page.locator("[data-mute-marker]")).toHaveCount(0); + + // Armed, the next click on the waveform places the mark: at 5.00 of the + // cached 0.00-9.00, the tone between the two silences. + await page.locator("[data-mute-pick]").click(); + await expect(page.locator("[data-mute-pick=armed]")).toBeVisible(); + const layer = page.locator("[data-mute-pick-layer]"); + const box = (await layer.boundingBox())!; + const { play, tap } = await playThrough(page, () => + page.mouse.click(box.x + (box.width * 5) / 9, box.y + box.height / 2), + ); + const marker = page.locator("[data-mute-marker]"); + await expect(marker).toBeVisible(); + const mark = Number(await marker.getAttribute("data-mute-marker")); + expect(mark).toBeGreaterThan(4.9); + expect(mark).toBeLessThan(5.1); + await expect(page.locator("[data-mute-pick-layer]")).toHaveCount(0); + await expect(page.locator("[data-mute-unsaved]")).toContainText("was none"); + + // The pick plays ACROSS the mark -- up to three seconds before it (here + // from the selection's start), to the end of the selection -- and the sound + // goes at the mark: the fade (MUTE_FADE, the build's) ENDS there, so the + // last non-zero frame is at the mark, not the 6.00 the tone runs on to. + expect(play.engine).toBe("webaudio"); + expect(play.from).toBeCloseTo(Math.max(3, mark - 3), 2); + expect(play.to).toBeCloseTo(6, 2); + const { last, sr } = tap!; + const silentAt = play.ctxStart + (mark - play.from); + expect(Math.abs((last + 1) / sr - silentAt), "silent at the mark, not at the end").toBeLessThan(0.01); + + // Saved by `save window`, like the edges; the manifest has it. + await page.locator("[data-save-window]").click(); + await expect(page.locator("[data-bench-note]")).toContainText("saved"); + expect(readClip("c01").muteFrom).toBeCloseTo(mark, 2); + await expect(page.locator("[data-mute-unsaved]")).toHaveCount(0); + + // A reload shows the saved mark. + await page.reload(); + await expect(page.locator("[data-mute-marker]")).toHaveAttribute("data-mute-marker", String(readClip("c01").muteFrom)); + await expect(page.locator("[data-mute-from]")).toBeVisible(); + + // Moved like an edge, and cleared. + await keyboardLive(page); + await page.locator("body").press("Shift+Semicolon"); + await expect(page.locator("[data-mute-marker]")).toHaveAttribute( + "data-mute-marker", + String(Number((readClip("c01").muteFrom! - 0.5).toFixed(2))), + ); + await page.locator("[data-mute-clear]").click(); + await expect(page.locator("[data-mute-marker]")).toHaveCount(0); + await expect(page.locator("[data-mute-unsaved]")).toBeVisible(); + await page.locator("[data-save-window]").click(); + await expect(page.locator("[data-bench-note]")).toContainText("saved"); + expect(readClip("c01").muteFrom).toBeUndefined(); + + // The writer's rule, through the route: inside the clip, or refused. + const { token: t } = await token(request, "c01"); + const out = await request.put("/api/report/window", { + data: { project: PROJECT, clip: "c01", muteFrom: 8, token: t }, + }); + expect(out.status()).toBe(400); + expect(((await out.json()) as { error: string }).error).toMatch(/must lie inside the clip 3–6/); +}); + +test("a decode that fails falls back to the element, and the bench says so", async ({ page }) => { + await page.route("**/api/report/audio**", (r) => + r.fulfill({ status: 422, json: { error: "this file has no audio track" } }), + ); + await page.goto(bench("c01")); + const fallback = page.locator("[data-playback-fallback]"); + await expect(fallback).toBeVisible({ timeout: 15_000 }); + await expect(fallback).toContainText("this file has no audio track"); + await page.locator("[data-play-selection]").click(); + await expect(page.locator("[data-playback]")).toHaveAttribute("data-play-engine", "element"); + await expect + .poll(() => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => !el.paused)) + .toBe(true); }); test("the playback speed is this browser's, and it survives a reload", async ({ page }) => { @@ -841,6 +1095,11 @@ test("auto-audition plays the clip you walk onto", async ({ page, request }) => await page.locator("[data-clip-nav=next]").click(); await expect(page.locator("[data-bench=c04]")).toBeVisible(); + // The whole clip, from the decoded audio -- and the picture with it. + const playback = page.locator("[data-playback]"); + await expect(playback).toHaveAttribute("data-play-engine", "webaudio", { timeout: 15_000 }); + expect(Number(await playback.getAttribute("data-play-from"))).toBeCloseTo(15, 2); + expect(Number(await playback.getAttribute("data-play-to"))).toBeCloseTo(18, 2); await expect .poll( () => page.getByTestId("clip-video").evaluate((el: HTMLVideoElement) => !el.paused), diff --git a/umtool/e2e/dashboard.spec.ts b/umtool/e2e/dashboard.spec.ts @@ -51,7 +51,14 @@ test("umtool doctor reports a deliberately bad path as absent, and exits 1", () stdout = execFileSync("node", ["bin/umtool.mjs", "doctor", "--json"], { cwd: UMTOOL, encoding: "utf8", - env: { ...process.env, YTDLP_BIN: path.join(FIXTURE, "bin", "definitely-not-here"), QRENCODE_BIN: path.join(FIXTURE, "bin", "qrencode") }, + env: { + ...process.env, + YTDLP_BIN: path.join(FIXTURE, "bin", "definitely-not-here"), + QRENCODE_BIN: path.join(FIXTURE, "bin", "qrencode"), + // The fixture's roots, never this shell's media root or cache (empty = unset). + UMTOOL_MEDIA_DIR: "", + UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + }, }); } catch (e) { const err = e as { status: number; stdout: string }; diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1483,13 +1483,19 @@ const deckManifest = (slug, title, timeline) => { // onscreen-fixture: the On-screen section and the bench's fields WRITE here -- // the switch, the settings, the table, a stale token. Never built: its // schedule is the estimate, which is the state a report is in when titles are -// first written. A card, because a row is any entry and not only a clip. +// first written. A card, because a row is any entry and not only a clip; a +// teaser last, because the page and the table must name one by its lines. const ONSCREEN = writeProject( "onscreen-fixture", deckManifest("onscreen-fixture", "The On-screen Fixture", [ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" }, { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" }, { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" }, + { + type: "teaser", id: "t01", seconds: 6, + lines: ["Next Season", { text: "The Big Build in the Valley", break: "in the Valley" }, "Spring 2027"], + tail: "?", + }, ]), ); @@ -1677,6 +1683,34 @@ copyFileSync( path.join(DASH, "out", "clips-raw", "vid1_0.00-9.00.mp4"), ); +// -- the media root (release 17) ------------------------------------------------ +// +// storage.spec.ts moves storage-fixture's out/ to a media root and back, builds +// through the link, and unplugs the root. Its CLI is given UMTOOL_MEDIA_DIR = +// this directory; the APP is not (every other spec's out/ stays a directory). +// A SIBLING of the fixture, not inside it: REPORTS_ROOT is the fixture root, and +// a media root inside the tree it mirrors is refused. Reset here, every run. +// storage-fixture dash-fixture's shape: a cached window, buildable offline +// storage-fresh-fixture no out/ at all: the first writer makes the link +const MEDIA = `${dest}-media`; +rmSync(MEDIA, { recursive: true, force: true }); +mkdirSync(MEDIA, { recursive: true }); +for (const slug of ["storage-fixture", "storage-fresh-fixture"]) { + const dir = writeProject( + slug, + manifest(slug, "The Storage Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because" }, + { type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, lock: true, quote: "another whole sentence" }, + ]), + ); + if (slug !== "storage-fixture") continue; + mkdirSync(path.join(dir, "out", "clips-raw"), { recursive: true }); + copyFileSync( + path.join(REPORT, "out", "clips-raw", "vid1_0.00-9.00.mp4"), + path.join(dir, "out", "clips-raw", "vid1_0.00-9.00.mp4"), + ); +} + console.log(`fixture at ${dest}`); if (planned) console.log(` planned clip (used in a build): ${planned}`); console.log(` videos/: alpha (4 cuts, 3 variants), beta (2 cuts), deck (1 cut, 2 variants)`); @@ -1691,6 +1725,8 @@ console.log(` flagged source: ${flagged ? flagged.video : "none — no asr/"}`) console.log(` SONG_CODE_DIR=${path.join(dest, "code")}`); console.log(` SONG_DIR=${path.join(dest, "data")}`); console.log(` SONG_REPORTS_DIR=${reports}`); +console.log(` UMTOOL_CACHE_DIR=${path.join(dest, "cache")} (removed with the fixture; never ~/.cache)`); +console.log(` storage spec media root: ${MEDIA} (UMTOOL_MEDIA_DIR on its CLI only; reset here)`); console.log(` YTDLP_BIN=${path.join(BIN, "yt-dlp")} QRENCODE_BIN=${path.join(BIN, "qrencode")} HYPERFRAMES_BIN=${path.join(BIN, "hyperframes")}`); console.log(` CHANNELS_DIR=${CHANNELS} (testchan/vid1 punctuated, vid2 not; vid3/vid4/vid5 for the editor fetch)`); console.log(` projects: report-fixture (4 clips, 1 mid-sentence), no-origin-fixture,`); diff --git a/umtool/e2e/onscreen-posts.spec.ts b/umtool/e2e/onscreen-posts.spec.ts @@ -1,8 +1,8 @@ -import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; +import { test, expect, type APIRequestContext, type Locator, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { postWindows } from "umtool-report-to-video/deck"; +import { deckGeometry, postWindows, postsGeometry, shiftedFootage } from "umtool-report-to-video/deck"; // --------------------------------------------------------------------------- // POSTS on the on-screen deck, as umtool edits them: the Posts table under the @@ -12,7 +12,19 @@ import { postWindows } from "umtool-report-to-video/deck"; // One project, onscreen-posts-fixture (make-fixture.mjs): two dated clips and // a card, three posts whose dates put them on c01 ("first"), c01 ("date") and // c02 ("date"). Never built, so every timing is the estimate's. Each test puts -// the posts back to automatic and shown through the route before it starts. +// the posts back to automatic and shown, and the deck back to its defaults, +// through the routes before it starts. +// +// With the defaults (posts.seconds 4, hold 2.5, shift on) and the fixture's +// 0.2 s crossfade, the estimate is: +// c01 0 → 5.533 3 s clip + a 2.5 s hold, which at the fixture's +// 15 fps is 38 frames (2.533 s); two posts in +// 5.333 s share it, p-early at 0, p-mid at 2.667 +// c02 5.333 → 10.867 the same; p-late at 6.667, 4 s before its leave +// at 10.667 +// k01 10.667 → 13.667 the card +// and the footage moves aside as each clip's first post appears (c01 at 0, +// c02 at 6.667), with the posts column at the frame's right edge. // // The posts region's COMPOSITION is compose-chrome's (`region: "posts"`): the // preview test asserts every window composes and its page reports @@ -55,8 +67,54 @@ async function rows(request: APIRequestContext): Promise<Record<string, PostRow> return Object.fromEntries(j.posts.map((p) => [p.id, p])); } -const reset = (request: APIRequestContext) => - putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); +const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} }; + +async function putChrome(request: APIRequestContext, chrome: unknown) { + const r = await request.put("/api/report/chrome", { data: { project: PROJECT, chrome, token: await token(request) } }); + expect(r.ok(), await r.text()).toBeTruthy(); +} + +const reset = async (request: APIRequestContext) => { + await putChrome(request, DECK_ON); + await putPosts(request, Object.fromEntries(IDS.map((id) => [id, { attachTo: null, hide: false }]))); +}; + +type Rect = { x: number; y: number; width: number; height: number }; +type Preview = { + schedule: { + total: number; + segments: { id: string; start: number; duration: number; end: number; hold?: number }[]; + posts: { id: string; segment: string; appear: number; out: [number, number] }[]; + moves?: { segment: string; at: number; seconds: number; from: Rect; to: Rect }[]; + }; + posts: { geometry: Rect; windows: { segment: string; from: number; to: number; src?: string; error?: string }[] }; +}; +const previewOf = async (request: APIRequestContext, extra: Record<string, unknown> = {}) => + (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT, ...extra } })).json()) as Preview; + +/** Put the scrubber at `t` (seconds) and wait for the clock to say so. */ +async function seek(page: Page, t: number) { + await page.getByTestId("onscreen-scrubber").fill(String(t)); + await expect(page.getByTestId("onscreen-scrubber")).toHaveValue(String(t)); +} + +/** Fill a field the page may still be re-rendering from a load: retry until the value holds. */ +async function fillSure(input: Locator, value: string) { + await expect(async () => { + await input.fill(value); + await expect(input).toHaveValue(value, { timeout: 1000 }); + }).toPass({ timeout: 15_000 }); +} + +/** The backdrop's computed transform as [scaleX, scaleY, translateX px, translateY px, frame width px], or null for none. */ +const backdropMatrix = (page: Page) => + page.getByTestId("onscreen-backdrop-frame").evaluate((el) => { + const tr = getComputedStyle(el).transform; + if (!tr || tr === "none") return null; + const m = new DOMMatrixReadOnly(tr); + // offsetWidth: the frame's laid-out width, before the transform scales it. + return [m.a, m.d, m.e, m.f, (el as HTMLElement).offsetWidth]; + }); async function openSection(page: Page) { await page.goto(`/browse/${PROJECT}`); @@ -210,16 +268,34 @@ test("the preview composes the posts region per window and overlays it while the }) => { // The route: one window per clip that carries posts, the deck's own // postWindows over the schedule it returns, at postsGeometry. - const pv = (await (await request.post("/api/report/chrome/preview", { data: { project: PROJECT } })).json()) as { - schedule: Parameters<typeof postWindows>[0] & { total: number }; - posts: { - geometry: { x: number; y: number; width: number; height: number }; - windows: { segment: string; from: number; to: number; src?: string; error?: string }[]; - }; - }; - expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule)); + const pv = await previewOf(request); + expect(pv.posts.windows.map(({ segment, from, to }) => ({ segment, from, to }))).toEqual(postWindows(pv.schedule as Parameters<typeof postWindows>[0])); expect(pv.posts.windows.map((w) => w.segment)).toEqual(["c01", "c02"]); expect(pv.posts.geometry.width).toBe(600); + + // The defaults' timing: 4 s per post, each carrying clip held 2.5 s, every + // start and the total measured with the holds. + expect(pv.schedule.segments.map((s) => [s.id, s.start, s.duration, s.hold ?? 0])).toEqual([ + // The hold is rounded to whole frames: 2.5 s at the fixture's 15 fps is + // 37.5 frames, so 38 -- 2.533 s. + ["c01", 0, 5.533, 2.533], + ["c02", 5.333, 5.533, 2.533], + ["k01", 10.667, 3, 0], + ]); + expect(pv.schedule.total).toBe(13.667); + expect(pv.schedule.posts.map((p) => [p.id, p.segment, p.appear, p.out])).toEqual([ + ["p-early", "c01", 0, [5.333, 5.533]], + ["p-mid", "c01", 2.667, [5.333, 5.533]], + ["p-late", "c02", 6.667, [10.667, 10.867]], + ]); + expect(pv.posts.windows.map(({ from, to }) => [from, to])).toEqual([[0, 5.533], [6.667, 10.867]]); + // The footage makes room: one move per carrying clip, and the column at the frame's edge. + const render = readManifest().render; + expect(pv.schedule.moves?.map((m) => [m.segment, m.at, m.seconds])).toEqual([["c01", 0, 0.6], ["c02", 6.667, 0.6]]); + expect(pv.schedule.moves?.[0].from).toEqual(deckGeometry(render).footage); + expect(pv.schedule.moves?.[0].to).toEqual(shiftedFootage(render)); + expect(pv.posts.geometry).toEqual(postsGeometry(render)); + expect(pv.posts.geometry.x).toBe(1920 - 24 - 600); // Every window composes: compose-chrome draws the posts region. for (const w of pv.posts.windows) { expect(w.error).toBeUndefined(); @@ -235,6 +311,28 @@ test("the preview composes the posts region per window and overlays it while the await openSection(page); await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); await expect(page.locator("[data-posts-window]")).toHaveCount(2); + + // The scrubber runs the held length, and the window marks sit on its clock. + await expect(page.getByTestId("onscreen-scrubber")).toHaveAttribute("max", "13.667"); + await expect(page.getByTestId("onscreen-time")).toContainText("/ 0:13.7"); + for (const [seg, from, to] of [["c01", 0, 5.533], ["c02", 6.667, 10.867]] as const) { + const style = await page.locator(`[data-posts-window="${seg}"]`).evaluate((el) => [ + parseFloat((el as HTMLElement).style.left), + parseFloat((el as HTMLElement).style.width), + ]); + expect(style[0]).toBeCloseTo((from / 13.667) * 100, 2); + expect(style[1]).toBeCloseTo(((to - from) / 13.667) * 100, 2); + } + + // A held segment shows its hold: a hatched tail, hold/duration of its block. + await expect(page.getByTestId("onscreen-segment-hold")).toHaveCount(2); + await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("data-hold", "2.533"); + await expect(page.locator('[data-seg-jump="k01"]')).toHaveAttribute("data-hold", "0"); + await expect(page.locator('[data-seg-jump="c01"]')).toHaveAttribute("title", /held 2\.533 s for its posts/); + const block = (await page.locator('[data-seg-jump="c02"]').boundingBox())!; + const tail = (await page.locator('[data-seg-hold="c02"]').boundingBox())!; + expect(tail.width / block.width).toBeCloseTo(2.533 / 5.533, 1); + expect(Math.abs(tail.x + tail.width - (block.x + block.width))).toBeLessThan(2); // Outside every window: nothing over the footage. k01 starts after the last. await page.locator('[data-seg-jump="k01"]').click(); await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); @@ -255,4 +353,95 @@ test("the preview composes the posts region per window and overlays it while the const W = 1920; expect(Math.abs((box.x - frame.x) / frame.width - pv.posts.geometry.x / W)).toBeLessThan(0.01); expect(Math.abs(box.width / frame.width - pv.posts.geometry.width / W)).toBeLessThan(0.01); + + // The backdrop moves inside the window: the window mark put the scrubber at + // 8.7, past c02's move (6.667 → 7.267), so the footage is all the way aside... + const from = pv.schedule.moves![1].from; + const to = pv.schedule.moves![1].to; + const backdrop = page.getByTestId("onscreen-backdrop-frame"); + await expect(backdrop).toHaveAttribute("data-move", "c02"); + await expect(backdrop).toHaveAttribute("data-move-progress", "1"); + const aside = (await backdropMatrix(page))!; + expect(aside[0]).toBeCloseTo(to.width / from.width, 3); + expect(aside[1]).toBeCloseTo(to.height / from.height, 3); + // ...its box's left edge where the build puts it (in frame pixels)... + expect((aside[2] / aside[4]) * W + aside[0] * from.x).toBeCloseTo(to.x, 0); + // ...half way through the move at its middle, on the smoothstep curve... + // (the move's middle from the schedule itself; the scrubber steps in 0.01 s, + // so the progress lands within a step of 0.5, not on it) + const mid = pv.schedule.moves![1].at + pv.schedule.moves![1].seconds / 2; + await seek(page, Math.round(mid * 100) / 100); + await expect + .poll(async () => Math.abs(Number(await backdrop.getAttribute("data-move-progress")) - 0.5)) + .toBeLessThan(0.02); + const half = (await backdropMatrix(page))!; + expect(half[0]).toBeCloseTo((1 + to.width / from.width) / 2, 2); + // ...in its box before the move... + await seek(page, 6.5); + await expect(page.getByTestId("onscreen-current")).toHaveText("c02"); + await expect(backdrop).toHaveAttribute("data-move-progress", "0"); + expect((await backdropMatrix(page))?.[0] ?? 1).toBeCloseTo(1, 3); + // ...and not moved at all over a segment that carries no posts. + await seek(page, 12); + await expect(page.getByTestId("onscreen-current")).toHaveText("k01"); + await expect(backdrop).toHaveAttribute("data-move", ""); + expect(await backdropMatrix(page)).toBeNull(); +}); + +test("make room off in the settings writes shift: false, the form keeps it, and the column goes back inside the footage", async ({ + page, + request, +}) => { + await openSection(page); + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + const shift = page.getByTestId("onscreen-setting-posts.shift"); + await expect(shift).toBeChecked(); + await expect(page.getByTestId("onscreen-setting-posts.hold")).toHaveValue("2.5"); + await expect(page.getByTestId("onscreen-setting-posts.seconds")).toHaveValue("4"); + await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86"); + await expect(page.getByTestId("onscreen-setting-posts.shift.seconds")).toHaveValue("0.6"); + + await shift.uncheck(); + // Off has no scale or move to set. + await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveCount(0); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { shift: false } }); + + // The schedule has no moves and the column is inside the footage box again. + const render = readManifest().render; + const pv = await previewOf(request); + expect(pv.schedule.moves).toBeUndefined(); + expect(pv.posts.geometry).toEqual(postsGeometry(render)); + const f = deckGeometry(render).footage; + expect(pv.posts.geometry.x).toBe(f.x + f.width - 24 - 600); + + // Saving another setting keeps it: the form never drops `shift: false`. + await page.reload(); + await expect(page.getByTestId("onscreen-setting-posts.shift")).not.toBeChecked(); + await fillSure(page.getByTestId("onscreen-setting-posts.hold"), "1.5"); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ + posts: { hold: 1.5, shift: false }, + }); + + // In the live preview: the overlay inside the footage box, and the backdrop where it always was. + await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); + await page.locator('[data-posts-window="c02"]').click(); + const overlay = page.getByTestId("onscreen-posts-preview"); + await expect(overlay).toHaveAttribute("data-segment", "c02"); + await expect(overlay).toHaveAttribute("data-posts-ready", "1", { timeout: 30_000 }); + const frame = (await page.getByTestId("onscreen-preview").boundingBox())!; + const box = (await overlay.boundingBox())!; + const W = 1920; + expect((box.x - frame.x) / frame.width).toBeGreaterThanOrEqual(f.x / W - 0.005); + expect((box.x + box.width - frame.x) / frame.width).toBeLessThanOrEqual((f.x + f.width) / W + 0.005); + expect(Math.abs((box.x - frame.x) / frame.width - (f.x + f.width - 24 - 600) / W)).toBeLessThan(0.01); + await expect(page.getByTestId("onscreen-backdrop-frame")).toHaveAttribute("data-move", ""); + expect(await backdropMatrix(page)).toBeNull(); + + // On again: the default, so nothing under posts but the hold. + await page.getByTestId("onscreen-setting-posts.shift").check(); + await expect(page.getByTestId("onscreen-setting-posts.shift.scale")).toHaveValue("0.86"); + await page.getByTestId("onscreen-settings-save").click(); + await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ posts: { hold: 1.5 } }); }); diff --git a/umtool/e2e/onscreen.spec.ts b/umtool/e2e/onscreen.spec.ts @@ -268,6 +268,11 @@ test("the preview is the composition: it reports ready, and typing reaches it be const cardAuto = await row(page, "k01").getByTestId("onscreen-title").getAttribute("placeholder"); expect(cardAuto).toBe("A card"); await expect(frame.locator('[data-seg="k01"] .deck-title')).toHaveText(cardAuto!); + // A teaser is a card row named by its lines, here and on the report page's timeline. + const teaserTitle = "Next Season — The Big Build in the Valley — Spring 2027 ?"; + await expect(frame.locator('[data-seg="t01"]')).toHaveCount(1); + expect(await row(page, "t01").getByTestId("onscreen-title").getAttribute("placeholder")).toBe(teaserTitle); + await expect(page.locator('li[data-entry="t01"][data-kind="teaser"]')).toContainText(teaserTitle); // Typing is patched into the frame by postMessage: nothing is saved. await fillSure(row(page, "c02").getByTestId("onscreen-title"), "Typed, not saved"); diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts @@ -318,6 +318,10 @@ const cliEnv = { SONG_REPORTS_DIR: path.join(FIXTURE, "reports"), SONG_DIR: path.join(FIXTURE, "data"), CHANNELS_DIR: path.join(FIXTURE, "channels"), + // The fixture's cache, as playwright.config.ts gives the app (never ~/.cache). + UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + // Never the real media root, whatever this shell exports (empty = unset). + UMTOOL_MEDIA_DIR: "", }; const umtool = (args: string[]) => execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env: cliEnv }); @@ -415,7 +419,7 @@ test("deleting the index changes nothing but latency", async ({ request }) => { // CACHE_DIR is documented as derived output, safe to delete at any time. This // is that promise, tested. - rmSync(path.join(FIXTURE, "data", ".cache", "umtool", "index"), { + rmSync(path.join(FIXTURE, "cache", "index"), { recursive: true, force: true, }); diff --git a/umtool/e2e/report-longform.spec.ts b/umtool/e2e/report-longform.spec.ts @@ -21,6 +21,10 @@ const cliEnv = { SONG_REPORTS_DIR: path.join(FIXTURE, "reports"), SONG_DIR: path.join(FIXTURE, "data"), CHANNELS_DIR: path.join(FIXTURE, "channels"), + // The fixture's cache, as playwright.config.ts gives the app (never ~/.cache). + UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + // Never the real media root, whatever this shell exports (empty = unset). + UMTOOL_MEDIA_DIR: "", YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"), }; const umtool = (args: string[]) => diff --git a/umtool/e2e/storage.spec.ts b/umtool/e2e/storage.spec.ts @@ -0,0 +1,180 @@ +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, lstatSync, readdirSync, readlinkSync, renameSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// A project's render scratch on a media root (release 17, slice U1). +// +// With UMTOOL_MEDIA_DIR set, a project's out/ is a link to the same +// project-relative path under it: made by the first writer, or moved there by +// `umtool storage move-out`. Only THIS spec's CLI is given the variable (the +// media root is make-fixture's `<fixture>-media`, reset every run); the app is +// not, which is the point of half of it -- a reader, and a build the app runs, +// go through the link without knowing a media root exists. +// +// The tests run in order and hand the project's state on: moved out, built +// through, unplugged, plugged back, moved back. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const UMTOOL = path.join(HERE, ".."); +const FIXTURE = path.join(UMTOOL, ".e2e-song"); +const MEDIA = `${FIXTURE}-media`; +const UNPLUGGED = `${MEDIA}.unplugged`; +const PROJECT = "reports/storage-fixture"; +const FRESH = "reports/storage-fresh-fixture"; +const dirOf = (id: string) => path.join(FIXTURE, id); +const mirrorOf = (id: string) => path.join(MEDIA, id); + +const env = { + ...process.env, + SONG_REPORTS_DIR: path.join(FIXTURE, "reports"), + SONG_DIR: path.join(FIXTURE, "data"), + CHANNELS_DIR: path.join(FIXTURE, "channels"), + UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), + YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"), + UMTOOL_MEDIA_DIR: MEDIA, +}; +const umtool = (args: string[]) => + JSON.parse(execFileSync("node", ["bin/umtool.mjs", ...args, "--json"], { cwd: UMTOOL, encoding: "utf8", env })); +// The pipeline's first writer, as a build's step 1 runs it. +const checkAvailability = (id: string) => + spawnSync( + "node", + [ + path.join(UMTOOL, "report-to-video", "check-availability.mjs"), + path.join(dirOf(id), "video.manifest.json"), + "--out", + path.join(dirOf(id), "out"), + "--allow-missing", + ], + { cwd: path.join(UMTOOL, "report-to-video"), encoding: "utf8", env }, + ); + +const isLink = (p: string) => existsSync(path.dirname(p)) && lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() === true; +const isRealDir = (p: string) => lstatSync(p, { throwIfNoEntry: false })?.isDirectory() === true; +const parked = (id: string) => readdirSync(dirOf(id)).filter((n) => n.startsWith("out.moved-") || n === "out.incoming"); + +async function projectRow(request: APIRequestContext, id: string) { + const j = (await (await request.get("/api/browse/projects")).json()) as { + projects: { id: string; state: string; facts: string[] }[]; + }; + return j.projects.find((p) => p.id === id); +} + +test.describe.configure({ mode: "serial" }); + +test.afterAll(() => { + // A failure mid-way must not leave the root unplugged for the next run's + // reader of this file -- make-fixture resets it anyway. + if (existsSync(UNPLUGGED) && !existsSync(MEDIA)) renameSync(UNPLUGGED, MEDIA); +}); + +test("move-out leaves a link to the media root, and readers see the same project", async ({ request }) => { + const out = path.join(dirOf(PROJECT), "out"); + expect(isRealDir(out)).toBe(true); + const before = await projectRow(request, PROJECT); + expect(before).toBeTruthy(); + + // A dry run measures and changes nothing. + const dry = umtool(["storage", "move-out", PROJECT, "--dry-run"]); + expect(dry.results[0].state).toBe("would-move"); + expect(dry.results[0].bytes).toBeGreaterThan(0); + expect(isRealDir(out)).toBe(true); + expect(existsSync(mirrorOf(PROJECT))).toBe(false); + + const moved = umtool(["storage", "move-out", PROJECT]); + expect(moved.ok).toBe(true); + expect(moved.results[0].state).toBe("moved"); + expect(isLink(out)).toBe(true); + expect(readlinkSync(out)).toBe(path.join(mirrorOf(PROJECT), "out")); + expect(existsSync(path.join(mirrorOf(PROJECT), "out", "clips-raw", "vid1_0.00-9.00.mp4"))).toBe(true); + expect(parked(PROJECT)).toEqual([]); + + // The index and the page read <project>/out by path; the link changes nothing. + const after = await projectRow(request, PROJECT); + expect(after?.state).toBe(before?.state); + expect(after?.facts).toEqual(before?.facts); + + // Again: nothing to do. + expect(umtool(["storage", "move-out", PROJECT]).results[0].state).toBe("already"); + const status = umtool(["storage", PROJECT]); + expect(status.projects[0].state).toBe("link"); +}); + +test("a build the app runs writes through the link, and leaves it a link", async ({ request }) => { + const start = await request.post("/api/report/build", { data: { project: PROJECT, preset: "fast" } }); + expect(start.ok()).toBeTruthy(); + const { job } = (await start.json()) as { job: { id: string } }; + let state = "running"; + for (let i = 0; i < 150 && state === "running"; i += 1) { + const j = (await (await request.get("/api/jobs")).json()) as { jobs: { id: string; state: string }[] }; + state = j.jobs.find((x) => x.id === job.id)?.state ?? "running"; + if (state === "running") await new Promise((r) => setTimeout(r, 200)); + } + expect(state).toBe("done"); + + const out = path.join(dirOf(PROJECT), "out"); + expect(isLink(out)).toBe(true); + expect(existsSync(path.join(mirrorOf(PROJECT), "out", "storage-fixture.mp4"))).toBe(true); + expect(existsSync(path.join(mirrorOf(PROJECT), "out", "availability.json"))).toBe(true); +}); + +test("an unplugged media root refuses loudly and materialises nothing", () => { + renameSync(MEDIA, UNPLUGGED); + try { + expect(umtool(["storage", PROJECT]).projects[0].state).toBe("dangling"); + + // A moved project: the link dangles, and the first writer says why. + const r = checkAvailability(PROJECT); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain("is the media drive mounted?"); + expect(isLink(path.join(dirOf(PROJECT), "out"))).toBe(true); + + // A project with no out/ yet: the root is stat'd, never created. + const fresh = checkAvailability(FRESH); + expect(fresh.status).not.toBe(0); + expect(fresh.stderr).toContain("is not there"); + expect(existsSync(path.join(dirOf(FRESH), "out"))).toBe(false); + + // Neither recreated the root on the disk it was "on". + expect(existsSync(MEDIA)).toBe(false); + + // The doctor says so, and exits 1. + const doctor = spawnSync("node", ["bin/umtool.mjs", "doctor", "--json"], { cwd: UMTOOL, encoding: "utf8", env }); + expect(doctor.status).toBe(1); + const roots = JSON.parse(doctor.stdout).roots; + expect(roots.media.tiered).toBe(true); + expect(roots.media.problem).toContain("is not there"); + expect(roots.cache.path).toBe(path.join(FIXTURE, "cache")); + } finally { + renameSync(UNPLUGGED, MEDIA); + } +}); + +test("the first writer of a project with no out/ makes the link", () => { + const r = checkAvailability(FRESH); + expect(r.status, r.stderr).toBe(0); + const out = path.join(dirOf(FRESH), "out"); + expect(isLink(out)).toBe(true); + expect(readlinkSync(out)).toBe(path.join(mirrorOf(FRESH), "out")); + expect(existsSync(path.join(mirrorOf(FRESH), "out", "availability.json"))).toBe(true); +}); + +test("move-back makes out/ a real directory again and removes the media copy", () => { + const back = umtool(["storage", "move-back", PROJECT]); + expect(back.ok).toBe(true); + expect(back.results[0].state).toBe("moved"); + const out = path.join(dirOf(PROJECT), "out"); + expect(isRealDir(out)).toBe(true); + expect(existsSync(path.join(out, "storage-fixture.mp4"))).toBe(true); + expect(parked(PROJECT)).toEqual([]); + // The project's mirror is gone; the root, and the other project's, are not. + expect(existsSync(mirrorOf(PROJECT))).toBe(false); + expect(existsSync(MEDIA)).toBe(true); + expect(isLink(path.join(dirOf(FRESH), "out"))).toBe(true); + + expect(umtool(["storage", "move-back", PROJECT]).results[0].state).toBe("already"); +}); diff --git a/umtool/lib/media.ts b/umtool/lib/media.ts @@ -2,10 +2,10 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; -import { MEDIA_ROOTS, MIX_CACHE, REPORTS_ROOT, SONG_REPORTS, labelFor } from "./paths"; +import { MEDIA_ROOT, MEDIA_ROOTS, MEDIA_TIERED, MIX_CACHE, REPORTS_ROOT, SONG_REPORTS, inside, labelFor } from "./paths"; import { brightnessCurve, brightnessSteps } from "../song/flatness.mjs"; const run = promisify(execFile); @@ -79,6 +79,22 @@ const SCRATCH_FILE = /^(poly-song-|polytmp-|seg_|i_|o_|ms\d?seg|out\.raw)/; /** Below this is a fragment, a probe or a one-note extraction, not a track. */ const MIN_INTERESTING = 256 * 1024; +/** + * A project's `out` linked to the media root (UMTOOL_MEDIA_DIR, release 17) is + * walked like the directory it replaced, so a tiered deliverable stays in the + * picker under its project. Only a link INTO the media root: every other link + * stays unfollowed, as it always was (SONG_DATA's 39 GB are links). + */ +/** The project directories that may be links to the media root (U2 adds deliverables). */ +const MEDIA_LINKS = new Set(["out"]); + +async function isMediaLink(e: { name: string; isSymbolicLink(): boolean }, abs: string): Promise<boolean> { + if (!MEDIA_TIERED || !e.isSymbolicLink() || !MEDIA_LINKS.has(e.name)) return false; + const real = await realpath(abs).catch(() => null); + if (!real || !inside(MEDIA_ROOT, real)) return false; + return stat(real).then((s) => s.isDirectory(), () => false); +} + export type MediaRow = { path: string; label: string; size: number; mtimeMs: number }; /** @@ -103,7 +119,7 @@ export async function listMediaUnder(root: string, maxDepth = 2, limit = 200): P for (const e of entries) { if (e.name.startsWith(".")) continue; const abs = path.join(dir, e.name); - if (e.isDirectory()) { + if (e.isDirectory() || (await isMediaLink(e, abs))) { if (depth > 0 && !SCRATCH_DIR.test(e.name)) await walk(abs, depth - 1); continue; } @@ -139,7 +155,7 @@ export async function listMedia(limit = 400): Promise<MediaRow[]> { for (const e of entries) { if (e.name.startsWith(".")) continue; const abs = path.join(dir, e.name); - if (e.isDirectory()) { + if (e.isDirectory() || (await isMediaLink(e, abs))) { if (depth > 0 && !SCRATCH_DIR.test(e.name)) await walk(abs, depth - 1); continue; } @@ -168,7 +184,13 @@ export async function listMedia(limit = 400): Promise<MediaRow[]> { // to find nothing anybody would load, which is the opposite of the problem // this is fixing. const depthFor = (r: string) => (r === REPORTS_ROOT || r === SONG_REPORTS ? 2 : 1); - for (const r of MEDIA_ROOTS) await walk(r, depthFor(r)); + // The media root is reached through the projects' `out` links, under the + // project's own path; walked as a root as well, every tiered deliverable + // would be listed twice and the second copy would land in "other". + for (const r of MEDIA_ROOTS) { + if (MEDIA_TIERED && r === MEDIA_ROOT) continue; + await walk(r, depthFor(r)); + } out.sort((a, b) => b.mtimeMs - a.mtimeMs); return out.slice(0, limit); } diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs @@ -14,9 +14,25 @@ import { SONG_DATA, SONG_REPORTS } from "../song/paths.mjs"; // record paths relative to it (make-thumb, accept-thumb) import only siblings. export { SONG_DATA, SONG_REPORTS }; -// Derived output (sliced mp3s, waveform peaks, the project index). Lives with -// the data, not in the repo, and is safe to delete at any time. -export const CACHE_DIR = path.join(SONG_DATA, ".cache", "umtool"); +// Derived output (sliced mp3s, waveform peaks, the project index, posters, the +// mix bench's analyses). Not in the repo, and safe to delete at any time. +// +// It used to live under SONG_DATA (`<SONG_DIR>/.cache/umtool`), which tied +// every project's index and every report's derived files to wherever the song +// project's 39 GB happened to sit -- a report-only machine, or one whose song +// data is on a drive that is not mounted, had its cache follow it there +// (release 17). It is a cache, so it goes where caches go: +// `UMTOOL_CACHE_DIR`, else `$XDG_CACHE_HOME/archilyzer/umtool` (an empty +// XDG_CACHE_HOME is unset, as common/lib/paths.ts reads it), else +// `~/.cache/archilyzer/umtool`. Nothing is migrated: the first `umtool index` +// rebuilds the index there, and every other file is re-made on demand. +// OLD_CACHE_DIR is only for `umtool doctor`, which reports a leftover one. +const XDG_CACHE = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); +export const CACHE_DIR = path.resolve( + /* turbopackIgnore: true */ + process.env.UMTOOL_CACHE_DIR || path.join(/* turbopackIgnore: true */ XDG_CACHE, "archilyzer", "umtool"), +); +export const OLD_CACHE_DIR = path.join(/* turbopackIgnore: true */ SONG_DATA, ".cache", "umtool"); /** * A file in CACHE_DIR, by name. A route names its cache files through this @@ -61,6 +77,53 @@ export const REPORTS_ROOT = path.resolve( const dedupe = (list) => [...new Set(list.map((p) => path.resolve(p)))]; // --------------------------------------------------------------------------- +// MEDIA_ROOT -- where a project's RENDER SCRATCH (`out/`) lives (release 17). +// +// A report project's manifest, revisions/, notes and sources are small text and +// stay under REPORTS_ROOT. Its `out/` -- fetched windows, segments, the +// deliverable, ~18 of the 20 GB in ~/reports -- is bulk that can be re-made, +// and belongs on a media drive. With UMTOOL_MEDIA_DIR set, a project's `out` is +// an absolute SYMLINK to the same project-relative path under it: +// +// <REPORTS_ROOT>/<folder>/<project>/out -> <MEDIA_ROOT>/<folder>/<project>/out +// +// made by the first writer (lib/report/storage.mjs ensureOutDir) or moved there +// by `umtool storage move-out`. Every reader keeps opening `<project>/out/...` +// by path; the link is the only place the media drive is named. +// +// UNSET, MEDIA_ROOT is REPORTS_ROOT, MEDIA_TIERED is false, and nothing changes: +// `out/` is a plain directory in the project, as it always was. +// +// MEDIA_ROOT is READABLE -- so a client may name a media file by its real path +// (one taken through a project's `out` link lands under it) to the mix bench's +// /api/mix/{media,track} -- and never WRITABLE by a client-named path: what a +// render may write to is still WRITE_ROOTS, judged lexically, so a write to +// `<project>/out/...` is judged by the project's place, never the link's target. +// --------------------------------------------------------------------------- +export const MEDIA_ROOT = path.resolve( + /* turbopackIgnore: true */ + process.env.UMTOOL_MEDIA_DIR || REPORTS_ROOT, +); + +/** True when render scratch goes to a media root of its own. */ +export const MEDIA_TIERED = MEDIA_ROOT !== REPORTS_ROOT; + +/** + * Where `abs` (a path under REPORTS_ROOT) is mirrored under MEDIA_ROOT, or null + * when it is not under REPORTS_ROOT -- a project somewhere else is never tiered. + * Pure: it never touches the disk. The roots are parameters so a test can name + * its own; the defaults are the process's. + */ +export function mediaMirror(abs, { reportsRoot = REPORTS_ROOT, mediaRoot = MEDIA_ROOT } = {}) { + const rel = path.relative( + /* turbopackIgnore: true */ path.resolve(/* turbopackIgnore: true */ reportsRoot), + path.resolve(/* turbopackIgnore: true */ abs), + ); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null; + return path.join(/* turbopackIgnore: true */ path.resolve(/* turbopackIgnore: true */ mediaRoot), rel); +} + +// --------------------------------------------------------------------------- // READ vs WRITE, and why they are two lists. // // resolveInRoots() guards both what may be OPENED and what may be RENDERED TO. @@ -148,7 +211,7 @@ export const CHANNELS_DIR = path.resolve( export const READ_ROOTS = dedupe( process.env.MIX_ROOTS ? process.env.MIX_ROOTS.split(":").filter(Boolean) - : [SONG_REPORTS, REPORTS_ROOT, SONG_DATA, SONG_SCRATCH, CHANNELS_DIR], + : [SONG_REPORTS, REPORTS_ROOT, SONG_DATA, SONG_SCRATCH, CHANNELS_DIR, MEDIA_ROOT], ); export const WRITE_ROOTS = dedupe( diff --git a/umtool/lib/paths.ts b/umtool/lib/paths.ts @@ -8,6 +8,7 @@ import path from "node:path"; // SONG_REPORTS the um-song deliverables -- quartering-*.mp4 and their .plan.json // SONG_SCRATCH render scratch, ABOVE SONG_DATA // REPORTS_ROOT the tree every PROJECT hangs off -- songs AND report videos +// MEDIA_ROOT where a project's out/ is linked to (UMTOOL_MEDIA_DIR); = REPORTS_ROOT when unset // // Everything the bench reads or writes must resolve inside one of these. Not // because this is exposed -- it is a local tool on a loopback port -- but @@ -21,7 +22,9 @@ export { CACHE_DIR, cacheFile, INDEX_DIR, + MEDIA_ROOT, MEDIA_ROOTS, + MEDIA_TIERED, MIX_CACHE, READ_ROOTS, REPORTS_ROOT, @@ -31,6 +34,7 @@ export { WRITE_ROOTS, inside, labelFor, + mediaMirror, resolveInRoots, } from "./paths.mjs"; diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -47,8 +47,31 @@ export const SKIP_DIRS = new Set([ // never reaches inside one -- but a snapshot directory left behind by a // deleted manifest must not read as a project either. "revisions", + // A report's cut clips. Like out/, it may be a link to the media root + // (release 17), and the walk follows a link to a directory with a stat -- + // which, on a media drive that is unplugged or stalled, is a hang or a + // miss, never a project. + "clips", ]); +/** + * Name PREFIXES the walk never descends into, for the same reason as `clips`: + * `share-<x>/` (a deliver's zip and its staging) may be a link to the media + * root, and none of them can contain a project. + */ +export const SKIP_PREFIXES = ["share-"]; + +/** + * What a cut move leaves beside the directory it moved (lib/report/storage.mjs): + * `out.moved-<stamp>`, `out.incoming` -- only for the names that move, so a + * folder of projects that merely ends in `.incoming` is still walked. + */ +const MOVE_LEFTOVER = /^(out|clips|share-[^/]*)\.(moved-[^/]*|incoming)$/; + +/** Whether the walk skips a directory entry by its name. */ +export const skipsDir = (name) => + SKIP_DIRS.has(name) || SKIP_PREFIXES.some((p) => name.startsWith(p)) || MOVE_LEFTOVER.test(name); + const has = (names, n) => names.has(n); const someMatch = (names, re) => [...names].some((n) => re.test(n)); diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs @@ -11,6 +11,7 @@ import { DEFAULT_VARIANT, cachedWindowsFor } from "umtool-report-to-video/build- import { rawCacheOf } from "../report/raw-cache.mjs"; import { CHANNELS_DIR } from "../paths.mjs"; import { channelName, cleanTitle } from "umtool-report-to-video/attribution"; +import { teaserTitle } from "umtool-report-to-video/deck"; /** * Where a build's per-entry segments live. @@ -1110,7 +1111,8 @@ export async function readClipDetail(dir, { manifest = null } = {}) { // and 500'd the whole project page. Every other real manifest is clips only, // which is exactly why this survived testing. if (e.type !== "clip") { - entries.push({ ...e, kind: e.type ?? "entry" }); + // A teaser has no heading or title of its own: its row names its lines. + entries.push({ ...e, kind: e.type ?? "entry", ...(e.type === "teaser" ? { label: teaserTitle(e) } : {}) }); continue; } const chan = channelFor(m, e); diff --git a/umtool/lib/projects/walk.mjs b/umtool/lib/projects/walk.mjs @@ -15,7 +15,7 @@ // project. The 3.1 GB is never touched. import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; -import { RESERVED_BROWSE, SKIP_DIRS, detectKind } from "./kinds.mjs"; +import { RESERVED_BROWSE, detectKind, skipsDir } from "./kinds.mjs"; /** A single safe path segment: no separators, no traversal, no dotfiles. */ export const isSegment = (v) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v) && !v.includes(".."); @@ -103,7 +103,7 @@ export async function walkProjects(root, { maxDepth = MAX_DEPTH } = {}) { for (const e of entries) { if (e.name.startsWith(".")) continue; - if (SKIP_DIRS.has(e.name)) continue; + if (skipsDir(e.name)) continue; let isDir = e.isDirectory(); if (!isDir && e.isSymbolicLink()) { isDir = await stat(path.join(abs, e.name)).then((s) => s.isDirectory(), () => false); diff --git a/umtool/lib/report/export.mjs b/umtool/lib/report/export.mjs @@ -18,6 +18,8 @@ import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_VARIANT, selectVariant, segmentOffsets } from "umtool-report-to-video/build-video"; import { citeUrlFor, channelFor, readAvailability, readManifest } from "../projects/report.mjs"; +import { teaserTitle } from "umtool-report-to-video/deck"; +import { outDirState } from "./storage.mjs"; export const EXPORT_FORMATS = ["toc-bbcode", "toc-markdown", "description", "chapters"]; @@ -60,15 +62,24 @@ export function parseFfmeta(text) { } /** The chapter title the build would print: `chapter`, else the entry's own words. */ -const titleOf = (e, i) => e.chapter ?? (e.type === "clip" ? `${i + 1}. ${e.video}` : e.title ?? e.heading ?? `Card ${i + 1}`); +const titleOf = (e, i) => + e.chapter ?? + (e.type === "clip" ? `${i + 1}. ${e.video}` : e.type === "teaser" ? teaserTitle(e) : e.title ?? e.heading ?? `Card ${i + 1}`); /** * Deliverable-second offsets for every entry of the chosen variant. - * @returns {Promise<{ starts: number[], source: "ffmeta"|"segments", file: string, note?: string } | { error: string }>} + * @returns {Promise<{ starts: number[], source: "ffmeta"|"schedule"|"segments", file: string, note?: string } | { error: string }>} */ export async function chapterOffsets(dir, manifest, variant) { const entries = manifest.timeline ?? []; const outDir = path.join(dir, "out"); + // Read through, never made: an export writes nothing under out/. But a link + // to a media root that is not mounted would read below as "no build", which + // sends somebody off to rebuild a cut that is sitting on an unplugged drive. + const out = await outDirState(dir); + if (out.state === "dangling") { + return { error: `out/ is a link to ${out.target}, which is not there — is the media drive mounted?` }; + } const ffmetaCandidates = [path.join(outDir, variant, "chapters.ffmeta"), path.join(outDir, "chapters.ffmeta")]; for (const file of ffmetaCandidates) { const text = await readFile(file, "utf8").catch(() => null); @@ -84,6 +95,21 @@ export async function chapterOffsets(dir, manifest, variant) { return { starts: chapters.map((c) => c.start), source: "ffmeta", file }; } + // No ffmeta, but a deck schedule for this cut: its starts are the cut's own, + // holds included -- the segment files alone do not know a clip is held. + const sched = await readFile(path.join(outDir, variant, "schedule.json"), "utf8") + .then((t) => JSON.parse(t), () => null); + if (sched?.kind === "deck" && Array.isArray(sched.segments) && + sched.segments.map((x) => x.id).join("\n") === entries.map((e) => e.id).join("\n")) { + const D = sched.transition ?? 0; + return { + starts: sched.segments.map((x, i) => (i === 0 ? 0 : x.start + D)), + source: "schedule", + file: path.join(outDir, variant, "schedule.json"), + note: "no chapters.ffmeta; offsets from the deck's schedule.json", + }; + } + // No ffmeta: the pipeline's own offset arithmetic over the segments on disk. const segDirs = [path.join(outDir, variant, "segments"), path.join(outDir, "segments")]; for (const segDir of segDirs) { diff --git a/umtool/lib/report/footage-move.mjs b/umtool/lib/report/footage-move.mjs @@ -0,0 +1,90 @@ +// Where the footage is at a moment of the cut, for umtool's live preview. +// +// `posts.shift` moves the footage aside while a clip's posts are up: the +// schedule's `moves` (deck.mjs `footageMoves`) say when and from which box to +// which. The BUILD draws the move; the preview only has to put its backdrop -- +// a still of the built segment, or the neutral frame -- where the build will +// have put the footage, so a scrubbed moment reads as the render. Pure: no +// DOM, no React, so the arithmetic is unit-tested and the page only applies it. +// +// The rule the build follows, read here the same way: +// - a move belongs to ONE segment, and only that segment's footage moves: +// the next one comes in at the normal box through the transition, so once +// the scrubber is in the next segment there is no move; +// - before `at` the footage is in its box; over `seconds` it eases to `to`; +// after that it stays at `to` to the end of the segment (the hold included). +// +// The easing is smoothstep, p²(3 − 2p): the curve the build's move uses. + +/** @typedef {{ x: number, y: number, width: number, height: number }} Rect */ +/** @typedef {{ segment: string, at: number, segmentAt: number, seconds: number, from: Rect, to: Rect }} Move */ + +/** smoothstep on [0, 1], clamped: 0 and 1 outside it. */ +export function ease(p) { + if (!(p > 0)) return 0; + if (p >= 1) return 1; + return p * p * (3 - 2 * p); +} + +/** + * The segment on screen at `t`: the last one that has started. Past the end, + * the last; before the first, the first. The preview's own rule (its + * `segmentAt`), so the backdrop and the move agree on whose footage it is. + * + * @param {{ segments: Array<{ id: string, start: number }> }} schedule + * @param {number} t + */ +export function segmentIdAt(schedule, t) { + const segs = schedule?.segments ?? []; + for (let i = segs.length - 1; i >= 0; i -= 1) if (t >= segs[i].start) return segs[i].id; + return segs[0]?.id ?? null; +} + +const lerp = (a, b, p) => a + (b - a) * p; + +/** + * The footage's box at `t`, when the segment on screen has a move: its + * eased progress (0 before the move, 1 after it) and the rect between `from` + * and `to`. null when the segment on screen has no move -- the footage is in + * its box and nothing needs drawing differently. + * + * @param {{ segments: Array<{ id: string, start: number }>, moves?: Move[] }} schedule + * @param {number} t seconds in the cut's clock + * @returns {{ segment: string, progress: number, rect: Rect, from: Rect, to: Rect } | null} + */ +export function footageAt(schedule, t) { + const moves = schedule?.moves ?? []; + if (!moves.length) return null; + const id = segmentIdAt(schedule, t); + const m = moves.find((x) => x.segment === id); + if (!m) return null; + const progress = m.seconds > 0 ? ease((t - m.at) / m.seconds) : t >= m.at ? 1 : 0; + const rect = { + x: lerp(m.from.x, m.to.x, progress), + y: lerp(m.from.y, m.to.y, progress), + width: lerp(m.from.width, m.to.width, progress), + height: lerp(m.from.height, m.to.height, progress), + }; + return { segment: m.segment, progress, rect, from: m.from, to: m.to }; +} + +/** + * The CSS transform that takes a whole-frame backdrop (W×H, `transform-origin: + * 0 0`) with its footage at `from` and puts that footage at `rect`. Translate + * is in percent of the element -- the frame -- so it holds at any displayed + * size. "none" when nothing moves. + * + * @param {Rect} from + * @param {Rect} rect + * @param {{ W: number, H: number }} frame + */ +export function backdropTransform(from, rect, { W, H }) { + const sx = rect.width / from.width; + const sy = rect.height / from.height; + const tx = rect.x - sx * from.x; + const ty = rect.y - sy * from.y; + if (Math.abs(sx - 1) < 1e-6 && Math.abs(sy - 1) < 1e-6 && Math.abs(tx) < 1e-6 && Math.abs(ty) < 1e-6) return "none"; + const pct = (v, of) => `${Math.round((v / of) * 100 * 10000) / 10000}%`; + const n = (v) => Math.round(v * 1e6) / 1e6; + return `translate(${pct(tx, W)}, ${pct(ty, H)}) scale(${n(sx)}, ${n(sy)})`; +} diff --git a/umtool/lib/report/footage-move.test.mjs b/umtool/lib/report/footage-move.test.mjs @@ -0,0 +1,93 @@ +// The footage's box at a moment of the cut, as umtool's preview draws it. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { estimateSchedule } from "umtool-report-to-video/deck"; +import { backdropTransform, ease, footageAt, segmentIdAt } from "./footage-move.mjs"; + +const FROM = { x: 173, y: 2, width: 1574, height: 886 }; +const TO = { x: 24, y: 64, width: 1354, height: 762 }; +const schedule = { + segments: [ + { id: "a", start: 0 }, + { id: "b", start: 9.5 }, + { id: "c", start: 19 }, + ], + moves: [{ segment: "a", at: 4, segmentAt: 4, seconds: 0.5, from: FROM, to: TO }], +}; + +test("ease: smoothstep, clamped", () => { + assert.equal(ease(-1), 0); + assert.equal(ease(0), 0); + assert.equal(ease(0.5), 0.5); + assert.equal(ease(0.25), 0.15625); + assert.equal(ease(1), 1); + assert.equal(ease(2), 1); + assert.equal(ease(Number.NaN), 0); +}); + +test("segmentIdAt: the last segment that has started", () => { + assert.equal(segmentIdAt(schedule, 0), "a"); + assert.equal(segmentIdAt(schedule, 9.49), "a"); + assert.equal(segmentIdAt(schedule, 9.5), "b"); + assert.equal(segmentIdAt(schedule, 100), "c"); + assert.equal(segmentIdAt(schedule, -1), "a"); + assert.equal(segmentIdAt({ segments: [] }, 1), null); +}); + +test("footageAt: the box before the move, eased through it, held at `to` to the end of the segment", () => { + assert.deepEqual(footageAt(schedule, 3.9).rect, FROM); + assert.equal(footageAt(schedule, 3.9).progress, 0); + const mid = footageAt(schedule, 4.25); + assert.equal(mid.progress, 0.5); + assert.deepEqual(mid.rect, { x: 98.5, y: 33, width: 1464, height: 824 }); + assert.equal(footageAt(schedule, 4.125).progress, 0.15625); + assert.deepEqual(footageAt(schedule, 4.5).rect, TO); + assert.deepEqual(footageAt(schedule, 9.4).rect, TO); + // The next segment comes in at the normal box: no move once it is on screen. + assert.equal(footageAt(schedule, 9.5), null); + assert.equal(footageAt(schedule, 20), null); + assert.equal(footageAt({ segments: schedule.segments }, 5), null); + // A zero-second move is a cut. + const snap = { ...schedule, moves: [{ ...schedule.moves[0], seconds: 0 }] }; + assert.equal(footageAt(snap, 3.99).progress, 0); + assert.equal(footageAt(snap, 4).progress, 1); +}); + +test("footageAt over deck.mjs's own schedule: the moves estimateSchedule emits", () => { + const m = { + render: { width: 1920, height: 1080, fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }, + timeline: [ + { type: "clip", id: "c01", video: "v", start: 0, end: 12, date: "2024-09-03" }, + { type: "clip", id: "c02", video: "v", start: 20, end: 30, date: "2024-09-10" }, + ], + posts: [{ id: "p", platform: "x", date: "2024-09-05", text: "t", url: "https://x.com/a/status/1" }], + }; + const s = estimateSchedule(m); + const [move] = s.moves; + assert.equal(move.segment, "c01"); + assert.deepEqual(footageAt(s, move.at - 0.01).rect, move.from); + assert.deepEqual(footageAt(s, move.at + move.seconds).rect, move.to); + // Through c01's hold, still aside; c02 is at its box. + const c02 = s.segments.find((x) => x.id === "c02"); + assert.deepEqual(footageAt(s, c02.start - 0.01).rect, move.to); + assert.equal(footageAt(s, c02.start), null); +}); + +test("backdropTransform: the frame scaled and moved so `from` lands on the rect", () => { + const frame = { W: 1920, H: 1080 }; + assert.equal(backdropTransform(FROM, FROM, frame), "none"); + const tr = backdropTransform(FROM, TO, frame); + // sx 1354/1574, tx 24 − sx·173 = −124.82 px = −6.501 % of 1920; ty 64 − sy·2 = 62.28 px. + assert.equal(tr, "translate(-6.501%, 5.7667%) scale(0.860229, 0.860045)"); + // Check the mapping itself: the box's corners land on TO's. + const sx = TO.width / FROM.width; + const sy = TO.height / FROM.height; + const tx = (-6.501 / 100) * 1920; + const ty = (5.7667 / 100) * 1080; + assert.ok(Math.abs(tx + sx * FROM.x - TO.x) < 0.01); + assert.ok(Math.abs(ty + sy * FROM.y - TO.y) < 0.01); + assert.ok(Math.abs(tx + sx * (FROM.x + FROM.width) - (TO.x + TO.width)) < 0.01); +}); diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -31,6 +31,7 @@ import { } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck"; +import { parseMuteFrom } from "./playback.mjs"; // Its own write queue, not lib/state.ts's. // @@ -281,6 +282,34 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) { } } + // ---- the mute mark ------------------------------------------------------ + // + // `muteFrom`: from this source second to the end of the clip the sound + // fades out and the picture plays on -- set by ear, in the bench, at the + // last silence before a finale's ending sound. Inside the EXTENT, like the + // cut, and checked against the entry AFTER the patch: a window save that + // leaves the mark outside is refused rather than keeping a mark that no + // longer says anything about the clip. Empty or null deletes it. + if (patch.muteFrom !== undefined) { + const v = parseMuteFrom(patch.muteFrom, entry.start, entry.end); + if (v == null) delete entry.muteFrom; + else entry.muteFrom = v; + } else if ((patch.start !== undefined || patch.end !== undefined) && entry.muteFrom != null) { + // Clamped and STORED, as a patched mark is: a mark within the writer's + // 0.02 s of the moved edge lands on it (past the new start it still + // mutes the whole clip), and the build (validateMuteFrom, strict) + // accepts every manifest saved here. Left as it was, a start moved from 10.00 to 10.01 under a mark at + // 10.00 saved, and the next build refused the whole manifest. + try { + entry.muteFrom = parseMuteFrom(entry.muteFrom, entry.start, entry.end); + } catch { + throw new Error( + `the mute mark ${entry.muteFrom} must lie inside the window ` + + `${entry.start}–${entry.end} — widen the window, or clear the mark`, + ); + } + } + // ---- the walk's verdict ------------------------------------------------- // // Whether somebody has LOOKED at this clip and said the description is what diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs @@ -22,6 +22,7 @@ import { updateOnscreen, updatePosts, } from "./manifest.mjs"; +import { validateCutEdits } from "umtool-report-to-video/deck"; const base = () => ({ slug: "t", @@ -181,6 +182,64 @@ test("updateClip: onscreen is normalised, and empty or null deletes the key", as } }); +test("updateClip: muteFrom is a number inside the clip, rounded; empty or null deletes the key", async () => { + const dir = await project(); + try { + const res = await updateClip(dir, "c01", { muteFrom: 18.456 }); + assert.equal(res.entry.muteFrom, 18.46); + assert.equal(entry(await read(dir), "c01").muteFrom, 18.46); + // A string from a form is a number too. + await updateClip(dir, "c01", { muteFrom: "17.5" }); + assert.equal(entry(await read(dir), "c01").muteFrom, 17.5); + + const before = await readRaw(dir); + // Outside [start, end], and not a number: refused, nothing written. + await assert.rejects(updateClip(dir, "c01", { muteFrom: 25 }), /must lie inside the clip 10–20/); + await assert.rejects(updateClip(dir, "c01", { muteFrom: 5 }), /must lie inside/); + await assert.rejects(updateClip(dir, "c01", { muteFrom: "later" }), /must be a number/); + // A window that would leave the mark outside it is refused too... + await assert.rejects(updateClip(dir, "c01", { end: 17 }), /mute mark 17.5 must lie inside the window 10–17/); + assert.equal(await readRaw(dir), before); + // ...unless the same patch moves or clears it. + await updateClip(dir, "c01", { end: 17, muteFrom: 16 }); + assert.equal(entry(await read(dir), "c01").muteFrom, 16); + // Checked against the window AFTER the patch: a wider window and a mark in + // the new seconds land together. + await updateClip(dir, "c01", { end: 22, muteFrom: 21 }); + assert.equal(entry(await read(dir), "c01").muteFrom, 21); + + await updateClip(dir, "c01", { muteFrom: "" }); + assert.equal("muteFrom" in entry(await read(dir), "c01"), false); + await updateClip(dir, "c01", { muteFrom: 12 }); + await updateClip(dir, "c01", { muteFrom: null }); + assert.equal("muteFrom" in entry(await read(dir), "c01"), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateClip: a window edge moved within 0.02 s past the mute mark clamps the mark onto it, so the build accepts what was saved", async () => { + const dir = await project(); + try { + // c01 is 10–20. A mark on the start, then the start moved a hundredth on. + await updateClip(dir, "c01", { muteFrom: 10 }); + await updateClip(dir, "c01", { start: 10.01 }); + let m = await read(dir); + assert.equal(entry(m, "c01").muteFrom, 10.01, "the mark moves onto the new start"); + assert.deepEqual(validateCutEdits(m), [], "and the build's strict check takes it"); + // The same at the end. + await updateClip(dir, "c01", { muteFrom: 20 }); + await updateClip(dir, "c01", { end: 19.99 }); + m = await read(dir); + assert.equal(entry(m, "c01").muteFrom, 19.99); + assert.deepEqual(validateCutEdits(m), []); + // Further than the tolerance is still refused. + await assert.rejects(updateClip(dir, "c01", { end: 19.9 }), /mute mark 19.99 must lie inside the window/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + const DECK = { engine: "hyperframes", layout: "deck", deck: { height: 180, title: { size: 60 } } }; test("updateChrome: round trip stores the block as given; null removes it", async () => { diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -15,7 +15,7 @@ // // The preview never renders and never touches the build's project or cache: // compose-chrome's `preview: true` writes out/<variant>/chrome/deck-preview/. -import { mkdir, readFile, rm } from "node:fs/promises"; +import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { composeChrome } from "umtool-report-to-video/compose-chrome"; import { selectVariant } from "umtool-report-to-video/build-video"; @@ -24,7 +24,9 @@ import { clipDay, deckText, estimateSchedule, + footageMoves, normalizeOnscreen, + postHolds, postSchedule, postWindows, resolveDeck, @@ -38,6 +40,7 @@ import { } from "../projects/report.mjs"; import { normalizePostPatches } from "./manifest.mjs"; import { deckPreviewDir, postsPreviewDir } from "./serve.mjs"; +import { ensureWriteDir } from "./storage.mjs"; /** The schedule document deck.mjs defines, built or estimated. */ /** @typedef {ReturnType<typeof estimateSchedule>} DeckSchedule */ @@ -121,6 +124,15 @@ export function scheduleMatches(schedule, entries) { * build's segments: an override or a hide saved since the build moves them, * and the build's `posts` would show where they were. * + * So are the HOLDS (`posts.hold` on a clip that carries posts) and the + * footage's moves: a hold is part of its segment's length in the cut, so a + * post moved to another clip, a hide, a changed `posts.hold`, or a build that + * predates holds all move every later start. The build's probed length of a + * segment is its `duration` less the hold it was built with; each segment + * gains the difference between the hold it has now and that one, and every + * later start (and the total) moves by the sum before it. A build whose holds + * are still the manifest's is kept to the millisecond. + * * Without a build schedule the draft is applied to the entries and the whole * cut is estimated. * @@ -148,22 +160,34 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; const patchedEntries = entries.map(patched); - const { posts: _builtPosts, ...rest } = built; + const { posts: _builtPosts, moves: _builtMoves, ...rest } = built; + const round = (v) => Math.round(v * 1000) / 1000; + const holds = deck.posts.show ? postHolds({ posts, entries: patchedEntries, metas, render }) : new Map(); + let shift = 0; + const segments = built.segments.map((s) => { + const hold = holds.get(s.id) ?? 0; + const delta = hold - (s.hold ?? 0); + const start = s.start + shift; + const duration = s.duration + delta; + shift += delta; + const { hold: _h, ...bare } = s; + return { + ...bare, + start: round(start), + duration: round(duration), + end: round(start + duration), + ...(hold > 0 ? { hold: round(hold) } : {}), + }; + }); + const total = round(built.total + shift); const placed = deck.posts.show - ? postSchedule({ - posts, - entries: patchedEntries, - metas, - segments: built.segments, - D: built.transition, - total: built.total, - render, - }) + ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render }) : []; - const round = (v) => Math.round(v * 1000) / 1000; + const moves = placed.length ? footageMoves({ posts: placed, segments, render }) : []; return { ...rest, - segments: built.segments.map((s, i) => { + total, + segments: segments.map((s, i) => { const e = patchedEntries[i]; const meta = metas[i] ?? null; const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel); @@ -171,6 +195,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), + ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } return estimateSchedule({ ...variantManifest, posts, timeline: entries.map(patched) }, { metas }); @@ -430,7 +455,7 @@ export async function deckStill(project, variant, schedule, t) { const want = deckPreviewDir(project.dir, variant); const stills = path.join(outDir, "chrome", "deck-stills"); return serialised(want, async () => { - await mkdir(stills, { recursive: true }); + await ensureWriteDir(stills); // through ensureOutDir: out/ may be a link to the media root const png = path.join(stills, `still-${process.pid}-${Math.random().toString(36).slice(2, 8)}.png`); try { /** @type {Record<string, unknown>} */ diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -24,7 +24,7 @@ const cut = () => ({ slug: "t", variant: "sourced", provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, - render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck" } }, + render: { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: { posts: { seconds: 2, hold: 0, shift: false } } } }, timeline: [ { type: "card", id: "k1", heading: "Opening", sub: "a card", seconds: 5 }, { type: "clip", id: "c01", video: "v1", start: 10, end: 20 }, @@ -162,6 +162,55 @@ test("no build schedule: the estimate places the posts with the draft applied", assert.deepEqual(s.posts.map((p) => [p.id, p.segment]), [["p1", "c01"], ["p2", "c02"]]); }); +/** `cut()` with posts and the room-for-posts defaults (4 s, hold 2.5, shift on) instead of the old ones. */ +const roomy = (posts = POSTS) => { + const m = withPosts(posts); + m.render.chrome.deck = {}; + return m; +}; + +test("a build that predates holds: each carrying clip gains the hold, every later start moves, and the moves follow", () => { + // built(): k1 0+5, c01 4.5+9.9, c02 13.9+12 = 25.9. p3 and p1 ride on c01, p2 on c02. + const s = previewSchedule({ variantManifest: roomy(), built: built(), draft: new Map(), metas }); + const by = Object.fromEntries(s.segments.map((x) => [x.id, x])); + assert.ok(!("hold" in by.k1)); + assert.deepEqual([by.k1.start, by.k1.duration, by.k1.end], [0, 5, 5]); + assert.deepEqual([by.c01.start, by.c01.duration, by.c01.end, by.c01.hold], [4.5, 12.4, 16.9, 2.5]); + assert.deepEqual([by.c02.start, by.c02.duration, by.c02.end, by.c02.hold], [16.4, 14.5, 30.9, 2.5]); + assert.equal(s.total, 30.9); + // The posts are measured with the holds: c01's leave is c02's (held) start. + const p = Object.fromEntries(s.posts.map((x) => [x.id, x])); + assert.deepEqual(p.p1.out, [16.4, 16.9]); + assert.equal(p.p1.appear, 12.4); + assert.equal(p.p3.appear, 8.4); + assert.deepEqual(p.p2.out, [30.6, 30.9]); + // One move per carrying clip, at its first post, from the box to the shifted box. + assert.deepEqual(s.moves.map((m) => [m.segment, m.at, m.segmentAt, m.seconds]), [["c01", 8.4, 3.9, 0.6], ["c02", 26.6, 10.2, 0.6]]); + assert.deepEqual(s.moves[0].to, { x: 24, y: 64, width: 1354, height: 762 }); + + // A build WITH those holds is kept to the millisecond: the same schedule back. + const again = previewSchedule({ variantManifest: roomy(), built: s, draft: new Map(), metas }); + assert.deepEqual(again.segments.map((x) => [x.id, x.start, x.duration, x.hold]), s.segments.map((x) => [x.id, x.start, x.duration, x.hold])); + assert.equal(again.total, s.total); + + // Hiding c01's posts takes its hold away again, and the later starts come back. + const hidden = previewSchedule({ + variantManifest: roomy(), built: s, draft: new Map(), metas, + postsDraft: { p1: { hide: true }, p3: { hide: true } }, + }); + const h = Object.fromEntries(hidden.segments.map((x) => [x.id, x])); + assert.ok(!("hold" in h.c01)); + assert.deepEqual([h.c01.duration, h.c02.start, hidden.total], [9.9, 13.9, 28.4]); + assert.deepEqual(hidden.moves.map((m) => m.segment), ["c02"]); + + // shift off: the holds, and no moves. + const fixed = roomy(); + fixed.render.chrome.deck = { posts: { shift: false } }; + const off = previewSchedule({ variantManifest: fixed, built: built(), draft: new Map(), metas }); + assert.equal(off.total, 30.9); + assert.ok(!("moves" in off)); +}); + test("applyPostsDraft: the writer's rule, on a copy", () => { const posts = withPosts().posts; posts[0].hide = true; diff --git a/umtool/lib/report/playback.mjs b/umtool/lib/report/playback.mjs @@ -0,0 +1,234 @@ +// The clip bench's bounded playback, as arithmetic. +// +// The bench used to play a range on the <video> element and stop it from +// `timeupdate`, which fires every 15–250 ms: what you heard ran past the end +// by up to a quarter of a second, and by a different amount every time. That +// is the difference between a cut that lands between two words and one that +// swallows the next syllable, and it could not be judged by ear. +// +// So a bounded range plays from the DECODED audio with Web Audio, the old +// um-triage chooser's technique: an AudioBufferSourceNode started at an exact +// buffer offset and stopped at an exact context time, sample-accurately. The +// picture follows along, muted. These are the numbers that decide where in the +// buffer to start, when to stop, where the playhead is, and when the finale's +// mute mark goes quiet. Pure: no DOM, no Web Audio, so they are unit-tested and +// the component only applies them. +// +// EVERYTHING IS IN ABSOLUTE SOURCE SECONDS except where a name says `wall` +// (seconds of the listener's time, which is source time divided by the rate) +// or `ctx` (the AudioContext's clock). + +/** Decoding is per window, but never more than this many seconds at once. */ +export const MAX_DECODE_SPAN = 120; + +/** + * Around the selection, when a window is too long to decode whole (a whole + * recording fetched into the saved-video store): enough to drag an edge and + * hear it without a second decode. + */ +export const DECODE_MARGIN = 30; + +/** + * How far ahead of "now" a playback is scheduled, in wall seconds. + * + * A start scheduled in the past is started late at the SAME offset, so every + * time computed from it would be early by however late it was. Scheduling a + * little ahead keeps `start` and `stop` on the clock they were computed on. + */ +export const START_LEAD = 0.03; + +/** + * The mute mark's fade, in source seconds: the BUILD's, so what the bench plays + * is what the cut does -- a ramp that ENDS at the mark, silent from the mark on. + * From `mute.mjs`, not `deck.mjs`: this module is in the bench's client bundle, + * and deck.mjs would bring node:crypto and attribution with it. + */ +export { MUTE_FADE } from "umtool-report-to-video/mute"; +import { MUTE_FADE } from "umtool-report-to-video/mute"; + +/** One Web Audio render quantum, in frames. */ +export const RENDER_QUANTUM = 128; + +const round3 = (n) => Math.round(n * 1000) / 1000; + +/** + * Which span of a cached window to decode. + * + * The whole window when it is short enough -- one decode then serves every + * drag. A longer one decodes the selection plus a margin each side, clamped to + * the window, and capped: a decode is held in memory as 32-bit floats, and two + * minutes of stereo is already ~46 MB. + * + * @param {{from: number, to: number}} win the cached file's span + * @param {{from: number, to: number}} want the range about to be played + * @param {{max?: number, margin?: number}} [opts] + * @returns {{from: number, to: number}} + */ +export function decodeSpan(win, want, { max = MAX_DECODE_SPAN, margin = DECODE_MARGIN } = {}) { + if (win.to - win.from <= max) return { from: win.from, to: win.to }; + let from = Math.max(win.from, want.from - margin); + let to = Math.min(win.to, Math.max(want.to, want.from) + margin); + if (to - from > max) { + // A selection wider than the cap: from its start, as much as fits. + from = Math.max(win.from, Math.min(want.from, win.to - max)); + to = Math.min(win.to, from + max); + } + return { from: round3(from), to: round3(to) }; +} + +/** + * Does a decoded span hold this whole range? A hair of tolerance, because the + * decoded span's end is a frame count divided by a rate. + * + * @param {{from: number, to: number} | null} span + * @param {number} from + * @param {number} to + */ +export function covers(span, from, to) { + if (!span) return false; + return from >= span.from - 0.005 && to <= span.to + 0.005; +} + +/** + * Where in the buffer to start, how much source to play, and how long that + * takes at this rate. + * + * `duration` is SOURCE seconds and `wall` is how long it lasts at `rate`. The + * stop is scheduled at `start + wall` on the context's clock rather than + * passed to `start()` as a duration: the spec reads that argument as buffer + * content, but a stop time on the context clock means one thing at any rate. + * + * @param {{from: number, to: number}} span the decoded buffer's absolute span + * @param {number} bufferSeconds the buffer's own duration + * @param {number} from + * @param {number} to + * @param {number} [rate] + * @returns {{offset: number, duration: number, wall: number} | null} null when + * nothing of the range is in the buffer + */ +export function bufferSchedule(span, bufferSeconds, from, to, rate = 1) { + const r = rate > 0 ? rate : 1; + const offset = Math.max(0, Math.min(bufferSeconds, from - span.from)); + const duration = Math.min(bufferSeconds - offset, to - Math.max(from, span.from)); + if (!(duration > 0)) return null; + return { offset, duration, wall: duration / r }; +} + +/** + * The playhead, in source seconds, from the audio clock. + * + * @param {number} from where the playback started, in source seconds + * @param {number} t0 the context time it started at + * @param {number} now the context time now + * @param {number} rate + * @param {number} duration source seconds the playback lasts + */ +export function playheadAt(from, t0, now, rate, duration) { + const r = rate > 0 ? rate : 1; + const played = Math.max(0, Math.min(duration, (now - t0) * r)); + return from + played; +} + +/** + * When the mute mark silences a playback of [from, to], as offsets in WALL + * seconds from its start. + * + * null the mark is not in this range (or there is none) + * { at: 0, fade: 0 } the range starts at or after the mark: silent + * from the first sample, the picture still plays + * { at, fade } full level until `at`, then a linear ramp to + * nothing over `fade`, reaching it AT the mark + * (as the build's afade does); a range that starts + * inside the fade ramps from its first sample + * + * @param {number | null | undefined} muteFrom + * @param {number} from + * @param {number} to + * @param {number} [rate] + * @param {number} [fade] source seconds + * @returns {{at: number, fade: number} | null} + */ +export function muteRamp(muteFrom, from, to, rate = 1, fade = MUTE_FADE) { + if (muteFrom == null || !Number.isFinite(muteFrom)) return null; + const r = rate > 0 ? rate : 1; + if (muteFrom >= to) return null; + if (muteFrom <= from) return { at: 0, fade: 0 }; + const st = Math.max(from, muteFrom - fade); + return { at: (st - from) / r, fade: (muteFrom - st) / r }; +} + +/** + * The element fallback's stop test, run once per animation frame. + * + * Stopping at the first frame PAST the end overruns by up to a frame (and the + * element's own clock reports late on top of that). Stopping as soon as the + * end is less than half a frame away splits the error both ways instead: never + * more than half a frame early or late, at any rate. + * + * @param {number} t the element's position, in source seconds + * @param {number} stopAt + * @param {number} [rate] + * @param {number} [frame] wall seconds per animation frame + */ +export function elementShouldStop(t, stopAt, rate = 1, frame = 1 / 60) { + const r = rate > 0 ? rate : 1; + return t + (frame * r) / 2 >= stopAt; +} + +/** + * A mute mark as the WRITER reads it: a number of source seconds inside the + * clip's extent, rounded like an edge -- or `null` to delete the key. + * + * `null` and `""` mean "no mark". Anything else must be a finite number in + * [start, end] (a hair of tolerance, the same 0.02 the cut's check allows). + * + * @param {unknown} raw + * @param {number} start + * @param {number} end + * @returns {number | null} + */ +export function parseMuteFrom(raw, start, end) { + if (raw === null || raw === "") return null; + const v = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw.trim()) : Number.NaN; + if (!Number.isFinite(v)) { + throw new Error(`muteFrom must be a number of source seconds, or empty to clear it (got \`${String(raw)}\`)`); + } + if (v < start - 0.02 || v > end + 0.02) { + throw new Error( + `muteFrom ${v} must lie inside the clip ${start}–${end} — the mark mutes from there to the clip's end`, + ); + } + return Math.round(Math.min(end, Math.max(start, v)) * 100) / 100; +} + +/** + * A RIFF/WAVE header for interleaved 16-bit PCM. + * + * Written by hand because ffmpeg writing WAV to a pipe cannot seek back to fill + * in the sizes, and a header that says "unknown length" is one more thing a + * decoder may or may not forgive. + * + * @param {{channels: number, sampleRate: number, dataBytes: number}} f + * @returns {Uint8Array} 44 bytes + */ +export function wavHeader({ channels, sampleRate, dataBytes }) { + const b = new Uint8Array(44); + const v = new DataView(b.buffer); + const tag = (at, s) => { + for (let i = 0; i < 4; i += 1) b[at + i] = s.charCodeAt(i); + }; + tag(0, "RIFF"); + v.setUint32(4, 36 + dataBytes, true); + tag(8, "WAVE"); + tag(12, "fmt "); + v.setUint32(16, 16, true); + v.setUint16(20, 1, true); // PCM + v.setUint16(22, channels, true); + v.setUint32(24, sampleRate, true); + v.setUint32(28, sampleRate * channels * 2, true); + v.setUint16(32, channels * 2, true); + v.setUint16(34, 16, true); + tag(36, "data"); + v.setUint32(40, dataBytes, true); + return b; +} diff --git a/umtool/lib/report/playback.test.mjs b/umtool/lib/report/playback.test.mjs @@ -0,0 +1,158 @@ +// The clip bench's bounded playback, as arithmetic. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MAX_DECODE_SPAN, + MUTE_FADE, + bufferSchedule, + covers, + decodeSpan, + elementShouldStop, + muteRamp, + parseMuteFrom, + playheadAt, + wavHeader, +} from "./playback.mjs"; + +const near = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) <= eps, `${a} ≉ ${b}`); + +test("decodeSpan: a short window decodes whole, whatever is being played", () => { + assert.deepEqual(decodeSpan({ from: 24019.6, to: 24032.6 }, { from: 24022.6, to: 24029.6 }), { + from: 24019.6, + to: 24032.6, + }); + // Exactly at the cap is still whole. + assert.deepEqual(decodeSpan({ from: 0, to: MAX_DECODE_SPAN }, { from: 5, to: 6 }), { + from: 0, + to: MAX_DECODE_SPAN, + }); +}); + +test("decodeSpan: a long window decodes the selection plus a margin, clamped and capped", () => { + const whole = { from: 0, to: 7200 }; + assert.deepEqual(decodeSpan(whole, { from: 3600, to: 3610 }), { from: 3570, to: 3640 }); + // Clamped at the recording's start. + assert.deepEqual(decodeSpan(whole, { from: 10, to: 20 }), { from: 0, to: 50 }); + // Clamped at its end. + assert.deepEqual(decodeSpan(whole, { from: 7190, to: 7200 }), { from: 7160, to: 7200 }); + // A selection wider than the cap: from its start, as much as fits. + const wide = decodeSpan(whole, { from: 1000, to: 1300 }); + assert.deepEqual(wide, { from: 1000, to: 1000 + MAX_DECODE_SPAN }); + // ...and never past the window's end. + assert.deepEqual(decodeSpan(whole, { from: 7150, to: 7400 }), { from: 7120, to: 7200 }); + assert.deepEqual(decodeSpan(whole, { from: 7000, to: 7400 }), { from: 7000, to: 7000 + MAX_DECODE_SPAN }); +}); + +test("covers: the decoded span holds the whole range, with a hair of tolerance", () => { + const span = { from: 10, to: 20 }; + assert.equal(covers(span, 10, 20), true); + assert.equal(covers(span, 12, 19.999), true); + assert.equal(covers(span, 9.9, 15), false); + assert.equal(covers(span, 15, 20.1), false); + assert.equal(covers(null, 1, 2), false); +}); + +test("bufferSchedule: offset into the buffer, source seconds to play, and how long that takes", () => { + const span = { from: 24019.6, to: 24032.6 }; + const s = bufferSchedule(span, 13, 24022.6, 24029.6, 1); + near(s.offset, 3, 1e-6); + near(s.duration, 7, 1e-6); + near(s.wall, 7, 1e-6); + // Speed changes how long it takes, not what is played. + const fast = bufferSchedule(span, 13, 24022.6, 24029.6, 1.5); + near(fast.offset, 3, 1e-6); + near(fast.duration, 7, 1e-6); + near(fast.wall, 7 / 1.5, 1e-6); + const slow = bufferSchedule(span, 13, 24022.6, 24029.6, 0.75); + near(slow.wall, 7 / 0.75, 1e-6); + // A range running past the buffer stops at the buffer. + near(bufferSchedule(span, 13, 24030, 24040, 1).duration, 2.6, 1e-6); + // Nothing of the range in the buffer. + assert.equal(bufferSchedule(span, 13, 24040, 24050, 1), null); + // A nonsense rate is read as 1, not as a division by zero. + near(bufferSchedule(span, 13, 24022.6, 24029.6, 0).wall, 7, 1e-6); +}); + +test("playheadAt: from the audio clock, in source seconds, held at the end", () => { + near(playheadAt(100, 5, 5, 1, 4), 100); + near(playheadAt(100, 5, 6, 1, 4), 101); + near(playheadAt(100, 5, 6, 2, 4), 102); + // Before the scheduled start (the lead): at the start, not before it. + near(playheadAt(100, 5, 4.98, 1, 4), 100); + // Past the end: held at the end. + near(playheadAt(100, 5, 60, 1, 4), 104); +}); + +test("muteRamp: when the mark silences a playback, in wall seconds from its start", () => { + assert.equal(muteRamp(null, 10, 20), null); + assert.equal(muteRamp(undefined, 10, 20), null); + // At or past the end of the range: nothing to mute in it. + assert.equal(muteRamp(20, 10, 20), null); + assert.equal(muteRamp(25, 10, 20), null); + // At or before the start: silent throughout, the picture still plays. + assert.deepEqual(muteRamp(10, 10, 20), { at: 0, fade: 0 }); + assert.deepEqual(muteRamp(5, 10, 20), { at: 0, fade: 0 }); + // Inside: full level until the fade, which ENDS at the mark (the build's). + assert.equal(MUTE_FADE, 0.04); + const m = muteRamp(16, 10, 20, 1); + near(m.at, 6 - MUTE_FADE); + near(m.fade, MUTE_FADE); + near(m.at + m.fade, 6); + // At 2x the mark arrives in half the time, and so does the fade. + const f = muteRamp(16, 10, 20, 2); + near(f.at, (6 - MUTE_FADE) / 2); + near(f.fade, MUTE_FADE / 2); + // A range starting inside the fade ramps from its first sample to the mark. + const g = muteRamp(16, 15.98, 20, 1); + near(g.at, 0); + near(g.fade, 0.02); +}); + +test("elementShouldStop: stops when the end is under half a frame away, at any rate", () => { + const frame = 1 / 60; + assert.equal(elementShouldStop(9.9, 10, 1, frame), false); + assert.equal(elementShouldStop(10 - frame / 2 - 0.001, 10, 1, frame), false); + assert.equal(elementShouldStop(10 - frame / 2 + 0.001, 10, 1, frame), true); + assert.equal(elementShouldStop(10.2, 10, 1, frame), true); + // At 2x a frame covers twice the source, so the stop comes a frame earlier. + assert.equal(elementShouldStop(10 - frame + 0.001, 10, 2, frame), true); +}); + +test("parseMuteFrom: a number inside the clip, rounded like an edge, or null to delete", () => { + assert.equal(parseMuteFrom(null, 10, 20), null); + assert.equal(parseMuteFrom("", 10, 20), null); + assert.equal(parseMuteFrom(15.456, 10, 20), 15.46); + assert.equal(parseMuteFrom(" 15.5 ", 10, 20), 15.5); + assert.equal(parseMuteFrom(10, 10, 20), 10); + assert.equal(parseMuteFrom(20, 10, 20), 20); + // The 0.02 tolerance an edge gets, clamped back inside. + assert.equal(parseMuteFrom(20.01, 10, 20), 20); + assert.equal(parseMuteFrom(9.99, 10, 20), 10); + assert.throws(() => parseMuteFrom(20.5, 10, 20), /must lie inside the clip 10–20/); + assert.throws(() => parseMuteFrom(9, 10, 20), /must lie inside/); + assert.throws(() => parseMuteFrom("soon", 10, 20), /must be a number/); + assert.throws(() => parseMuteFrom(true, 10, 20), /must be a number/); + assert.throws(() => parseMuteFrom(Number.NaN, 10, 20), /must be a number/); +}); + +test("wavHeader: 44 bytes of RIFF/WAVE for interleaved 16-bit PCM", () => { + const h = wavHeader({ channels: 2, sampleRate: 48000, dataBytes: 192000 }); + assert.equal(h.length, 44); + const v = new DataView(h.buffer); + const tag = (at) => String.fromCharCode(...h.slice(at, at + 4)); + assert.equal(tag(0), "RIFF"); + assert.equal(v.getUint32(4, true), 36 + 192000); + assert.equal(tag(8), "WAVE"); + assert.equal(tag(12), "fmt "); + assert.equal(v.getUint16(20, true), 1); + assert.equal(v.getUint16(22, true), 2); + assert.equal(v.getUint32(24, true), 48000); + assert.equal(v.getUint32(28, true), 48000 * 4); + assert.equal(v.getUint16(32, true), 4); + assert.equal(v.getUint16(34, true), 16); + assert.equal(tag(36), "data"); + assert.equal(v.getUint32(40, true), 192000); +}); diff --git a/umtool/lib/report/storage.mjs b/umtool/lib/report/storage.mjs @@ -0,0 +1,569 @@ +// Where a project's bulk lives: its render scratch (`out/`) on a media root, the +// manifest and everything small beside it (release 17). +// +// lib/paths.mjs names the roots: REPORTS_ROOT holds every project; MEDIA_ROOT +// (UMTOOL_MEDIA_DIR) holds the bulk; MEDIA_TIERED says they differ. A tiered +// project's `out` is an ABSOLUTE SYMLINK to the same project-relative path +// under MEDIA_ROOT, so every reader keeps opening `<project>/out/...` by path +// and none of them knows the media drive exists. +// +// ensureOutDir the first writer's call: makes `out/` -- a link when +// tiered, a directory when not -- and refuses loudly on a +// dangling link instead of building a new tree beside it +// moveDirToMedia an existing directory of the project to MEDIA_ROOT: +// copy, mirror, verify, park, link, delete the parked copy +// moveDirToLocal the reverse: back to a real directory in the project +// +// The movers take a NAME, not "out": `umtool storage move-out` moves `out/` +// with them, and a project's deliverables (`clips/`, `share-*/`) are moved by +// the same two calls behind the deliverables switch. +// +// Modelled on the editor's common/controller/relocateDir.ts, not imported from +// it (umtool's scripts are plain .mjs under node, and that is a TypeScript +// controller): the source is never touched until the copy verifies; `--delete` +// only ever points at the copy under construction; every step past the copy is +// dispatched on what is ON DISK, so a run cut at any point is finished by +// running it again. +// +// THE MEDIA ROOT IS NEVER CREATED HERE. A media drive that is not mounted +// leaves its mountpoint as an empty directory or no directory at all, and a +// recursive mkdir would build the tree on the root filesystem and fill it. So +// the root is stat'd first and must already be a directory; everything BELOW +// it may be made with a recursive mkdir. +// +// Every path and fs call carries `turbopackIgnore`: lib/report/onscreen.mjs +// imports this module, and the app's routes import that (plans/FACTS.md, "A +// path joined from `process.cwd()` …"). +import { spawn } from "node:child_process"; +import { lstat, mkdir, readdir, readlink, rename, rm, rmdir, stat, statfs, symlink, unlink } from "node:fs/promises"; +import path from "node:path"; +import { MEDIA_ROOT, REPORTS_ROOT, inside, mediaMirror } from "../paths.mjs"; + +/** The free space a move keeps on the volume it copies to, beyond the copy. */ +export const MOVE_MARGIN_BYTES = 1024 ** 3; + +/** The project directories the movers may move. One path segment, never a dot name. */ +const assertName = (name) => { + if (typeof name !== "string" || !name || name.startsWith(".") || /[\/\\\0]/.test(name)) { + throw new Error(`not a project directory name: ${JSON.stringify(name)}`); + } +}; + +/** The roots a call works against: the process's, unless a test names its own. */ +function rootsOf(opts = {}) { + const reportsRoot = path.resolve(/* turbopackIgnore: true */ opts.reportsRoot ?? REPORTS_ROOT); + const mediaRoot = path.resolve(/* turbopackIgnore: true */ opts.mediaRoot ?? MEDIA_ROOT); + return { reportsRoot, mediaRoot, tiered: mediaRoot !== reportsRoot }; +} + +/** + * What a path is RIGHT NOW. lstat, never stat: a dangling link -- the state an + * unmounted media drive leaves behind -- reads as absent through stat. + * @returns {Promise<{ kind: "missing" } | { kind: "dir" } | { kind: "link", target: string, targetIsDir: boolean } | { kind: "other" }>} + */ +export async function pathState(p) { + let st; + try { + st = await lstat(/* turbopackIgnore: true */ p); + } catch { + return { kind: "missing" }; + } + if (st.isSymbolicLink()) { + const raw = await readlink(/* turbopackIgnore: true */ p).catch(() => ""); + const target = path.resolve(/* turbopackIgnore: true */ path.dirname(/* turbopackIgnore: true */ p), raw); + const targetIsDir = await stat(/* turbopackIgnore: true */ p).then((s) => s.isDirectory(), () => false); + return { kind: "link", target, targetIsDir }; + } + if (st.isDirectory()) return { kind: "dir" }; + return { kind: "other" }; +} + +/** + * Why render scratch cannot go to the media root now, or null when it can (or + * when nothing is tiered). The root must already exist as a directory -- it is + * never created -- and must neither sit inside REPORTS_ROOT nor contain it (a + * mirror inside the tree it mirrors would be walked as projects). + */ +export async function mediaRootProblem(opts = {}) { + const { reportsRoot, mediaRoot, tiered } = rootsOf(opts); + if (!tiered) return null; + if (inside(reportsRoot, mediaRoot) || inside(mediaRoot, reportsRoot)) { + return `the media root ${mediaRoot} (UMTOOL_MEDIA_DIR) must be outside the reports root ${reportsRoot}, and must not contain it`; + } + const st = await stat(/* turbopackIgnore: true */ mediaRoot).catch(() => null); + if (!st) { + return `the media root ${mediaRoot} (UMTOOL_MEDIA_DIR) is not there — is its drive mounted? Nothing was created.`; + } + if (!st.isDirectory()) return `the media root ${mediaRoot} (UMTOOL_MEDIA_DIR) is not a directory`; + return null; +} + +/** + * The state of a project's `out`, for a reader that wants to say WHY a build's + * files are not there rather than "no build": `dangling` is a link whose target + * is gone (the media drive is not mounted). + * @returns {Promise<{ state: "absent" | "dir" | "link" | "dangling" | "other", target?: string }>} + */ +export async function outDirState(projectDir) { + const s = await pathState(path.join(/* turbopackIgnore: true */ projectDir, "out")); + if (s.kind === "missing") return { state: "absent" }; + if (s.kind === "link") return { state: s.targetIsDir ? "link" : "dangling", target: s.target }; + return { state: s.kind }; +} + +/** + * Make sure `<projectDir>/out` exists, and return its path. + * + * a directory -> it, as it is (an untiered project, or one not moved yet) + * a link to a directory -> it + * a DANGLING link -> throws: the media drive is not there, and writing + * through the link would fail anyway -- but a + * recursive mkdir under it would fail with a + * message about some subdirectory, so this says + * what is actually wrong. Nothing is created. + * absent, tiered -> `<MEDIA_ROOT>/<project-relative>/out`, then the link + * absent, not tiered -> a directory, as every writer always made it + * + * A project outside REPORTS_ROOT (a hand-run script's `--out` somewhere else) + * is never tiered. + */ +export async function ensureOutDir(projectDir, opts = {}) { + const out = path.join(/* turbopackIgnore: true */ projectDir, "out"); + const s = await pathState(out); + if (s.kind === "dir") return out; + if (s.kind === "link") { + if (s.targetIsDir) return out; + throw new Error( + `${out} is a link to ${s.target}, which is not there — is the media drive mounted? ` + + `Nothing was written, and nothing was created in its place.`, + ); + } + if (s.kind === "other") throw new Error(`${out} exists and is not a directory`); + + // A cut move leaves `out.moved-<ts>` or `out.incoming` beside a missing + // `out`. Making a fresh, empty out/ there would let the next move-out mirror + // it over the complete media copy (review L5): refuse, and say how to finish. + await assertNoLeftovers(projectDir, "out", "nothing was created"); + + const roots = rootsOf(opts); + const mirror = roots.tiered ? mediaMirror(projectDir, roots) : null; + if (!mirror) { + await mkdir(/* turbopackIgnore: true */ out, { recursive: true }); + return out; + } + const problem = await mediaRootProblem(roots); + if (problem) throw new Error(`cannot make ${out}: ${problem}`); + // The project must exist before its mirror is made: otherwise the symlink + // fails and leaves an empty mirror on the media root (review N5). + // stat, not lstat: a project directory may itself be a link (the walk follows them). + if (!(await stat(/* turbopackIgnore: true */ projectDir).then((st) => st.isDirectory(), () => false))) { + throw new Error(`cannot make ${out}: ${projectDir} is not a directory`); + } + const target = path.join(/* turbopackIgnore: true */ mirror, "out"); + // Recursive is safe here: the root itself was just seen to exist. + await mkdir(/* turbopackIgnore: true */ target, { recursive: true }); + try { + await symlink(/* turbopackIgnore: true */ target, out, "dir"); + } catch (e) { + // Two writers starting at once: whichever linked first won, and a link (or + // directory) that now resolves is as good as ours. + if (e?.code !== "EEXIST") throw e; + const again = await pathState(out); + if (again.kind === "dir" || (again.kind === "link" && again.targetIsDir)) return out; + throw e; + } + return out; +} + +/** + * Make a directory a pipeline step writes into, and return it. + * + * When it is a project's `out` or lies under one (`out/<variant>`, + * `out/<variant>/chrome/deck-stills`), that `out` is made by ensureOutDir FIRST + * -- a link when tiered -- and only then is the rest made under it. A plain + * recursive mkdir of `out/<variant>/segments` was how every script made `out/`, + * and it would make a real directory where the link belongs. Any other + * directory (a hand-run script's `--out` somewhere else) is made as it always + * was. + */ +export async function ensureWriteDir(dir) { + let d = path.resolve(/* turbopackIgnore: true */ dir); + for (let i = 0; i < 4; i++) { + if (path.basename(/* turbopackIgnore: true */ d) === "out") { + await ensureOutDir(path.dirname(/* turbopackIgnore: true */ d)); + break; + } + const up = path.dirname(/* turbopackIgnore: true */ d); + if (up === d) break; + d = up; + } + await mkdir(/* turbopackIgnore: true */ dir, { recursive: true }); + return dir; +} + +// --------------------------------------------------------------------------- +// Measuring +// --------------------------------------------------------------------------- + +/** Files and bytes under a directory. Follows no symlink. */ +export async function measureTree(dir) { + let bytes = 0; + let files = 0; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop(); + let entries; + try { + entries = await readdir(/* turbopackIgnore: true */ cur, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const p = path.join(/* turbopackIgnore: true */ cur, e.name); + if (e.isDirectory()) stack.push(p); + else if (e.isFile()) { + const st = await stat(/* turbopackIgnore: true */ p).catch(() => null); + if (st) { + bytes += st.size; + files += 1; + } + } + } + } + return { bytes, files }; +} + +async function freeBytes(dir) { + const s = await statfs(/* turbopackIgnore: true */ dir).catch(() => null); + return s ? Number(s.bavail) * Number(s.bsize) : null; +} + +async function assertRoom(dir, bytes) { + const free = await freeBytes(dir); + if (free !== null && free < bytes + MOVE_MARGIN_BYTES) { + throw new Error( + `not enough room on ${dir}: ${gb(free)} free, the copy needs ${gb(bytes)} plus ${gb(MOVE_MARGIN_BYTES)} to spare. Nothing moved.`, + ); + } +} + +const gb = (n) => `${(n / 1024 ** 3).toFixed(2)} GB`; + +// --------------------------------------------------------------------------- +// rsync +// --------------------------------------------------------------------------- + +/** `rsync <args> <src>/ <dest>/` -- the trailing slashes copy the CONTENTS. */ +function rsync(bin, args, src, dest, log) { + return new Promise((resolve, reject) => { + const argv = [...args, `${src}/`, `${dest}/`]; + log(`$ ${bin} ${argv.join(" ")}`); + const child = spawn(bin, argv, { stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.on("data", (c) => (output += c)); + child.stderr.on("data", (c) => (output += c)); + child.on("error", reject); + child.on("close", (code) => resolve({ code: code ?? 1, output })); + }); +} + +// What a dry run itemizes that is not a difference: rsync's own chatter. +const driftLines = (output) => + output + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("sending incremental") && !/^(sent|total size)/.test(l)); + +/** + * COPY, MIRROR, VERIFY -- the source is not touched by any of it. + * + * 1. `rsync -a --partial`: the bytes, resumable. + * 2. `rsync -a --delete --info=del` toward the COPY: what changed on the + * source meanwhile is re-sent, what it no longer has leaves the copy + * (each removal in the log). Never pointed the other way: the copy is + * checked to be neither the source nor inside it, nor around it. + * 3. `rsync -a --dry-run --itemize-changes --delete` must list nothing, and + * the two trees must measure the same. One more mirror pass if the source + * moved under the check; a second difference refuses. + */ +async function copyMirrorVerify(src, dest, { rsyncBin, log }) { + if (inside(src, dest) || inside(dest, src)) { + throw new Error(`refusing to copy ${src} into ${dest}: one is inside the other. Nothing moved.`); + } + const copied = await rsync(rsyncBin, ["-a", "--partial"], src, dest, log); + if (copied.code !== 0) throw new Error(`rsync failed (exit ${copied.code}): ${copied.output.trim().split("\n").pop() ?? ""}`); + for (let pass = 0; ; pass++) { + const mirrored = await rsync(rsyncBin, ["-a", "--delete", "--info=del"], src, dest, log); + if (mirrored.output.trim()) log(mirrored.output.trim()); + if (mirrored.code !== 0) throw new Error(`the mirror pass failed (exit ${mirrored.code}). The source has NOT been touched.`); + const check = await rsync(rsyncBin, ["-a", "--dry-run", "--itemize-changes", "--delete"], src, dest, () => {}); + if (check.code !== 0) throw new Error(`the verify failed (exit ${check.code}). The source has NOT been touched.`); + const drift = driftLines(check.output); + if (!drift.length) break; + if (pass >= 1) { + throw new Error( + `the copy still differs from the source after a second mirror pass (${drift.length} item(s), first: ${drift[0]}) — ` + + `something is still writing into ${src}. Stop it and run the move again. The source has NOT been touched.`, + ); + } + log(`the source changed during the copy (${drift.length} item(s)) — one more mirror pass`); + } + const [a, b] = await Promise.all([measureTree(src), measureTree(dest)]); + if (a.files !== b.files || a.bytes !== b.bytes) { + throw new Error( + `the copy does not measure the same: ${a.files} file(s)/${a.bytes} B against ${b.files}/${b.bytes} B. The source has NOT been touched.`, + ); + } + return a; +} + +// --------------------------------------------------------------------------- +// The movers +// --------------------------------------------------------------------------- + +const stamp = () => new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z"); + +/** `<name>.moved-<stamp>` siblings: a move-out parked them and was cut before deleting. */ +async function parkedOf(projectDir, name) { + const names = await readdir(/* turbopackIgnore: true */ projectDir).catch(() => []); + return names + .filter((n) => n.startsWith(`${name}.moved-`)) + .sort() + .map((n) => path.join(/* turbopackIgnore: true */ projectDir, n)); +} + +/** `<name>.incoming`, when a move-back left it (a cut between its copy and its rename). */ +async function incomingOf(projectDir, name) { + const p = path.join(/* turbopackIgnore: true */ projectDir, `${name}.incoming`); + return (await pathState(p)).kind === "dir" ? p : null; +} + +/** Every leftover of a cut move of `<name>`: parked copies and an incoming copy. */ +export async function leftoversOf(projectDir, name) { + const incoming = await incomingOf(projectDir, name); + return [...(await parkedOf(projectDir, name)), ...(incoming ? [incoming] : [])]; +} + +/** + * Refuse while a cut move of `<name>` has left something behind. A writer that + * made a fresh `<name>/` there, and a move that then mirrored it over the + * complete copy, would lose everything but the leftover nobody names. + */ +async function assertNoLeftovers(projectDir, name, what, { beside = false } = {}) { + const left = await leftoversOf(projectDir, name); + if (!left.length) return; + const names = left.map((p) => path.basename(/* turbopackIgnore: true */ p)).join(", "); + if (beside) { + // A real `<name>/` AND a leftover: running a move again would only refuse + // again (re-review R1). Only a person can say which one to keep. + throw new Error( + `${name}/ and ${names} both exist in ${projectDir}: a move of ${name}/ was cut, and something made a fresh ${name}/ since. ` + + `The leftover holds the moved data. Keep one and remove the other by hand, then run the move; ${what}`, + ); + } + throw new Error( + `a move of ${name}/ in ${projectDir} was cut (left: ${left.map((p) => path.basename(/* turbopackIgnore: true */ p)).join(", ")}) — ` + + `run \`umtool storage ${left.some((p) => p.endsWith(".incoming")) ? "move-back" : "move-out"} <project>\` to finish it; ${what}`, + ); +} + +const defaults = (opts) => ({ + rsyncBin: opts.rsyncBin ?? process.env.RSYNC_BIN ?? "rsync", + log: opts.log ?? (() => {}), + dryRun: !!opts.dryRun, +}); + +/** + * Move `<projectDir>/<name>` to the same project-relative path under the media + * root and leave an absolute link in its place. + * + * Dispatched on the disk, so running it again finishes a run that was cut: + * + * a link to the mirror -> "already" (and a parked copy left by a cut + * between the link and its deletion is removed) + * a link anywhere else -> refused; it is not this media root's + * absent, a parked copy -> the cut was between the park and the link + * (the copy had verified): link, delete the parked copy + * absent, nothing parked -> "absent", nothing to move + * a directory -> copy, mirror, verify, rename it to + * `<name>.moved-<stamp>`, link, delete the parked copy + * a directory + a leftover -> refused: a parked copy or `<name>.incoming` + * beside a real directory means a writer made a + * fresh one after a cut move (review L5) + * absent + `<name>.incoming` -> refused: a cut move-back is finished by move-back + * + * `dryRun` measures and changes nothing. Nothing in the project may be writing + * into `<name>` while it runs (the app's jobs are in its own memory, so a CLI + * caller cannot see them); the verify refuses if the tree keeps changing. + * + * @param {string} projectDir + * @param {string} name one path segment: "out", "clips", "share-<x>" + * @param {{ dryRun?: boolean, log?: (m: string) => void, rsyncBin?: string, reportsRoot?: string, mediaRoot?: string }} [opts] + * @returns {Promise<{ state: "moved" | "already" | "absent" | "would-move" | "would-finish", src: string, dest: string, bytes?: number, files?: number, resumed?: boolean }>} + */ +export async function moveDirToMedia(projectDir, name, opts = {}) { + assertName(name); + const { rsyncBin, log, dryRun } = defaults(opts); + const roots = rootsOf(opts); + if (!roots.tiered) { + throw new Error(`UMTOOL_MEDIA_DIR is not set: there is no media root to move ${name}/ to`); + } + const mirror = mediaMirror(projectDir, roots); + if (!mirror) throw new Error(`${projectDir} is not under the reports root ${roots.reportsRoot}`); + const src = path.join(/* turbopackIgnore: true */ projectDir, name); + const dest = path.join(/* turbopackIgnore: true */ mirror, name); + const s = await pathState(src); + + if (s.kind === "link") { + if (s.target !== dest) { + throw new Error(`${src} is already a link, to ${s.target} — not to ${dest}. Nothing moved.`); + } + const parked = await parkedOf(projectDir, name); + if (!dryRun && s.targetIsDir) { + for (const p of parked) await rm(/* turbopackIgnore: true */ p, { recursive: true, force: true }); + } + return { state: "already", src, dest }; + } + if (s.kind === "other") throw new Error(`${src} is not a directory. Nothing moved.`); + + if (s.kind === "missing") { + const parked = await parkedOf(projectDir, name); + if (!parked.length) { + // A cut move-BACK is finished by move-back, never overtaken by a move-out. + if (await incomingOf(projectDir, name)) await assertNoLeftovers(projectDir, name, "nothing moved"); + return { state: "absent", src, dest }; + } + if (parked.length > 1) { + throw new Error(`${src} is missing and there are ${parked.length} parked copies (${parked.join(", ")}) — settle them by hand`); + } + // A parked copy exists only after the copy verified, so the media side is + // complete -- provided it is reachable. + if ((await pathState(dest)).kind !== "dir") { + throw new Error(`${src} was parked at ${parked[0]}, but ${dest} is not there — is the media drive mounted? Nothing changed.`); + } + if (dryRun) return { state: "would-finish", src, dest }; + log(`finishing a cut move: linking ${src} -> ${dest}`); + await symlink(/* turbopackIgnore: true */ dest, src, "dir"); + await rm(/* turbopackIgnore: true */ parked[0], { recursive: true, force: true }); + return { state: "moved", src, dest, resumed: true }; + } + + // A real directory: the move itself -- unless a cut move left something + // behind, in which case this directory is a writer's fresh one. + await assertNoLeftovers(projectDir, name, "nothing moved", { beside: true }); + const problem = await mediaRootProblem(roots); + if (problem) throw new Error(`cannot move ${src}: ${problem}`); + const measured = await measureTree(src); + if (dryRun) return { state: "would-move", src, dest, ...measured }; + await assertRoom(roots.mediaRoot, measured.bytes); + await mkdir(/* turbopackIgnore: true */ dest, { recursive: true }); + const verified = await copyMirrorVerify(src, dest, { rsyncBin, log }); + const parked = path.join(/* turbopackIgnore: true */ projectDir, `${name}.moved-${stamp()}`); + await rename(/* turbopackIgnore: true */ src, parked); + await symlink(/* turbopackIgnore: true */ dest, src, "dir"); + await rm(/* turbopackIgnore: true */ parked, { recursive: true, force: true }); + log(`moved ${src} -> ${dest} (${verified.files} file(s), ${gb(verified.bytes)})`); + return { state: "moved", src, dest, ...verified }; +} + +/** + * The reverse: `<projectDir>/<name>`, a link into the media root, becomes a + * real directory in the project again, and the media copy is deleted. + * + * a directory -> "already" + * absent, `<name>.incoming` -> the cut was between removing the link and + * renaming the verified copy: rename it, and + * report (never delete) the project's mirror + * absent, nothing incoming -> "absent" + * a link whose target is gone -> refused: the drive is not mounted + * a link -> copy the target into `<name>.incoming`, + * mirror, verify, remove the link, rename, + * then delete the copy only when it is this + * project's own mirror under a tiered media + * root (and the directories above it the move + * left empty, never the root); any other + * target is left and reported (mediaCopyLeft) + * a parked `<name>.moved-*` -> refused: a cut move-out is finished first + * a directory + a leftover -> refused (a writer's fresh directory) + * + * @param {string} projectDir + * @param {string} name + * @param {{ dryRun?: boolean, log?: (m: string) => void, rsyncBin?: string, reportsRoot?: string, mediaRoot?: string }} [opts] + * @returns {Promise<{ state: "moved" | "already" | "absent" | "would-move" | "would-finish", src: string, from?: string, bytes?: number, files?: number, resumed?: boolean }>} + */ +export async function moveDirToLocal(projectDir, name, opts = {}) { + assertName(name); + const { rsyncBin, log, dryRun } = defaults(opts); + const roots = rootsOf(opts); + const src = path.join(/* turbopackIgnore: true */ projectDir, name); + const incoming = path.join(/* turbopackIgnore: true */ projectDir, `${name}.incoming`); + const s = await pathState(src); + + const mirror = roots.tiered ? mediaMirror(projectDir, roots) : null; + const ownCopy = mirror ? path.join(/* turbopackIgnore: true */ mirror, name) : null; + if (s.kind === "dir") { + await assertNoLeftovers(projectDir, name, "nothing moved", { beside: true }); + // A move-back cut after its rename leaves the media copy behind (review + // L4): report it, never delete it blind. + const left = ownCopy && (await pathState(ownCopy)).kind === "dir" ? ownCopy : undefined; + return { state: "already", src, ...(left ? { mediaCopyLeft: left } : {}) }; + } + if (s.kind === "other") throw new Error(`${src} is not a directory. Nothing moved.`); + // A cut move-OUT is finished by move-out first. + if ((await parkedOf(projectDir, name)).length) await assertNoLeftovers(projectDir, name, "nothing moved"); + + if (s.kind === "missing") { + if ((await pathState(incoming)).kind !== "dir") return { state: "absent", src }; + if (dryRun) return { state: "would-finish", src }; + // `.incoming` outlives the link only once it verified. + log(`finishing a cut move: ${incoming} -> ${src}`); + await rename(/* turbopackIgnore: true */ incoming, src); + // The link is gone, so what was copied cannot be told from the disk: the + // project's own mirror is reported, never deleted (review L3). + const left = ownCopy && (await pathState(ownCopy)).kind === "dir" ? ownCopy : undefined; + return { state: "moved", src, resumed: true, ...(left ? { mediaCopyLeft: left } : {}) }; + } + + // A link. + const from = s.target; + if (!s.targetIsDir) { + throw new Error(`${src} is a link to ${from}, which is not there — is the media drive mounted? Nothing moved.`); + } + const measured = await measureTree(from); + if (dryRun) return { state: "would-move", src, from, ...measured }; + await assertRoom(projectDir, measured.bytes); + await mkdir(/* turbopackIgnore: true */ incoming, { recursive: true }); + const verified = await copyMirrorVerify(from, incoming, { rsyncBin, log }); + await unlink(/* turbopackIgnore: true */ src); // the link, not what it points at + await rename(/* turbopackIgnore: true */ incoming, src); + const left = await dropMediaCopy(from, ownCopy, roots.mediaRoot); + if (left) log(`left ${left} in place: it is not this project's own copy on the media root`); + log(`moved ${from} -> ${src} (${verified.files} file(s), ${gb(verified.bytes)})`); + return { state: "moved", src, from, ...verified, ...(left ? { mediaCopyLeft: left } : {}) }; +} + +/** + * Delete a media copy that has been brought home, then every directory above + * it the move left empty, stopping at -- never removing -- the media root. + * + * ONLY this project's own mirror (`ownCopy`, null when nothing is tiered), and + * only when the copy that came home IS that mirror (review L3). Without + * UMTOOL_MEDIA_DIR the "media root" would be the reports root itself, and a + * hand-made `out` link into another project's real out/ would be deleted after + * the copy. Anything else is left where it is, and its path returned so the + * caller can say so. + * @returns {Promise<string | undefined>} the path left in place, if any + */ +async function dropMediaCopy(from, ownCopy, mediaRoot) { + if (!ownCopy || from !== ownCopy || !inside(mediaRoot, from) || from === mediaRoot) return from; + if ((await pathState(from)).kind !== "dir") return undefined; + await rm(/* turbopackIgnore: true */ from, { recursive: true, force: true }); + for (let d = path.dirname(/* turbopackIgnore: true */ from); d !== mediaRoot && inside(mediaRoot, d); d = path.dirname(/* turbopackIgnore: true */ d)) { + try { + await rmdir(/* turbopackIgnore: true */ d); + } catch { + break; // not empty: something else lives there + } + } + return undefined; +} diff --git a/umtool/lib/report/storage.test.mjs b/umtool/lib/report/storage.test.mjs @@ -0,0 +1,469 @@ +// A project's render scratch on a media root (release 17): the roots in +// lib/paths.mjs, ensureOutDir / ensureWriteDir, the two movers, and the walk's +// skip rules for a media drive that is not there. +// +// Every call here names its own roots (`reportsRoot`, `mediaRoot`), so nothing +// depends on, or touches, the process's REPORTS_ROOT. The env-derived roots are +// tested in a child process, where the module is evaluated fresh. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { mediaMirror } from "../paths.mjs"; +import { SKIP_DIRS, skipsDir } from "../projects/kinds.mjs"; +import { walkProjects } from "../projects/walk.mjs"; +import { + ensureOutDir, + ensureWriteDir, + mediaRootProblem, + moveDirToLocal, + moveDirToMedia, + outDirState, +} from "./storage.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +/** A reports root with one project, and a media root beside it (not inside). */ +async function world({ withOut = true } = {}) { + const base = await mkdtemp(path.join(tmpdir(), "umtool-storage-")); + const reportsRoot = path.join(base, "reports"); + const mediaRoot = path.join(base, "media"); + const projectDir = path.join(reportsRoot, "folder", "proj"); + await mkdir(projectDir, { recursive: true }); + await mkdir(mediaRoot); + await writeFile(path.join(projectDir, "video.manifest.json"), "{}\n"); + if (withOut) { + await mkdir(path.join(projectDir, "out", "clips-raw"), { recursive: true }); + await writeFile(path.join(projectDir, "out", "proj.mp4"), Buffer.alloc(4096, 1)); + await writeFile(path.join(projectDir, "out", "clips-raw", "v_0-9.mp4"), Buffer.alloc(2048, 2)); + } + const roots = { reportsRoot, mediaRoot }; + const mirror = path.join(mediaRoot, "folder", "proj"); + return { base, reportsRoot, mediaRoot, projectDir, roots, mirror, done: () => rm(base, { recursive: true, force: true }) }; +} + +const kind = async (p) => { + const st = await lstat(p).catch(() => null); + if (!st) return "missing"; + return st.isSymbolicLink() ? "link" : st.isDirectory() ? "dir" : "file"; +}; + +// --------------------------------------------------------------------------- +// The roots +// --------------------------------------------------------------------------- + +/** lib/paths.mjs's values under a given environment, evaluated fresh. */ +function rootsUnder(envPatch) { + const env = { ...process.env }; + for (const k of ["UMTOOL_MEDIA_DIR", "UMTOOL_CACHE_DIR", "UMTOOL_INDEX_DIR", "XDG_CACHE_HOME", "MIX_ROOTS", "MIX_WRITE_ROOTS"]) delete env[k]; + Object.assign(env, { REPORTS_DIR: "/r/reports", SONG_DIR: "/r/song-data-that-is-not-there" }, envPatch); + const code = + "const m = await import(process.argv[1]);" + + "console.log(JSON.stringify({ media: m.MEDIA_ROOT, tiered: m.MEDIA_TIERED, reports: m.REPORTS_ROOT, cache: m.CACHE_DIR," + + " old: m.OLD_CACHE_DIR, index: m.INDEX_DIR, read: m.READ_ROOTS, write: m.WRITE_ROOTS }));"; + const out = execFileSync(process.execPath, ["--input-type=module", "-e", code, path.join(HERE, "..", "paths.mjs")], { + env, + encoding: "utf8", + }); + return JSON.parse(out); +} + +test("MEDIA_ROOT unset is REPORTS_ROOT: nothing tiered, no new root", () => { + const r = rootsUnder({}); + assert.equal(r.media, "/r/reports"); + assert.equal(r.tiered, false); + assert.equal(r.read.filter((p) => p === "/r/reports").length, 1); +}); + +test("UMTOOL_MEDIA_DIR is a READ root and never a write root", () => { + const r = rootsUnder({ UMTOOL_MEDIA_DIR: "/m/umtool" }); + assert.equal(r.media, "/m/umtool"); + assert.equal(r.tiered, true); + assert.ok(r.read.includes("/m/umtool")); + assert.ok(!r.write.includes("/m/umtool")); +}); + +test("CACHE_DIR: UMTOOL_CACHE_DIR, else XDG_CACHE_HOME/archilyzer/umtool, else ~/.cache — never SONG_DATA", () => { + assert.equal(rootsUnder({ UMTOOL_CACHE_DIR: "/c/u" }).cache, "/c/u"); + assert.equal(rootsUnder({ XDG_CACHE_HOME: "/x" }).cache, "/x/archilyzer/umtool"); + const plain = rootsUnder({ XDG_CACHE_HOME: "" }); + assert.equal(plain.cache, path.join(homedir(), ".cache", "archilyzer", "umtool")); + assert.equal(plain.index, path.join(plain.cache, "index")); + assert.equal(plain.old, "/r/song-data-that-is-not-there/.cache/umtool"); + assert.ok(!plain.cache.startsWith("/r/song-data")); +}); + +test("mediaMirror: the project-relative path under the media root, or null outside the reports root", () => { + const roots = { reportsRoot: "/r", mediaRoot: "/m" }; + assert.equal(mediaMirror("/r/a/b", roots), "/m/a/b"); + assert.equal(mediaMirror("/r", roots), null); + assert.equal(mediaMirror("/elsewhere/p", roots), null); + assert.equal(mediaMirror("/r-sibling/p", roots), null); +}); + +// --------------------------------------------------------------------------- +// ensureOutDir / ensureWriteDir +// --------------------------------------------------------------------------- + +test("ensureOutDir, not tiered: a plain directory, as every writer made it", async () => { + const w = await world({ withOut: false }); + try { + const out = await ensureOutDir(w.projectDir, { reportsRoot: w.reportsRoot, mediaRoot: w.reportsRoot }); + assert.equal(await kind(out), "dir"); + assert.deepEqual(await readdir(w.mediaRoot), []); + } finally { + await w.done(); + } +}); + +test("ensureOutDir, tiered and absent: the mirror is made under the media root and linked", async () => { + const w = await world({ withOut: false }); + try { + const out = await ensureOutDir(w.projectDir, w.roots); + assert.equal(await kind(out), "link"); + assert.equal(await readlink(out), path.join(w.mirror, "out")); + assert.equal(await kind(path.join(w.mirror, "out")), "dir"); + assert.deepEqual(await outDirState(w.projectDir), { state: "link", target: path.join(w.mirror, "out") }); + // Again: the link is kept as it is. + assert.equal(await ensureOutDir(w.projectDir, w.roots), out); + } finally { + await w.done(); + } +}); + +test("ensureOutDir keeps an existing real out/ even when tiered (move-out moves it, not the writer)", async () => { + const w = await world(); + try { + await ensureOutDir(w.projectDir, w.roots); + assert.equal(await kind(path.join(w.projectDir, "out")), "dir"); + assert.deepEqual(await readdir(w.mediaRoot), []); + } finally { + await w.done(); + } +}); + +test("ensureOutDir never creates the media root: an unplugged drive refuses and nothing is made", async () => { + const w = await world({ withOut: false }); + try { + await rm(w.mediaRoot, { recursive: true }); + await assert.rejects(ensureOutDir(w.projectDir, w.roots), /is not there — is its drive mounted\? Nothing was created/); + assert.equal(existsSync(w.mediaRoot), false); + assert.equal(await kind(path.join(w.projectDir, "out")), "missing"); + } finally { + await w.done(); + } +}); + +test("a dangling out link refuses loudly; nothing is materialised in its place or under it", async () => { + const w = await world({ withOut: false }); + try { + await ensureOutDir(w.projectDir, w.roots); + await rename(w.mediaRoot, `${w.mediaRoot}.unplugged`); + assert.equal((await outDirState(w.projectDir)).state, "dangling"); + await assert.rejects(ensureOutDir(w.projectDir, w.roots), /is the media drive mounted\?/); + await assert.rejects(ensureWriteDir(path.join(w.projectDir, "out", "sourced", "segments")), /is the media drive mounted\?/); + // And a plain recursive mkdir through the link fails too (ENOTDIR), making nothing. + await assert.rejects(mkdir(path.join(w.projectDir, "out", "clips-raw"), { recursive: true })); + assert.equal(await kind(path.join(w.projectDir, "out")), "link"); + assert.equal(existsSync(w.mediaRoot), false); + } finally { + await w.done(); + } +}); + +test("a media root inside the reports root (or around it) is refused", async () => { + const w = await world({ withOut: false }); + try { + const inner = { reportsRoot: w.reportsRoot, mediaRoot: path.join(w.reportsRoot, "media") }; + await mkdir(inner.mediaRoot); + assert.match(await mediaRootProblem(inner), /must be outside the reports root/); + assert.match(await mediaRootProblem({ reportsRoot: w.reportsRoot, mediaRoot: w.base }), /must be outside/); + assert.equal(await mediaRootProblem(w.roots), null); + assert.equal(await mediaRootProblem({ reportsRoot: w.reportsRoot, mediaRoot: w.reportsRoot }), null); + } finally { + await w.done(); + } +}); + +test("ensureWriteDir makes a project's out/ through ensureOutDir before anything under it", async () => { + const w = await world({ withOut: false }); + try { + // ensureWriteDir uses the process's roots; under the default (no + // UMTOOL_MEDIA_DIR in the test environment) it is a plain directory, the + // old behaviour. The tiered case is ensureOutDir's, tested above, and the + // storage e2e drives the pipeline scripts through it. + const deep = path.join(w.projectDir, "out", "sourced", "chrome", "deck-stills"); + assert.equal(await ensureWriteDir(deep), deep); + assert.equal(await kind(deep), "dir"); + // A directory that is not under any out/ is made as it always was. + const other = path.join(w.base, "elsewhere", "x"); + await ensureWriteDir(other); + assert.equal(await kind(other), "dir"); + } finally { + await w.done(); + } +}); + +// --------------------------------------------------------------------------- +// The movers +// --------------------------------------------------------------------------- + +test("moveDirToMedia: copy, verify, link; the bytes are the same and nothing is left parked", async () => { + const w = await world(); + try { + const logs = []; + const r = await moveDirToMedia(w.projectDir, "out", { ...w.roots, log: (m) => logs.push(m) }); + assert.equal(r.state, "moved"); + assert.equal(r.files, 2); + assert.equal(r.bytes, 4096 + 2048); + const out = path.join(w.projectDir, "out"); + assert.equal(await kind(out), "link"); + assert.equal(await readlink(out), path.join(w.mirror, "out")); + assert.deepEqual(await readFile(path.join(out, "proj.mp4")), Buffer.alloc(4096, 1)); + assert.deepEqual((await readdir(w.projectDir)).sort(), ["out", "video.manifest.json"]); + assert.ok(logs.some((l) => l.includes("--delete")), "the mirror pass ran"); + // Again: nothing to do. + assert.equal((await moveDirToMedia(w.projectDir, "out", w.roots)).state, "already"); + } finally { + await w.done(); + } +}); + +test("moveDirToMedia --dry-run measures and changes nothing", async () => { + const w = await world(); + try { + const r = await moveDirToMedia(w.projectDir, "out", { ...w.roots, dryRun: true }); + assert.equal(r.state, "would-move"); + assert.equal(r.bytes, 6144); + assert.equal(await kind(path.join(w.projectDir, "out")), "dir"); + assert.deepEqual(await readdir(w.mediaRoot), []); + } finally { + await w.done(); + } +}); + +test("moveDirToMedia refuses without a media root, and on a link that is not its own", async () => { + const w = await world(); + try { + await assert.rejects( + moveDirToMedia(w.projectDir, "out", { reportsRoot: w.reportsRoot, mediaRoot: w.reportsRoot }), + /UMTOOL_MEDIA_DIR is not set/, + ); + await assert.rejects(moveDirToMedia(w.projectDir, "../x", w.roots), /not a project directory name/); + const other = path.join(w.base, "other"); + await mkdir(other); + await symlink(other, path.join(w.projectDir, "clips")); + await assert.rejects(moveDirToMedia(w.projectDir, "clips", w.roots), /already a link, to .* not to/); + assert.equal((await moveDirToMedia(w.projectDir, "share-none", w.roots)).state, "absent"); + } finally { + await w.done(); + } +}); + +test("moveDirToMedia: a failed copy leaves the source untouched", async () => { + const w = await world(); + try { + await assert.rejects(moveDirToMedia(w.projectDir, "out", { ...w.roots, rsyncBin: "false" }), /rsync failed/); + assert.equal(await kind(path.join(w.projectDir, "out")), "dir"); + assert.deepEqual(await readFile(path.join(w.projectDir, "out", "proj.mp4")), Buffer.alloc(4096, 1)); + } finally { + await w.done(); + } +}); + +test("moveDirToMedia finishes a run cut between the park and the link", async () => { + const w = await world(); + try { + // What a cut leaves: the verified copy on the media root, the source parked. + await mkdir(path.join(w.mirror), { recursive: true }); + execFileSync("cp", ["-a", path.join(w.projectDir, "out"), path.join(w.mirror, "out")]); + await rename(path.join(w.projectDir, "out"), path.join(w.projectDir, "out.moved-20261001T000000Z")); + const r = await moveDirToMedia(w.projectDir, "out", w.roots); + assert.equal(r.state, "moved"); + assert.equal(r.resumed, true); + assert.equal(await kind(path.join(w.projectDir, "out")), "link"); + assert.deepEqual((await readdir(w.projectDir)).sort(), ["out", "video.manifest.json"]); + } finally { + await w.done(); + } +}); + +test("moveDirToMedia removes a parked copy left by a cut after the link", async () => { + const w = await world(); + try { + await moveDirToMedia(w.projectDir, "out", w.roots); + await mkdir(path.join(w.projectDir, "out.moved-20261001T000000Z")); + assert.equal((await moveDirToMedia(w.projectDir, "out", w.roots)).state, "already"); + assert.deepEqual((await readdir(w.projectDir)).sort(), ["out", "video.manifest.json"]); + } finally { + await w.done(); + } +}); + +test("moveDirToLocal: a real directory again, the media copy and its empty parents gone, the root kept", async () => { + const w = await world(); + try { + await moveDirToMedia(w.projectDir, "out", w.roots); + const r = await moveDirToLocal(w.projectDir, "out", w.roots); + assert.equal(r.state, "moved"); + assert.equal(r.bytes, 6144); + const out = path.join(w.projectDir, "out"); + assert.equal(await kind(out), "dir"); + assert.deepEqual(await readFile(path.join(out, "clips-raw", "v_0-9.mp4")), Buffer.alloc(2048, 2)); + assert.deepEqual((await readdir(w.projectDir)).sort(), ["out", "video.manifest.json"]); + assert.equal(existsSync(path.join(w.mediaRoot, "folder")), false); + assert.equal(existsSync(w.mediaRoot), true); + assert.equal((await moveDirToLocal(w.projectDir, "out", w.roots)).state, "already"); + } finally { + await w.done(); + } +}); + +test("moveDirToLocal refuses a dangling link and finishes a cut rename", async () => { + const w = await world(); + try { + await moveDirToMedia(w.projectDir, "out", w.roots); + await rename(w.mediaRoot, `${w.mediaRoot}.unplugged`); + await assert.rejects(moveDirToLocal(w.projectDir, "out", w.roots), /is the media drive mounted\? Nothing moved/); + assert.equal(await kind(path.join(w.projectDir, "out")), "link"); + await rename(`${w.mediaRoot}.unplugged`, w.mediaRoot); + + // A cut between removing the link and renaming the verified copy. The link + // is gone, so what was copied cannot be told: the mirror is reported, kept. + execFileSync("cp", ["-a", path.join(w.mirror, "out"), path.join(w.projectDir, "out.incoming")]); + await rm(path.join(w.projectDir, "out")); + const r = await moveDirToLocal(w.projectDir, "out", w.roots); + assert.equal(r.state, "moved"); + assert.equal(r.resumed, true); + assert.equal(await kind(path.join(w.projectDir, "out")), "dir"); + assert.equal(r.mediaCopyLeft, path.join(w.mirror, "out")); + assert.equal(existsSync(path.join(w.mirror, "out")), true); + // And from then on "already" keeps saying so (review L4). + const again = await moveDirToLocal(w.projectDir, "out", w.roots); + assert.equal(again.state, "already"); + assert.equal(again.mediaCopyLeft, path.join(w.mirror, "out")); + } finally { + await w.done(); + } +}); + +// --------------------------------------------------------------------------- +// The review's fixes (L3, L5, N5) +// --------------------------------------------------------------------------- + +test("move-back without a media root never deletes what the link pointed at (L3)", async () => { + const w = await world({ withOut: false }); + try { + // Another project's REAL out/, and a hand-made link to it. + const other = path.join(w.reportsRoot, "other", "out"); + await mkdir(other, { recursive: true }); + await writeFile(path.join(other, "keep.mp4"), Buffer.alloc(1024, 3)); + await symlink(other, path.join(w.projectDir, "out")); + const untiered = { reportsRoot: w.reportsRoot, mediaRoot: w.reportsRoot }; + const r = await moveDirToLocal(w.projectDir, "out", untiered); + assert.equal(r.state, "moved"); + assert.equal(r.mediaCopyLeft, other); + assert.equal(await kind(path.join(w.projectDir, "out")), "dir"); + assert.deepEqual(await readFile(path.join(other, "keep.mp4")), Buffer.alloc(1024, 3)); + } finally { + await w.done(); + } +}); + +test("a tiered move-back of a link that is not the project's own mirror leaves the target (L3)", async () => { + const w = await world({ withOut: false }); + try { + const elsewhere = path.join(w.mediaRoot, "someone-else", "out"); + await mkdir(elsewhere, { recursive: true }); + await writeFile(path.join(elsewhere, "x.mp4"), Buffer.alloc(512, 4)); + await symlink(elsewhere, path.join(w.projectDir, "out")); + const r = await moveDirToLocal(w.projectDir, "out", w.roots); + assert.equal(r.mediaCopyLeft, elsewhere); + assert.equal(existsSync(path.join(elsewhere, "x.mp4")), true); + } finally { + await w.done(); + } +}); + +test("a cut move's leftovers are a guard: no fresh out/, no move over them (L5)", async () => { + const w = await world({ withOut: false }); + try { + // A move-out cut after the park: out/ is missing, the parked copy is the data. + const parked = path.join(w.projectDir, "out.moved-20261001T000000Z"); + await mkdir(parked); + await writeFile(path.join(parked, "proj.mp4"), Buffer.alloc(4096, 1)); + for (const roots of [w.roots, { reportsRoot: w.reportsRoot, mediaRoot: w.reportsRoot }]) { + await assert.rejects(ensureOutDir(w.projectDir, roots), /was cut \(left: out\.moved-20261001T000000Z\).*move-out <project>.*nothing was created/); + } + assert.equal(await kind(path.join(w.projectDir, "out")), "missing"); + // A writer that made a fresh out/ anyway (an older binary): move-out refuses to mirror it over. + await mkdir(path.join(w.projectDir, "out")); + // Both exist: neither move sends the person to the other, which would only + // refuse again (re-review R1); the sentence says what to do by hand. + for (const move of [moveDirToMedia, moveDirToLocal]) { + await assert.rejects( + move(w.projectDir, "out", w.roots), + /out\/ and out\.moved-20261001T000000Z both exist .*The leftover holds the moved data\. Keep one and remove the other by hand, then run the move; nothing moved/, + ); + } + assert.deepEqual(await readdir(w.mediaRoot), []); + await rm(path.join(w.projectDir, "out"), { recursive: true }); + await rm(parked, { recursive: true }); + + // A move-back cut after the unlink: move-out refuses; move-back finishes it. + await mkdir(path.join(w.projectDir, "out.incoming")); + await assert.rejects(moveDirToMedia(w.projectDir, "out", w.roots), /move-back <project>.*nothing moved/); + await assert.rejects(ensureOutDir(w.projectDir, w.roots), /left: out\.incoming/); + assert.equal((await moveDirToLocal(w.projectDir, "out", w.roots)).state, "moved"); + assert.equal(await kind(path.join(w.projectDir, "out")), "dir"); + } finally { + await w.done(); + } +}); + +test("ensureOutDir for a project that does not exist leaves no empty mirror (N5)", async () => { + const w = await world({ withOut: false }); + try { + const ghost = path.join(w.reportsRoot, "ghost"); + await assert.rejects(ensureOutDir(ghost, w.roots), /is not a directory/); + assert.deepEqual(await readdir(w.mediaRoot), []); + // A project directory that is itself a link is a directory (re-review R2). + const real = path.join(w.base, "elsewhere-proj"); + await mkdir(real); + const linked = path.join(w.reportsRoot, "linked"); + await symlink(real, linked); + assert.equal(await kind(await ensureOutDir(linked, w.roots)), "link"); + } finally { + await w.done(); + } +}); + +// --------------------------------------------------------------------------- +// The walk never descends what may sit on the media drive +// --------------------------------------------------------------------------- + +test("the project walk skips out, clips and share-* (a link into an unplugged drive is never stat'd)", async () => { + assert.ok(SKIP_DIRS.has("out") && SKIP_DIRS.has("clips")); + assert.ok(skipsDir("share-emancipation") && skipsDir("clips") && !skipsDir("shares") && !skipsDir("project")); + assert.ok(skipsDir("out.moved-20261001T000000Z") && skipsDir("out.incoming") && !skipsDir("incoming")); + assert.ok(skipsDir("share-x.incoming") && !skipsDir("drafts.incoming") && !skipsDir("old.moved-2026")); + const w = await world(); + try { + for (const hidden of ["clips", "share-x"]) { + const d = path.join(w.reportsRoot, hidden, "inner"); + await mkdir(d, { recursive: true }); + await writeFile(path.join(d, "video.manifest.json"), "{}\n"); + } + const ids = (await walkProjects(w.reportsRoot)).map((p) => p.id); + assert.deepEqual(ids, ["folder/proj"]); + } finally { + await w.done(); + } +}); diff --git a/umtool/next.config.ts b/umtool/next.config.ts @@ -19,7 +19,8 @@ const nextConfig: NextConfig = { // with "Can't resolve 'cbor-x'", which names a package nothing here uses. serverExternalPackages: ["lmdb"], // No route's trace may list the e2e fixture (.e2e-song, where - // e2e/fixtures/make-fixture.mjs links the song data), the e2e server's own + // e2e/fixtures/make-fixture.mjs links the song data; .e2e-song-media, the + // storage spec's media root), the e2e server's own // build directory (.next-e2e) or an env file: none is a run-time input. The // clip-audio route's trace listed 1,704 such files (plans/release-15.md, slice // UT). That was fixed at the call (lib/paths.mjs `cacheFile`); this is the @@ -27,7 +28,7 @@ const nextConfig: NextConfig = { // back to what the sibling routes list. scripts/next-build-trace.test.mjs // reads the last build's traces back. outputFileTracingExcludes: { - "/*": ["./.e2e-song/**/*", "./.next-e2e/**/*", "./.env*"], + "/*": ["./.e2e-song/**/*", "./.e2e-song-media/**/*", "./.next-e2e/**/*", "./.env*"], }, turbopack: { // Same reasoning as editor/next.config.ts: Turbopack infers the workspace diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts @@ -76,6 +76,15 @@ export default defineConfig({ // var. CHANNELS_DIR has to be said explicitly: it is where a report // video's cue files live, and its default is the real 3 GB corpus. `CHANNELS_DIR=${FIXTURE}/channels ` + + // The cache (the project index, posters, analyses) is no longer under + // SONG_DIR (release 17): its default is the user's ~/.cache, which a + // suite must never write. The fixture's own, rebuilt with it every run. + `UMTOOL_CACHE_DIR=${FIXTURE}/cache ` + + // And never the media root: Playwright hands the app this shell's whole + // environment, so a shell that exports UMTOOL_MEDIA_DIR would put every + // fixture build's out/ on the real media drive. Empty is unset + // (lib/paths.mjs reads it with ||); storage.spec.ts gives its CLI its own. + `UMTOOL_MEDIA_DIR= ` + // Stub binaries, so a build spec is offline and deterministic. The // pipeline already reads both as overrides; the fixture writes them. `YTDLP_BIN=${FIXTURE}/bin/yt-dlp QRENCODE_BIN=${FIXTURE}/bin/qrencode ` + diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -174,7 +174,7 @@ the manifest names the MCP video id while the cue file lives under the URL slug. ## Manifest shape `timeline` is an ordered list; entries are `card`, `clip` or `image` (plus -`scroll`, `chart` and `ledger` — the vocabulary is open). +`scroll`, `chart`, `ledger` and `teaser` — the vocabulary is open). ```jsonc { "type": "card", "id": "ch3", "style": "chapter", "seconds": 4.0, @@ -187,7 +187,7 @@ the manifest names the MCP video id while the cue file lives under the URL slug. "quote": "The pre-application screening was approved by the county, dude." } ``` -Two per-clip fields exist for compilations that span sources or need a hand-cut +Three per-clip fields exist for compilations that span sources or need a hand-cut window: - **`channel`** — the archived channel this clip's cue file lives under, overriding @@ -201,6 +201,22 @@ window: sentence is an editorial decision that widening would silently undo. `lock` also handles the reverse case — a clip whose lead-in would drag in seconds of some *other* audio (a news package playing before the speaker starts). +- **`muteFrom`** — SOURCE seconds, like `start`/`end`/`cutEnd`, within the clip's + `start`–`end`: the clip's sound goes silent from that second to the end of the + clip while the picture plays on. A 40 ms fade ends exactly at `muteFrom`, so + nothing of a sound that starts there gets through and nothing clicks; from it on + the sound is digital silence, and a hold on that clip stays silent. It is made + where the cut is joined (see [The cut's edits](#the-cuts-edits-mutefrom-and-renderendfade)), + so changing it rebuilds no segment: `--chrome-only` applies it under the deck. + Only the played window is in the segment, so a mark past `cutEnd` (inside the + extent, so it validates) mutes nothing; the build says it will not be heard. + +`render.endFade` (seconds, default 0 = off, at most 10) fades the cut's LAST +segment — whatever it is — picture to `palette.bg` and sound to silence over its +final `endFade` seconds (all of it, when the segment is shorter), reaching both +on the last frame. It is for a cut that +ends on a clip; once a finale entry follows the last clip, the ordinary +crossfade into it does the job and `endFade` fades the finale instead. ### `render.chrome` — the on-screen deck's settings, and per-entry `onscreen` @@ -223,8 +239,10 @@ whatever a manifest omits, one level deep: "qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20 "overCards": "hide", // "hide" | "show" "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3 - "posts": { "show": true, "seconds": 2, "position": "top-right", // the manifest's `posts` (below); position | "top-left" - "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // seconds 0.5–10; width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80 + "posts": { "show": true, "seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none) + "shift": { "scale": 0.86, "seconds": 0.6 }, // the footage makes room: scale 0.5–1, seconds 0–3; or false + "position": "top-right", // | "top-left" + "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24 } // width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80 } } } @@ -282,15 +300,33 @@ footage, so it rides on a clip. - **When.** A clip's posts, oldest first, stack: post j of k appears at A − seconds·(k − j), where A is the start of the outgoing transition (the next segment's start under a crossfade, 0.3 s before a hard cut, 0.3 s before the end - of the cut). Each has `seconds` alone before the next stacks on, and the last - has the clip's final `seconds`; a clip too short for that shares what it has - after its incoming dissolve. They all leave together over the transition. + of the cut). Each has `seconds` (4 by default) alone before the next stacks on, + and the last has the clip's final `seconds`; a clip too short for that shares + what it has after its incoming dissolve. They all leave together over the + transition. +- **The hold.** A clip that carries posts is held on its last frame, in silence, + for `hold` seconds (2.5) before its outgoing transition, so the last post can + be read. The hold is part of the clip's length in the CUT: every start after + it, the total, the chapters and the posts' own timing are measured with it + (the schedule's `segments[i].hold`). +- **The move.** With `shift` on (the default), the footage eases over + `shift.seconds` (0.6) from its box to `shift.scale` of it (0.86), its far edge + `inset` from the frame's edge away from the column and centred above the deck, + as the clip's first post appears; it stays there to the end of the clip, hold + included, and the next clip comes in at the normal box through the transition + (the schedule's `moves`). At 1920×1080: 1574×886 at (173, 2) → 1354×762 at + (24, 64). - **What.** The post's date (as the deck writes dates; a date-time is drawn as its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to `maxLines` with an ellipsis — and a QR of the post's own `url`. -- **Where.** A column inside the footage box, `inset` from its top and from the - `position` side, `width` wide. Cards stack top-down; when the next would - overflow the column, the oldest slide up and out. +- **Where.** A column `inset` from the top of the footage box and from the + FRAME's edge on the `position` side (inside the footage box when `shift` is + `false`), `width` wide. Cards stack top-down; when the next would overflow the + column, the oldest slide up and out. +- **How they read.** Each card slides in from past the frame's edge on the + column's side and its accent rim flares as it lands, then settles to a quiet + glow; an accent rail runs down its leading edge and the platform is a pill + beside the handle. The QR is fully opaque once the card is in. `validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a deck build refuses a bad `posts` before a single fetch. Posts are drawn only @@ -387,6 +423,98 @@ cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderra under `render.chrome`'s deck layout.** The deck replaces all of it with one persistent panel — see [The on-screen deck](#renderchrome--the-on-screen-deck). +### The `teaser` entry type + +A season teaser's "coming soon" card: a full-frame graphic, its words the +manifest's, popping in one line at a time over a dark cinematic ground, with a +trailer hit under each pop. Put it after the last clip; the ordinary crossfade +joins them, and `render.endFade` — which fades the cut's last segment — now +falls on the teaser. + +```jsonc +{ "type": "teaser", "id": "fin", "seconds": 7, + "lines": ["Pirate Software", + { "text": "The Largest Ferret Rescue in the United States", + "break": "in the United States" }, + "February 2027"], + "tail": "?", // optional: appended to the LAST line, fades in on its own + "hits": true } // optional, default true: false makes the card silent +``` + +- **`lines`** — 1 to 5, each one line of at most 80 characters: a string, or + `{ text, break }`, where `break` is the END of `text` drawn as a smaller, + wide-tracked second tier under the rest that pops a beat (0.3 s) after it. + The words are data: they are drawn uppercase, and kept as written everywhere + else. **Roles follow position:** with three or more lines the first is a + small wide-tracked overline between two accent rules, the last a mid-size + kicker (a date), and everything between a big heavy title; two lines are an + overline and a title; one is a title. +- **What fits the frame** (`TEASER_LIMITS.fit`): a row wider than 80 % of the + frame shrinks to its role's floor and no further, so each row is also held to + what fits at that floor — a **title** at most **34** characters, a + **kicker** 56, an **overline** 64, a `break`'s second tier 66; the tail and + the space before it count on the row that carries it. An 80-character title + spilled past both edges of the frame. A longer title fits by setting its end + as a `break`. The counts were measured in the face on ordinary words in + capitals (a title holds 36–37 there); a row of only wide capitals (M, W) can + still spill. +- **`seconds`** — 3 to 20. The pops land 0.55 s in (after the incoming + dissolve) and 0.7 s apart; the tail starts 0.8 s after the last and fades in + over 1.7 s; the last 1.2 s are a still hold for the end fade. A card too + short for all of it plays every beat proportionally faster. +- **`tail`** — at most 8 characters, in the accent, set a little apart from + the last line. +- **The chapter** is the lines joined with " — ", the tail after the last + (an authored `chapter` still wins). The deck slides away over a teaser + whatever `overCards` says — it is full frame, never framed into the footage + box — and it has no pip and no QR. + +**How it is drawn.** `chrome-teaser.mjs` builds the page (pure; the cue list +is `teaserCues`, every cue a `fromTo` with its from stated, as the deck's are), +compose-chrome renders it as region `teaser` (`chrome/teaser-<id>/`, frames in +`chrome/teaser-<id>-frames/`, cached by the page's content key — changed words +are a new render), and the build encodes the frames into `segments/<id>.mp4` +at the parameters every segment shares. The display face is the vendored +Archivo (`fonts/Archivo[wdth,wght].ttf`, weight 600–900 and width 112–125 %), +copied in as the private family `TeaserDisplay`. The ground is `palette.bg` +lifted toward the accent at the centre and falling toward black at the edges, +a vignette, seeded film grain, a soft light leak drifting across, letterbox +bars that close in over the dissolve, and a slow push-in over the whole card. +Each line slams in from 1.42× (the overline from 1.25×), blurred, undershoots +to 0.968× as it lands — a flash of the accent behind it and a streak of light +through it — and settles to rest. Rendered on the ferret cut: 210 frames in +about 90 s. + +**The sound.** Synthesised in ffmpeg, no samples (`teaserAudioGraph`): under +each pop a trailer hit — a sub sine dropping from ~92 to ~40 Hz with its octave +for body on an exponential decay, a band-passed noise burst for the punch, a +low noise tail and a short low-passed echo. The title's hit is the biggest, +the overline's and the date's a little smaller, the second tier's lighter and +shorter; under the tail a low swell rises and settles as it fades in. The times +are `teaserTimes` (`deck.mjs`), the SAME the composition's cues are built from, +and each hit starts on the sample of its pop. The sum is limited at −6 dBFS +(`alimiter`, no auto-level, latency compensated) and levelled against the ferret +cut: the cut measures −17.7 LUFS integrated, the teaser's segment −19.7. `hits: false` is +digital silence, as a card's is. + +**When it is rebuilt.** The segment is re-encoded only when its key — the +frames' render key, the whole sound graph and the encode's parameters (`crf`, +`preset`, `audioBitrate`, `audioRate`, `audioChannels`) — differs from the one +recorded beside it (`<id>.teaser.json`). Any build that reaches a teaser +rebuilds it when its words, motion, sound or encoding changed: a full build, `--only <id>`, and +**`--chrome-only`**, which builds teaser segments (they are chrome — graphics +made from the manifest, nothing fetched) while still rebuilding no clip. +`verify-build` checks each teaser's frame count and that its segment was +encoded from the frames on disk. + +**umtool** shows a teaser as a card row named by its lines (the report page, +the On-screen table, the timeline strip). Editing its lines there is not +built: it needs a writer (`updateTeaser(dir, id, {lines, tail, seconds, hits}, +{token})` through `withManifestLock` and `validateTeaser`), a route, a small +form (one field per line with a break picker), and a preview — a still of the +composition at a chosen second through compose-chrome's `--still`, which the +deck's true-still route already does for its region. + ## Chrome, not cards **The ferret-rescue cut has no cards at all** — no title, no chapter breaks, no @@ -919,6 +1047,13 @@ deck slides away for its duration when it is `"hide"` (the default) — no text handover happens across a hidden segment, because there is nothing on screen to animate. +The code's host (`JASOLYZER.PAGES.DEV`, muted, tracked 0.1 em) runs up the QR's +left side and is exactly as long as the code is tall (`deckLayout(render).qr.size`, +150 px by default), whatever the host: the page measures the string's ink in the +loaded face once at load, scales its size to fit, and indents the first +letter's side bearing away, so the ink starts on the code's bottom edge and +ends on its top. + `out/<variant>/schedule.json` (`deckSchedule()`) is the one source of *when*: the build writes it from PROBED segment durations and real source metadata; `estimateSchedule()` produces the same shape (`estimated: true`) from the @@ -970,7 +1105,10 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|d rebuilt and nothing is fetched.** The driver labels this "re-render on-screen". - **`--no-chrome`** — the deck's framing with no overlay: a fast picture check - of the letterboxing, with nothing composed or rendered. + of the letterboxing, with nothing composed or rendered. Holds and footage + moves are part of the CUT, not the chrome, so `--no-chrome` still applies + them (the footage moves aside for cards it does not draw), and its length is + the schedule's. - **`--chrome-preview <at> <dur>`** — renders only that window of the deck and writes `out/<variant>/<slug>.preview.mp4` of it, from a cached concat when there is one, else built straight from the segments the window touches. @@ -1007,8 +1145,8 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts -- `chrome/posts-<segment>-frames/` with their own `.key` (html, assets, fps, frames, renderer version — the deck's cache, per window); `--preview` composes `chrome/posts-preview-<segment>/` and never renders. -- **The page** is region-local (`postsGeometry`, 600×838 at (1123, 26) by - default), transparent outside the cards. `?still=<t>` and the preview's +- **The page** is region-local (`postsGeometry`, 600×838 at (1296, 26) by + default; at (1123, 26) with `shift: false`), transparent outside the cards. `?still=<t>` and the preview's `deck:seek` take CUT seconds; a preview page answers with `{type: "posts:ready", segment, from, to, ids}`. - **The stack is planned in the page.** Whether the next card overflows the @@ -1028,7 +1166,75 @@ node umtool/report-to-video/compose-chrome.mjs <manifest.json> --region posts -- pass, `--chrome-only` and `--chrome-preview` (only the windows it touches) all lay them; `--no-chrome` lays neither. - **`verify-build`** also checks each window's `chrome/posts-<segment>-frames` - holds that window's frame count. + holds that window's frame count, and that each held clip's freeze is in the + file: two frames inside the still part of the hold -- after the hold starts + and the move lands, before the outgoing dissolve (or the end fade) -- are the + same, outside the deck and the column, within re-encoding noise. A hold with + under three frames of still picture there (0.5 s under a 0.5 s crossfade) is + reported as not checked. + +### The hold and the move, where the cut is joined + +`segmentJoins(schedule)` turns the schedule's holds and `moves` into one join +per carrying clip; the segment FILES are never touched, so `--chrome-only` +changes a hold or a move without rebuilding a clip, and a cut without posts has +no joins and runs the graphs it always did. + +- **The hold** is `tpad=stop_mode=clone:stop_duration=<hold>` on the input's + picture and `apad=pad_dur=<hold>` (silence) on its sound, before the + xfade/acrossfade. A hold is a whole number of frames (`postHolds` rounds it: + 2.5 s at 25 fps is 2.52 s), because `tpad` clones whole frames and `apad` + pads exact seconds. +- **The move** is one `perspective` filter on that input, AFTER the hold + (`sense=destination`, `eval=frame`): the input frame's corners are placed so + the footage box goes from `from` to `to`, eased by smoothstep over + `[segmentAt, segmentAt + seconds]` in the segment's own clock, hold included, + then held there. A first post that appears inside the hold therefore still + moves the footage, the frozen frame with it. Perspective resamples at + 1/256 px, so the box glides with no whole-pixel stepping, where `scale` + + `overlay` and `zoompan` round to whole pixels. Before the move the map is the + identity, which perspective copies bit for bit. `fillborders` pins the + frame's outer 2 px to `palette.bg` first, because perspective fills what the + shrink uncovers from the input's edge. +- **Hard cuts** with a hold or a move concatenate through the concat FILTER + (`hardCutFilterArgs`, one encode) rather than the demuxer's stream copy; the + prerail's `.segments` record then names each join, so a changed hold is never + served from a stale concat. Without joins the stream copy is unchanged. +- **Every length is the schedule's** under the deck: the xfade offsets, the + chapters, a `--chrome-preview` window and `--chapters-only` all add the holds + to the probed lengths (`cutOffsets`), the same sum `deckSchedule` makes. + +### The cut's edits: `muteFrom` and `render.endFade` + +Both are made on one input's chain before the join, beside the hold and the +move (`cutJoins` merges them into the deck's joins; `withCutEdits` is the pure +merge), so neither touches a segment file, and a cut that sets neither gets no +join for them: its graph, record and schedule are what they are without the two +keys. (Every crossfaded graph did change once, separately, when each input's +sound was pinned to its picture's length — `umtool/docs/quirks.md`.) They work +with or without the deck, on crossfades and hard cuts. + +- **`muteFrom` is mapped through the segment's cut record.** The build snaps a + clip's cut to the nearest silence, so its segment starts up to `snapWindow` + seconds from the manifest's `start` (or `cutStart − leadIn`). Every clip build + writes `segments/<id>.cut.json` — `{ video, start, end }`, the source seconds + it was really cut from — and `muteFrom − start` is the mute point in the + segment's clock (`muteSegmentSeconds`, in `deck.mjs`). A segment with no record + (built before records existed, or copied without it), or one whose record is + not as long as the segment, is measured from the unsnapped start instead, and + the build says so in a note. The sound chain is + `afade=t=out:st=<at − 0.04>:d=0.04` (then the hold's `apad`, if any); a mute at + or before the segment's start is `volume=0`. +- **The end fade's picture is a `geq` blend toward bg's Y′CbCr**, enabled from + the fade's first frame: frame `lastFrame − n` is the last untouched one and + `lastFrame` (the hold's clones counted) is bg. Not `fade=…:color=`, which works + in RGB only (`umtool/docs/quirks.md`). The sound is `afade` out to silence at the + last frame's time, over the same `n` frames. A fade longer than the segment is + the segment (`endFadeFrames` clamps `n` to `lastFrame`), so a 3 s finale under + `endFade: 5` still ends on bg and in silence together. +- **The hard-cut record names a mute and a fade** (`"mute"`, `"fade"` on a + `# join` line, only when there is one), so a cached prerail made without them, + or with other values, is never reused. ### Two ffmpeg traps that are the deck's alone diff --git a/umtool/report-to-video/av-sync.test.mjs b/umtool/report-to-video/av-sync.test.mjs @@ -0,0 +1,87 @@ +// The crossfade concat keeps every clip's sound on its picture. +// +// An encoded segment's audio is routinely a few to ~20 ms off its video, and +// `acrossfade` joins by the SOUND's length while `xfade` offsets come from the +// PICTURE's. Unpinned, the difference accumulates clip by clip. These segments +// make it large on purpose -- each one's audio is 30 ms short -- and put a tone +// exactly 1.0 s into each: in the joined cut, the third tone must still start +// 1.0 s after the third segment does. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { xfadeGraph } from "./build-video.mjs"; + +const have = spawnSync("ffmpeg", ["-version"]).status === 0; +const RENDER = { width: 320, height: 180, fps: 30, audioRate: 48000, audioChannels: 2 }; + +function segment(dir, name, seconds) { + const out = path.join(dir, `${name}.mp4`); + const a = (seconds - 0.03).toFixed(3); + const r = spawnSync("ffmpeg", [ + "-v", "error", "-y", + "-f", "lavfi", "-i", `color=c=gray:s=320x180:r=30:d=${seconds}`, + // Silence, then a tone from exactly 1.0 s; the stream is 30 ms short. + "-f", "lavfi", "-i", `sine=f=1000:r=48000:d=${a}`, + "-filter_complex", `[1:a]volume=enable='lt(t,1)':volume=0,aformat=channel_layouts=stereo[a]`, + "-map", "0:v", "-map", "[a]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + out, + ]); + assert.equal(r.status, 0, String(r.stderr)); + return out; +} + +/** The time (s) the tone's third onset starts in a file: the first loud window after `after`. */ +function onsetAfter(file, after) { + const r = spawnSync("ffmpeg", ["-v", "error", "-i", file, "-ac", "1", "-ar", "8000", "-f", "s16le", "-"], { maxBuffer: 1 << 26 }); + const pcm = new Int16Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 2); + const w = 40; // 5 ms windows + for (let i = Math.floor(after * 8000); i + w < pcm.length; i += w) { + let sum = 0; + for (let k = i; k < i + w; k += 1) sum += pcm[k] * pcm[k]; + if (Math.sqrt(sum / w) > 1000) return i / 8000; + } + return null; +} + +test("the crossfade concat keeps every clip's sound on its picture", { skip: !have && "no ffmpeg" }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "av-sync-")); + try { + const durs = [4, 4, 4]; + const segs = durs.map((d, i) => segment(dir, `s${i}`, d)); + const D = 0.5; + const run = (graph, out) => { + const r = spawnSync("ffmpeg", [ + "-v", "error", "-y", ...segs.flatMap((s) => ["-i", s]), + "-filter_complex", graph.parts.join(";"), + "-map", graph.vlab, "-map", graph.alab, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", out, + ]); + assert.equal(r.status, 0, String(r.stderr)); + return out; + }; + // The third segment starts at 2 × (4 − 0.5) = 7.0 s; its tone at 8.0 s. + const pinned = onsetAfter(run(xfadeGraph(durs, D, null, RENDER), path.join(dir, "pinned.mp4")), 7.6); + assert.ok(Math.abs(pinned - 8.0) <= 0.01, `pinned onset ${pinned}, expected 8.0`); + + // The unpinned graph (what the concat wrote before) lands each later clip + // 30 ms earlier per clip before it: the drift this guards against. + const unpinned = { + parts: [ + `[0:v][1:v]xfade=transition=fade:duration=${D}:offset=3.500[v1]`, + `[0:a][1:a]acrossfade=d=${D}:c1=tri:c2=tri[a1]`, + `[v1][2:v]xfade=transition=fade:duration=${D}:offset=7.000[v2]`, + `[a1][2:a]acrossfade=d=${D}:c1=tri:c2=tri[a2]`, + ], + vlab: "[v2]", alab: "[a2]", + }; + const drift = onsetAfter(run(unpinned, path.join(dir, "unpinned.mp4")), 7.6); + assert.ok(8.0 - drift >= 0.045, `unpinned onset ${drift} should be ~60 ms early`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -66,6 +66,7 @@ // Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango. import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { promisify } from "node:util"; import { mkdir, writeFile, readFile, access, readdir, rename, stat } from "node:fs/promises"; import path from "node:path"; @@ -76,12 +77,14 @@ import { cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; +import { ensureWriteDir } from "../lib/report/storage.mjs"; // The deck (`render.chrome`): its geometry, validation and schedule are pure // and live in deck.mjs. This file only frames segments into its box and writes // the schedule down -- it never has a copy of the arithmetic. import { - assertChrome, deckGeometry, deckOn, deckSchedule, frameCount, postsGeometry, postWindows, resolveDeck, - scheduleFrom, snapWindow, validatePosts, + assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, frameCount, MUTE_FADE, muteSegmentSeconds, + playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserTitle, + validateCutEdits, validatePosts, validateTeasers, } from "./deck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. @@ -713,10 +716,8 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, // The lead-in is a breath before the first word, clamped into the extent: // starting exactly on the quote's first syllable sounds like a dropped // frame. - const lead = render.leadIn ?? 0.4; const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); - const playFrom = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; - const playTo = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; + const { from: playFrom, to: playTo } = playWindow(entry, render); if (hasCut) { EMIT("cut", { id: entry.id, @@ -749,6 +750,14 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, const cutA = Math.min(a.at, wantB - 1); const cutB = Math.max(b.at, cutA + 1); EMIT("snap", { id: entry.id, start: a.snapped, end: b.snapped, seconds: cutB - cutA }); + // Where in the SOURCE this segment really starts and ends, snapped: what a + // `muteFrom` (source seconds) is measured from at the join, long after this + // function is gone (`--chrome-only` rebuilds no segment). + const cutRecord = { + version: 1, id: entry.id, video: entry.video, + start: Number((fetchStart + cutA).toFixed(3)), end: Number((fetchStart + cutB).toFixed(3)), + snapped: { start: a.snapped, end: b.snapped }, + }; const quotePath = path.join(outDir, "segments", `${entry.id}.quote.txt`); const attribPath = path.join(outDir, "segments", `${entry.id}.attrib.txt`); @@ -788,6 +797,7 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, ], { maxBuffer: 1 << 24 }, ); + await writeCutRecord(seg, cutRecord); return seg; } @@ -918,9 +928,22 @@ async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, ], { maxBuffer: 1 << 24 }, ); + await writeCutRecord(seg, cutRecord); return seg; } +/** Beside `segments/<id>.mp4`: `<id>.cut.json`, the source seconds it was cut from. */ +export const cutRecordPath = (seg) => seg.replace(/\.mp4$/, ".cut.json"); + +async function writeCutRecord(seg, record) { + await writeFile(cutRecordPath(seg), JSON.stringify(record) + "\n", "utf8"); +} + +/** A segment's cut record, or null when there is none (a segment built before records existed). */ +export async function readCutRecord(seg) { + return readFile(cutRecordPath(seg), "utf8").then(JSON.parse, () => null); +} + // ---- QR provenance code -------------------------------------------------- // A compilation asks the viewer to take the edit on trust. The QR is the antidote: // it resolves to this clip's exact START in the archive's own viewer, so anyone can @@ -984,6 +1007,160 @@ async function buildCardSegment(card, render, outDir, nodes) { return seg; } +// ---- the teaser ----------------------------------------------------------- +// A `teaser` entry is a full-frame graphic card -- a season teaser's "coming +// soon" screen -- drawn by a HyperFrames composition from the entry's words +// (chrome-teaser.mjs) and rendered once, cached by the page's content key +// (compose-chrome). This encodes those frames into the segment at the +// parameters every segment shares, with a sound track: a trailer hit under +// each pop and a swell under the tail (`teaserHits`, deck.mjs -- the same +// times the composition's cues land on), or digital silence with `hits: false`. +// +// The segment is re-encoded only when its key changes: the frames' key and the +// sound's graph, recorded beside it (`<id>.teaser.json`). So a teaser whose +// words changed is re-rendered and re-encoded by any build that reaches it -- +// `--chrome-only` included, which builds teaser segments (they are chrome: +// graphics made from the manifest, nothing fetched) -- and an unchanged one is +// neither. + +/** The record beside a teaser's segment: the key it was encoded from. */ +export const teaserRecordPath = (seg) => seg.replace(/\.mp4$/, ".teaser.json"); + +/** The teaser sound's level and ceiling: `limit` is −6 dBFS; `level` is set against the ferret cut's loudness (README). */ +export const TEASER_AUDIO = Object.freeze({ level: 1.4, limit: 0.5 }); + +/** A number for an aevalsrc expression: 6 decimals, no trailing zeros. */ +const ev6 = (v) => { + const s = (Math.round(Number(v) * 1e6) / 1e6).toFixed(6).replace(/\.?0+$/, ""); + return s === "-0" ? "0" : s; +}; + +/** + * The teaser's sound, as one filtergraph fragment from no inputs to `[ta]`: + * `hits` (`teaserHits`) synthesised in ffmpeg, no samples. + * + * A hit is three layers, each summed over every hit in one `aevalsrc` and + * gated to its own span, so each starts on the sample its time names: + * - the boom: a sine whose pitch drops from f0 to f1 (most of the way in + * ~0.4 s), with its octave for body, on an exponential decay (`decay` is + * the time constant; it is inaudible by ~7×) after a 3 ms attack; + * - the punch: a burst of noise (50 ms time constant), band-passed (180 Hz–3.2 kHz); + * - the tail: a low noise decay (low-passed at 260 Hz) under it. + * A swell is the boom's sine rising f0 → f1 under an envelope that peaks + * three quarters of the way through `dur` and settles, with a breath of the + * low noise. The noise is a hash of the sample number, not `random()`, so it + * is the same whatever else is in the graph. The sum takes a short low-passed + * echo, then `level`, then a limiter at −6 dBFS (`limit`, no auto-level, + * latency compensated) so hits that overlap still sum cleanly; trimmed and + * padded to exactly `seconds`. No hits: digital silence. + */ +export function teaserAudioGraph(hits, { seconds, render, level = TEASER_AUDIO.level, limit = TEASER_AUDIO.limit }) { + const rate = render.audioRate; + const layout = render.audioChannels === 1 ? "mono" : "stereo"; + const len = ev6(seconds); + const tail = `atrim=end=${len},apad=whole_dur=${len},asetpts=PTS-STARTPTS[ta]`; + if (!hits.length) return `anullsrc=channel_layout=${layout}:sample_rate=${rate},${tail}`; + const noise = "(2*(sin(n*12.9898+78.233)*43758.5453-floor(sin(n*12.9898+78.233)*43758.5453))-1)"; + const boom = []; + const punch = []; + const rumble = []; + for (const h of hits) { + const a = ev6(h.at); + const u = `(t-${a})`; + const g = ev6(h.gain); + if (h.kind === "swell") { + const D = h.dur; + const peak = ev6(D * 0.75); + const v = `min(${u},${ev6(D)})`; + const phase = `(${ev6(h.f0)}*${v}+${ev6((h.f1 - h.f0) / (2 * D))}*${v}*${v}+${ev6(h.f1)}*(${u}-${v}))`; + const env = `pow(sin(PI/2*min(1,${u}/${peak})),2)*exp(-max(0,${u}-${peak})/${ev6(h.decay)})`; + const span = ev6(D * 0.75 + h.decay * 7); + boom.push(`if(between(t,${a},${a}+${span}),${g}*0.42*${env}*sin(2*PI*${phase}),0)`); + rumble.push(`if(between(t,${a},${a}+${span}),${g}*0.5*${env}*${noise},0)`); + continue; + } + const k = 0.13; // the pitch drop's time constant + const phase = `(${ev6(h.f1)}*${u}+${ev6((h.f0 - h.f1) * k)}*(1-exp(-${u}/${k})))`; + const span = ev6(h.decay * 7); + boom.push( + `if(between(t,${a},${a}+${span}),${g}*0.3*min(1,${u}/0.003)*exp(-${u}/${ev6(h.decay)})*` + + `(sin(2*PI*${phase})+0.6*exp(-${u}/${ev6(h.decay * 0.6)})*sin(4*PI*${phase})),0)`, + ); + punch.push(`if(between(t,${a},${a}+0.25),${g}*0.5*exp(-${u}/0.05)*${noise},0)`); + rumble.push(`if(between(t,${a},${a}+${ev6(Math.min(2.6, h.decay * 6))}),${g}*0.32*min(1,${u}/0.02)*exp(-${u}/${ev6(h.decay * 1.3)})*${noise},0)`); + } + const src = (terms) => `aevalsrc=exprs='${terms.length ? terms.join("+") : "0"}':s=${rate}:c=${layout}:d=${len}`; + return [ + `${src(boom)}[tb]`, + `${src(punch)},highpass=f=180,lowpass=f=3200[tp]`, + `${src(rumble)},lowpass=f=260,lowpass=f=260[tr]`, + `[tb][tp][tr]amix=inputs=3:normalize=0,aecho=1:1:90|190:0.18|0.09,lowpass=f=5000,` + + `volume=${ev6(level)},alimiter=limit=${ev6(limit)}:level=0:latency=1:attack=2:release=80,${tail}`, + ].join(";"); +} + +/** The teaser segment's encode: its frames, its sound, the shared parameters. */ +export function teaserEncodeArgs({ framesDir, seconds, render, audio, outPath }) { + return [ + "-nostdin", "-v", "error", "-y", + // The renderer writes an all-opaque frame as RGB and any other as RGBA; + // a switch mid-sequence would reinitialise the graph and end it early. + "-framerate", String(render.fps), "-reinit_filter", "0", "-start_number", "1", + "-i", path.join(framesDir, "frame_%06d.png"), + "-filter_complex", `[0:v]format=rgba,fps=${render.fps},setsar=1[tv];${audio}`, + "-map", "[tv]", "-map", "[ta]", + ...encodeArgs(render), + "-frames:v", String(frameCount(seconds, render.fps)), + "-shortest", + outPath, + ]; +} + +/** + * A teaser segment's key: its frames' render key, its sound's whole graph + * (every hit's time and parameters, `hits: false`'s silence, the level) and + * the encode's parameters (`encodeArgs`: crf, preset, the audio's bitrate, + * rate and channels -- the graph says "stereo" whatever `audioChannels` is). + * A segment whose recorded key differs is re-encoded, as every clip is when + * one of those changes. + */ +export const teaserSegmentKey = (framesKey, audioGraph, render) => + createHash("sha256") + .update(JSON.stringify({ v: 2, frames: framesKey, audio: audioGraph, encode: encodeArgs(render) })) + .digest("hex"); + +/** + * Compose and render the teaser (cached by compose-chrome's key), then encode + * its segment unless the one on disk was made from the same frames and sound. + * Dynamic import: compose-chrome imports this file, and its page module must + * not reach umtool's bundle through the build (docs/quirks.md). + */ +async function buildTeaserSegment(entry, { manifestPath, render, outDir, variant }) { + const { composeChrome } = await import("./compose-chrome.mjs"); + const t0 = Date.now(); + const r = await composeChrome({ + manifestPath, outDir, variant, region: "teaser", segment: entry.id, doRender: true, + fps: render.fps, workers: 4, quality: "high", format: "png-sequence", + }); + EMIT("chrome", { + phase: r.cached ? "cached" : "render", region: "teaser", segment: entry.id, + frames: r.frameCount, key: r.key, dir: r.frames, seconds: Number(((Date.now() - t0) / 1000).toFixed(1)), + }); + const seconds = Number(entry.seconds); + const audio = teaserAudioGraph(teaserHits(entry), { seconds, render }); + const key = teaserSegmentKey(r.key, audio, render); + const seg = path.join(outDir, "segments", `${entry.id}.mp4`); + const recPath = teaserRecordPath(seg); + const rec = await readFile(recPath, "utf8").then(JSON.parse, () => null); + if (rec?.key === key && (await exists(seg))) { + EMIT("note", { id: entry.id, message: `${entry.id}: teaser segment unchanged (key ${key.slice(0, 12)})` }); + return seg; + } + await execFileP(FFMPEG, teaserEncodeArgs({ framesDir: r.frames, seconds, render, audio, outPath: seg }), { maxBuffer: 1 << 26 }); + await writeFile(recPath, JSON.stringify({ key, frames: r.key, hits: teaserHits(entry).length }) + "\n", "utf8"); + return seg; +} + // ---- stills -------------------------------------------------------------- // An `image` entry is a screenshot in the cut: the receipts a clip cannot say // out loud -- a post, a thread, a DM -- shown for `seconds` and then gone. @@ -2105,28 +2282,329 @@ async function buildChartSegment(card, render, outDir, ledger) { return seg; } -// Crossfade every segment into the next. This is a full re-encode of the -// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade -// stays available for quick iteration. -async function concatWithXfade(segments, render, outPath, railPlan, chrome = null) { - const D = render.transition ?? 0.5; - const durs = []; - for (const s of segments) durs.push(await probeDuration(s, render.fps)); +// ---- room for posts: the hold and the footage move, where the cut is joined -- +// A clip that carries posts is HELD on its last frame (`segments[i].hold` in +// the deck's schedule) and its footage MOVES aside as its first post appears +// (`schedule.moves`). Both happen on that segment's input chain, before the +// join -- never in the segment file -- so `--chrome-only` changes them +// without rebuilding a clip, and every other input's chain is exactly what it +// was. A cut without posts has no joins (`segmentJoins` returns null) and +// every line below behaves as it always did. - const inputs = segments.flatMap((s) => ["-i", s]); +/** + * Per-input join work from the deck's schedule, aligned with its segments: + * `{ hold, move }` for a carrying clip, null for every other; null overall when + * no segment has either -- the switch that keeps a cut without posts on the + * paths it always took. + * + * @returns {Array<{ hold: number, move: object|null } | null> | null} + */ +export function segmentJoins(schedule) { + if (!schedule?.segments) return null; + const moves = new Map((schedule.moves ?? []).map((m) => [m.segment, m])); + const joins = schedule.segments.map((s) => { + const hold = s.hold > 0 ? s.hold : 0; + const move = moves.get(s.id) ?? null; + return hold || move ? { hold, move } : null; + }); + return joins.some(Boolean) ? joins : null; +} + +/** A number for an ffmpeg expression or option: at most 4 decimals, no trailing zeros. */ +const exprNum = (v) => { + const s = (Math.round(Number(v) * 1e4) / 1e4).toFixed(4).replace(/\.?0+$/, ""); + return s === "-0" ? "0" : s; +}; + +/** + * The footage move as one `perspective` filter (destination sense, evaluated + * per frame): an affine map of the WHOLE frame that takes the `from` box to + * the box eased toward `to`. Each corner of the input frame is placed at + * `base + d·e(t)`, where e is smoothstep over [segmentAt, segmentAt + seconds] + * in the segment's own clock (its hold included: the move runs after the + * `tpad`) and d is where that corner has gone at e = 1 -- + * scale `to.width / from.width` (and height), then translate. Before the move + * e = 0 and the map is the identity, which perspective copies bit for bit; + * after it e = 1 and the frame holds at `to`. + * + * Perspective resamples at 1/256 px, so the box glides with no whole-pixel + * stepping (scale + overlay and zoompan both round to whole pixels). It has no + * `t`, and its `in` counts frames from 1 -- hence `(in-1)/fps`. What the + * shrink uncovers is filled from the input's edge (perspective clamps), which + * the deck framing made `palette.bg`; `fillborders` pins the outermost pixels + * to it exactly, so a coding artefact at the edge cannot be smeared across + * the uncovered band. + */ +export function moveFilter(move, render) { + const { from: F, to: T } = move; + const kx = T.width / F.width - 1; + const ky = T.height / F.height - 1; + const dx = (u) => T.x - F.x + (u - F.x) * kx; + const dy = (v) => T.y - F.y + (v - F.y) * ky; + const W = render.width ?? 1920; + const H = render.height ?? 1080; + const t = `(in-1)/${render.fps}`; + const a = exprNum(move.segmentAt); + const p = move.seconds > 0 ? `clip((${t}-${a})/${exprNum(move.seconds)},0,1)` : `gte(${t},${a})`; + // smoothstep, 3p² − 2p³: flat at both ends, so the glide starts and lands without a jolt. + const e = (base, d) => `'st(0,${p});${base}+(${exprNum(d)})*ld(0)*ld(0)*(3-2*ld(0))'`; + const corners = [ + ["x0", e("0", dx(0))], ["y0", e("0", dy(0))], + ["x1", e("W", dx(W))], ["y1", e("0", dy(0))], + ["x2", e("0", dx(0))], ["y2", e("H", dy(H))], + ["x3", e("W", dx(W))], ["y3", e("H", dy(H))], + ]; + return [ + `fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=${render.palette.bg}`, + `perspective=${corners.map(([k, v]) => `${k}=${v}`).join(":")}:interpolation=linear:sense=destination:eval=frame`, + ].join(","); +} + +/** The hold on a segment's picture: its last frame, cloned for `hold` seconds. */ +export const holdVideoFilter = (hold) => `tpad=stop_mode=clone:stop_duration=${exprNum(hold)}`; +/** The hold on its sound: silence, for the same `hold` seconds. */ +export const holdAudioFilter = (hold) => `apad=pad_dur=${exprNum(hold)}`; + +/** + * A `muteFrom` on a segment's sound: silent from `at` (segment seconds) to its + * end, after a MUTE_FADE that ENDS at `at`, so nothing of a sound that starts + * there gets through and there is no click. `afade` out writes digital silence + * (zeros) after its fade and copies every sample before it. At 0 the whole + * segment is silent. + */ +export const muteAudioFilter = (at) => { + if (!(at > 0)) return "volume=0"; + const st = Math.max(0, at - MUTE_FADE); + return `afade=t=out:st=${exprNum(st)}:d=${exprNum(at - st)}`; +}; + +/** + * A `#rrggbb` colour as 8-bit limited-range BT.601 Y′CbCr -- what `pad` and a + * `color` source write for it into the segments' yuv420p (`#12101a` is + * 31/132/128 in both, measured). + */ +export function yuv601(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex)); + if (!m) throw new Error(`not a #rrggbb colour: ${hex}`); + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + return { + y: Math.round(16 + 65.481 * r + 128.553 * g + 24.966 * b), + u: Math.round(128 - 37.797 * r - 74.203 * g + 112 * b), + v: Math.round(128 + 112 * r - 93.786 * g - 18.214 * b), + }; +} + +/** + * The end fade on the cut's last segment, over its final `seconds`: the + * picture eased to `palette.bg`, so the LAST frame (`lastFrame`, 0-based, the + * hold's clones included) is exactly bg; the sound faded to silence at that + * frame's time. + * + * Not `fade=…:color=`: a coloured fade takes RGB only, so ffmpeg converts the + * segment to rgb24 -- and the concat filter then negotiates every OTHER + * segment to rgb24 too, a lossy round trip for the whole cut. `geq` blends + * each plane toward bg's Y′CbCr in the segment's own yuv420p, and only from + * the fade's first frame (`enable`): every frame before it passes untouched. + * Frame f's weight is (f − s)/n with s = lastFrame − n, so s is the last + * frame untouched and lastFrame is bg; `+0.5` rounds where geq truncates. + * + * A fade longer than the segment is the segment: n is clamped to lastFrame + * (`endFadeFrames`), so the fade runs from its first frame and its last is + * still exactly bg. Unclamped, the weight's denominator stayed n while s + * clamped to 0, and the last frame of a 3 s segment under `endFade: 5` was + * only 59 % of the way there while the sound did reach silence. + */ +export const endFadeFrames = (fade, fps) => + Math.max(1, Math.min(Math.round(fade.seconds * fps), fade.lastFrame)); +export const endFadeVideoFilter = (fade, render) => { + const fps = render.fps; + const n = endFadeFrames(fade, fps); + const st = exprNum(Math.max(0, fade.lastFrame - n) / fps); + const k = `clip((T-${st})/${exprNum(n / fps)},0,1)`; + const bg = yuv601(render.palette.bg); + const plane = (p, c) => `'${p}(X,Y)+(${c}-${p}(X,Y))*${k}+0.5'`; + return `geq=lum=${plane("lum", bg.y)}:cb=${plane("cb", bg.u)}:cr=${plane("cr", bg.v)}:enable='gte(t,${st})'`; +}; +/** The sound's half: the same frames as the picture's, so the two end together. */ +export const endFadeAudioFilter = (fade, render) => { + const end = fade.lastFrame / render.fps; + const st = Math.max(0, end - endFadeFrames(fade, render.fps) / render.fps); + return `afade=t=out:st=${exprNum(st)}:d=${exprNum(Math.max(1e-3, end - st))}`; +}; + +/** + * Input `i`'s chains before the join. Without a join the labels are the + * input's own (`[i:v]`, `[i:a]`) and there is no chain at all, so a cut + * without posts writes the graph it always did. + * + * The move goes AFTER the hold: its clock (`in`) then counts the held frames + * too, so a first post that appears inside the hold -- or so late that the + * glide runs past the clip's own last frame -- still moves the footage, the + * frozen frame with it, exactly when the schedule (and umtool's preview) say. + * Before the hold, such a move never started or froze part-way. + * + * @returns {{ parts: string[], v: string, a: string }} + */ +export function joinInputChain(i, join, render) { + if (!join) return { parts: [], v: `[${i}:v]`, a: `[${i}:a]` }; const parts = []; - let vlab = "[0:v]"; - let alab = "[0:a]"; - let acc = durs[0]; + // Picture: hold, move, end fade. Sound: mute, hold, end fade -- the mute is + // in the clip's own clock and the hold is silence anyway; the end fade is + // last on both, over the segment's final seconds as the cut plays them. + const vf = [ + join.hold > 0 ? holdVideoFilter(join.hold) : null, + join.move ? moveFilter(join.move, render) : null, + join.fade ? endFadeVideoFilter(join.fade, render) : null, + ].filter(Boolean); + const v = vf.length ? `[j${i}v]` : `[${i}:v]`; + if (vf.length) parts.push(`[${i}:v]${vf.join(",")}${v}`); + const af = [ + join.mute != null ? muteAudioFilter(join.mute) : null, + join.hold > 0 ? holdAudioFilter(join.hold) : null, + join.fade ? endFadeAudioFilter(join.fade, render) : null, + ].filter(Boolean); + const a = af.length ? `[j${i}a]` : `[${i}:a]`; + if (af.length) parts.push(`[${i}:a]${af.join(",")}${a}`); + return { parts, v, a }; +} + +/** + * The joins with the cut's edits merged in: a `muteFrom` (`mutes`: segment + * index → segment seconds) and the end fade on the LAST segment + * (`fade`: `{ seconds, lastFrame }`). A join gains `mute` / `fade` only when it + * has one, so a cut without either keeps exactly the joins (and the graph, + * and the hard-cut record) it had; null when nothing is joined at all. + */ +export function withCutEdits(joins, n, { mutes = new Map(), fade = null } = {}) { + if (!mutes.size && !fade) return joins; + const out = Array.from({ length: n }, (_, i) => joins?.[i] ?? null); + for (const [i, at] of mutes) out[i] = { hold: 0, move: null, ...(out[i] ?? {}), mute: at }; + if (fade) out[n - 1] = { hold: 0, move: null, ...(out[n - 1] ?? {}), fade }; + return out.some(Boolean) ? out : null; +} + +/** + * Every join the cut makes: the deck's holds and moves (`segmentJoins` of its + * schedule; none without the deck), each clip's `muteFrom` mapped to its + * segment's clock through the segment's cut record, and `render.endFade` on + * the last segment. Reads the records and probes what it needs; null when + * nothing is joined, so every concat then runs as it always did. + */ +export async function cutJoins({ schedule = null, entries, segments, render }) { + const base = schedule ? segmentJoins(schedule) : null; + const mutes = new Map(); + for (let i = 0; i < entries.length; i += 1) { + const e = entries[i]; + if (e.type !== "clip" || e.muteFrom == null) continue; + const seconds = await probeDuration(segments[i], render.fps); + const m = muteSegmentSeconds({ entry: e, record: await readCutRecord(segments[i]), render, seconds }); + if (m.note) EMIT("note", { id: e.id, message: m.note }); + const from = m.source === "record" ? "from its cut record" : "from the unsnapped start"; + // Validation allows the clip's whole extent, but only the played window is + // in the segment: a mark past the cut's end mutes nothing, and saying + // "muted from" would claim otherwise. + EMIT("note", { + id: e.id, + message: m.at >= seconds + ? `${e.id}: muteFrom ${e.muteFrom} will not be heard -- it lies ${m.at}s into a segment ${seconds}s long, past the cut's end (${from})` + : `${e.id}: muted from ${m.at}s into its segment (muteFrom ${e.muteFrom}, ${from})`, + }); + mutes.set(i, m.at); + } + const seconds = endFadeOf(render); + let fade = null; + if (seconds > 0 && segments.length) { + const last = segments.length - 1; + const frames = Math.round((await probeDuration(segments[last], render.fps)) * render.fps) + + Math.round((base?.[last]?.hold ?? 0) * render.fps); + fade = { seconds, lastFrame: frames - 1 }; + } + return withCutEdits(base, segments.length, { mutes, fade }); +} - for (let i = 1; i < segments.length; i += 1) { +/** + * The segments' lengths IN THE CUT: probed, plus each one's hold -- the sum + * `deckSchedule` makes (it is handed the same probed lengths and adds the same + * holds), so the xfade offsets, the chapters and a preview's window agree with + * the schedule's starts. Without joins this is segmentOffsets, unchanged. + */ +export async function cutOffsets(segments, D, fps, joins = null) { + const { durs } = await segmentOffsets(segments, D, fps); + const full = durs.map((d, i) => d + (joins?.[i]?.hold ?? 0)); + return { ...scheduleFrom(full, D), durs: full }; +} + +/** + * The crossfade concat's filtergraph from the cut's segment lengths (`durs`, + * holds included) and the per-input joins. Without joins, the graph it always was. + */ +export function xfadeGraph(durs, D, joins = null, render = null) { + const parts = []; + const ins = durs.map((d, i) => { + const c = joinInputChain(i, joins?.[i] ?? null, render); + parts.push(...c.parts); + // The sound is pinned to the picture's length before it is crossfaded. + // `xfade` places segment i+1 by the PICTURE's length, `acrossfade` by the + // SOUND's, and an encoded segment's audio is routinely a few to ~20 ms off + // its video (AAC frames do not end on video frames). Unpinned, the error + // accumulates segment by segment: measured 0.30 s early by the last clip of + // a 17-clip cut, 2.0 s on one with title and sources cards. Padded with + // silence and trimmed to exactly `d`, every clip's sound starts with its + // picture. `d` is the segment's length in the cut (frames ÷ fps, hold + // included), the same number the xfade offsets are summed from. + const len = d.toFixed(6); + const a = `[p${i}a]`; + parts.push(`${c.a}apad=whole_dur=${len},atrim=end=${len},asetpts=PTS-STARTPTS${a}`); + return { v: c.v, a }; + }); + let vlab = ins[0].v; + let alab = ins[0].a; + let acc = durs[0]; + for (let i = 1; i < durs.length; i += 1) { const off = acc - D; - parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); - parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); + parts.push(`${vlab}${ins[i].v}xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); + parts.push(`${alab}${ins[i].a}acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); vlab = `[v${i}]`; alab = `[a${i}]`; acc = acc + durs[i] - D; } + return { parts, vlab, alab }; +} + +/** + * A hard-cut concat through the concat FILTER, for a cut whose joins need a + * filtergraph (the demuxer's stream copy cannot host one): every input's + * chain, then `concat`, one encode at the parameters every segment shares. + */ +export function hardCutFilterArgs(segments, joins, render, outPath) { + const parts = []; + const pairs = segments.map((_, i) => { + const c = joinInputChain(i, joins?.[i] ?? null, render); + parts.push(...c.parts); + return `${c.v}${c.a}`; + }); + parts.push(`${pairs.join("")}concat=n=${segments.length}:v=1:a=1[vc][ac]`); + return [ + "-nostdin", "-v", "error", "-y", + ...segments.flatMap((s) => ["-i", s]), + "-filter_complex", parts.join(";"), + "-map", "[vc]", "-map", "[ac]", + ...encodeArgs(render), + outPath, + ]; +} + +// Crossfade every segment into the next. This is a full re-encode of the +// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade +// stays available for quick iteration. `joins` (the deck's holds and moves, +// `segmentJoins`) go on their inputs before the join; null leaves the graph +// exactly as it was. +async function concatWithXfade(segments, render, outPath, railPlan, chrome = null, joins = null) { + const D = render.transition ?? 0.5; + const { durs } = await cutOffsets(segments, D, render.fps, joins); + + const inputs = segments.flatMap((s) => ["-i", s]); + const { parts, vlab, alab } = xfadeGraph(durs, D, joins, render); // The rail attaches to the LAST xfade node, so it runs after every dissolve // and sees an absolute, continuous `t`. One encode, not two. @@ -2281,27 +2759,28 @@ export function windowSegments(starts, durs, at, dur) { * runs over the whole cut (or a plain concat for hard cuts), trimmed to the * window, the window's deck frames over it. Seconds, not a whole-cut encode. */ -export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath }) { +export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, render, chromePlan, outPath, joins = null }) { + // `durs`/`starts` are the CUT's (cutOffsets: holds included), and the + // window's inputs carry their joins as the full concat's do. const { first, last, offset } = windowSegments(starts, durs, at, dur); const segs = segments.slice(first, last + 1); const ds = durs.slice(first, last + 1); - const parts = []; - let vlab = "[0:v]"; - let alab = "[0:a]"; + const js = joins ? joins.slice(first, last + 1) : null; + let parts = []; + let vlab; + let alab; if (segs.length > 1 && D > 0) { - let acc = ds[0]; - for (let i = 1; i < segs.length; i += 1) { - const off = acc - D; - parts.push(`${vlab}[${i}:v]xfade=transition=fade:duration=${D}:offset=${off.toFixed(3)}[v${i}]`); - parts.push(`${alab}[${i}:a]acrossfade=d=${D}:c1=tri:c2=tri[a${i}]`); - vlab = `[v${i}]`; - alab = `[a${i}]`; - acc = acc + ds[i] - D; + ({ parts, vlab, alab } = xfadeGraph(ds, D, js, render)); + } else { + const ins = segs.map((_, i) => joinInputChain(i, js?.[i] ?? null, render)); + for (const c of ins) parts.push(...c.parts); + vlab = ins[0].v; + alab = ins[0].a; + if (segs.length > 1) { + parts.push(`${ins.map((c) => `${c.v}${c.a}`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); + vlab = "[vc]"; + alab = "[ac]"; } - } else if (segs.length > 1) { - parts.push(`${segs.map((_, i) => `[${i}:v][${i}:a]`).join("")}concat=n=${segs.length}:v=1:a=1[vc][ac]`); - vlab = "[vc]"; - alab = "[ac]"; } const S = offset.toFixed(3); const T = Number(dur).toFixed(3); @@ -2385,14 +2864,15 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro * schedule says AND no segment is newer than it: a re-trimmed clip that kept * its length would otherwise pass the length check and play the old cut. */ -async function freshConcat(file, segments, total, fps) { +async function freshConcat(file, segments, total, fps, joins = null) { const st = await stat(file).catch(() => null); if (!st) return false; - // Same segments, same order. Length and age alone would take a REORDERED - // timeline's old concat -- every title, QR and chapter then lands on the - // wrong footage while the length check still passes. + // Same segments, same order, and the same holds and moves on them. Length + // and age alone would take a REORDERED timeline's old concat -- every title, + // QR and chapter then lands on the wrong footage while the length check + // still passes -- or one whose holds were joined differently. const recorded = await readFile(`${file}.segments`, "utf8").catch(() => null); - if (!sameConcatList(recorded, segments)) return false; + if (!sameConcatList(recorded, segments, joins)) return false; for (const s of segments) { if ((await stat(s)).mtimeMs > st.mtimeMs) return false; } @@ -2400,9 +2880,12 @@ async function freshConcat(file, segments, total, fps) { return got != null && Math.abs(got - total) <= 1.5 / fps; } -/** Does a recorded concat list name exactly these segments, in this order? */ -export function sameConcatList(recorded, segments) { - return recorded != null && recorded === concatListText(segments); +/** + * Does a recorded concat list name exactly these segments, in this order, + * joined the same way (`joins`, the deck's holds and moves)? + */ +export function sameConcatList(recorded, segments, joins = null) { + return recorded != null && recorded === concatRecordText(segments, joins); } // ---- chapter markers ----------------------------------------------------- @@ -2435,6 +2918,8 @@ export async function segmentOffsets(segments, D, fps) { export async function chapterTitle(entry, index, provenance, { deck = false } = {}) { if (entry.chapter) return entry.chapter; if (deck && entry.onscreen?.title) return entry.onscreen.title; + // A teaser is named by its own words, with or without the deck. + if (entry.type === "teaser") return teaserTitle(entry) || `Teaser ${index + 1}`; // A still's chapter is the SAME line it burns into the header, for the reason // a clip's is: the chapter list and the picture are two views of one cut, and // a viewer jumping by chapter should land on the words they were shown. @@ -2475,6 +2960,13 @@ export async function chapterTitle(entry, index, provenance, { deck = false } = * @returns the schedule document (deck.mjs's shape) */ export async function writeChromeSchedule({ manifest, entries, segments, D, outDir }) { + const doc = await measureChromeSchedule({ manifest, entries, segments, D }); + await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); + return doc; +} + +/** writeChromeSchedule's document, not written: what `--chapters-only` measures the cut by. */ +export async function measureChromeSchedule({ manifest, entries, segments, D }) { const { render, provenance = {} } = manifest; const { durs } = await segmentOffsets(segments, D, render.fps); const metas = []; @@ -2491,14 +2983,14 @@ export async function writeChromeSchedule({ manifest, entries, segments, D, outD } // The variant's posts, placed on the clips this cut plays with their real // upload dates. A manifest without posts writes the schedule it always did. - const doc = deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); - await writeFile(path.join(outDir, "schedule.json"), JSON.stringify(doc, null, 2) + "\n"); - return doc; + return deckSchedule({ entries, durs, D, render, provenance, metas, posts: manifest.posts ?? [] }); } -async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false) { +async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps, deck = false, joins = null) { if (segments.length < 2) return; - const { starts, total } = await segmentOffsets(segments, D, fps); + // The cut's own offsets: under the deck a held clip is longer in the cut + // than its file, and every chapter after it starts that much later. + const { starts, total } = await cutOffsets(segments, D, fps, joins); const lines = [";FFMETADATA1", ""]; for (let i = 0; i < entries.length; i += 1) { // Land just PAST the crossfade, so the marker opens on the incoming clip @@ -2539,7 +3031,37 @@ async function muxChapters(finalPath, entries, segments, D, outDir, provenance, export const concatListText = (segments) => segments.map((s) => `file '${path.resolve(s)}'`).join("\n") + "\n"; -async function concatHardCut(segments, outDir, outPath, { record = false } = {}) { +/** + * What a cached hard-cut concat records it was made from: the list, and -- + * only when there are joins -- one line per joined input with its hold and + * move. Without joins it is the list alone, as it always was. + */ +export function concatRecordText(segments, joins = null) { + const list = concatListText(segments); + if (!joins) return list; + // `mute` and `fade` only when a join has them: a record made before they + // existed, of a cut without them, still matches. + const lines = segments.flatMap((s, i) => (joins[i] + ? [`# join ${i} ${JSON.stringify({ + hold: joins[i].hold, move: joins[i].move, + ...(joins[i].mute != null ? { mute: joins[i].mute } : {}), + ...(joins[i].fade ? { fade: joins[i].fade } : {}), + })}`] + : [])); + return list + lines.join("\n") + "\n"; +} + +/** + * The hard-cut concat. Without joins, the demuxer's stream copy, as always; + * with them (the deck's holds and moves) the concat filter over each input's + * chain, one encode -- the copy cannot host a filtergraph. + */ +async function concatHardCut(segments, outDir, outPath, { record = false, joins = null, render = null } = {}) { + if (joins) { + await execFileP(FFMPEG, hardCutFilterArgs(segments, joins, render, outPath), { maxBuffer: 1 << 26 }); + if (record) await writeFile(`${outPath}.segments`, concatRecordText(segments, joins), "utf8"); + return; + } const listPath = path.join(outDir, "concat.txt"); await writeFile(listPath, concatListText(segments), "utf8"); await execFileP( @@ -2593,6 +3115,12 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A `render.chrome` that cannot be built is refused here, before a single // fetch is spent. Absent, validateChrome has nothing to say. if (render.chrome !== undefined && render.chrome !== null) assertChrome(render.chrome, render); + // A clip's `muteFrom` and `render.endFade`, checked against the WHOLE + // manifest, deck or not: both are made where the cut is joined. + { + const errors = [...validateCutEdits(whole), ...validateTeasers(whole)]; + if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); + } const deck = deckOn(render); // Posts are drawn only under the deck, so only the deck refuses bad ones -- // against the WHOLE timeline, where an `attachTo` has to name a clip. @@ -2616,6 +3144,11 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly const dirs = variantPaths(outRoot, manifest.slug, variant); const outDir = dirs.dir; + // A project's out/ first, through ensureOutDir: with UMTOOL_MEDIA_DIR set it + // is a link to the media root, and the recursive mkdirs below would + // otherwise make it a real directory here. A dangling link refuses here, + // before a byte is fetched. + await ensureWriteDir(outRoot); await mkdir(dirs.rawDir, { recursive: true }); for (const d of ["cards", "segments", "qr"]) { await mkdir(path.join(outDir, d), { recursive: true }); @@ -2632,8 +3165,8 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A still has nothing to fetch and is already on disk, so this is a no-op // rather than an error: a bench that walks the timeline asking for each // entry's window should not have to know which kinds have one. - if (entry?.type === "image") { - EMIT("note", { message: `${fetchOnly} is an image entry — nothing to fetch` }); + if (entry?.type === "image" || entry?.type === "teaser") { + EMIT("note", { message: `${fetchOnly} is ${entry.type === "image" ? "an image" : "a teaser"} entry — nothing to fetch` }); EMIT("done", { out: null, nothingToFetch: true }); return { out: null, failures: [] }; } @@ -2717,7 +3250,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!(await exists(seg))) throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`); } - await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); + // Under the deck the cut's offsets include the holds: measured, not written. + const joins = deck ? segmentJoins(await measureChromeSchedule({ manifest, entries, segments: segs, D })) : null; + await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); return { out: finalPath, failures: [] }; } @@ -2734,8 +3269,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } if (!(await exists(prerail))) { EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length }); - if (D === 0) await concatHardCut(segs, outDir, prerail); - else await concatWithXfade(segs, render, prerail, null); + const joins = await cutJoins({ entries, segments: segs, render }); + if (D === 0) await concatHardCut(segs, outDir, prerail, { joins, render }); + else await concatWithXfade(segs, render, prerail, null, null, joins); } const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir); await assertConcatLength(prerail, railPlan.total, render.fps, @@ -2765,6 +3301,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (!deck) throw new Error(`${what} needs the deck: render.chrome is not set in the manifest`); if (opts.noChrome) throw new Error(`${what} and --no-chrome contradict each other`); if (only) throw new Error(`${what} lays the deck over the whole cut; --only does not apply`); + // A teaser's segment is chrome too -- graphics made from the manifest's + // words, nothing fetched -- so it is (re)built here: re-rendered and + // re-encoded only when its words, motion or sound changed. + for (const e of entries) { + if (e.type !== "teaser") continue; + EMIT("card", { id: e.id, i: entries.indexOf(e), n: entries.length }); + await buildTeaserSegment(e, { manifestPath, render, outDir, variant }); + } const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`)); for (const seg of segs) { if (!(await exists(seg))) @@ -2772,6 +3316,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); + // The holds, the moves, the mutes and the end fade, joined on their + // inputs (null without any). + const joins = await cutJoins({ schedule, entries, segments: segs, render }); const prerail = prerailPath(outDir, manifest.slug, D); if (opts.chromePreview) { @@ -2781,14 +3328,14 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly manifestPath, render, outDir, variant, schedule, from: at, duration: dur, }); const out = path.join(outDir, `${manifest.slug}.preview.mp4`); - if (await freshConcat(prerail, segs, schedule.total, render.fps)) { + if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("chrome", { phase: "overlay", base: path.basename(prerail) }); await applyChrome(prerail, out, render, plan, { start: at, dur }); } else { - const { starts, durs } = await segmentOffsets(segs, D, render.fps); + const { starts, durs } = await cutOffsets(segs, D, render.fps, joins); EMIT("chrome", { phase: "overlay", base: "segments" }); await execFileP(FFMPEG, previewFromSegmentsArgs({ - segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, + segments: segs, durs, starts, D, at, dur, render, chromePlan: plan, outPath: out, joins, }), { maxBuffer: 1 << 26 }); } EMIT("done", { out, failures: [] }); @@ -2802,19 +3349,19 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // The hard-cut concat is a stream copy of these very segments; when // nothing changed since it was made it is reused, and the overlay is // the only encode. - if (await freshConcat(prerail, segs, schedule.total, render.fps)) { + if (await freshConcat(prerail, segs, schedule.total, render.fps, joins)) { EMIT("note", { message: `reusing ${path.basename(prerail)}` }); } else { - await concatHardCut(segs, outDir, prerail, { record: true }); + await concatHardCut(segs, outDir, prerail, { record: true, joins, render }); } await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, dirs.final, render, plan, null); } else { - await concatWithXfade(segs, render, dirs.final, null, plan); + await concatWithXfade(segs, render, dirs.final, null, plan, joins); } await assertConcatLength(dirs.final, schedule.total, render.fps, "deck build"); // The overlay re-encodes, so the chapters on the previous final are gone. - if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render)); + if (!opts.noChapters) await muxChapters(dirs.final, entries, segs, D, outDir, provenance, render.fps, deckOn(render), joins); EMIT("done", { out: dirs.final, failures: [] }); return { out: dirs.final, failures: [] }; } @@ -2826,6 +3373,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly if (entry.type === "card") { EMIT("card", { id: entry.id, i, n: entries.length }); segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes)); + } else if (entry.type === "teaser") { + EMIT("card", { id: entry.id, i, n: entries.length }); + segments.push(await buildTeaserSegment(entry, { manifestPath, render, outDir, variant })); } else if (entry.type === "image") { // `card`, not a new event name: umtool's activity feed and build chain // key off this one to mean "a segment that needs no network", and a @@ -2895,6 +3445,9 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); } + // The deck's holds and moves, each clip's muteFrom and the end fade, joined + // on their inputs; null without any, which leaves every concat as it was. + const joins = await cutJoins({ schedule: deck ? schedule : null, entries, segments, render }); const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir); @@ -2940,21 +3493,21 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // has to be a second pass here whether we like it or not. const prerail = prerailPath(outDir, manifest.slug, D); if (railPlan) { - await concatHardCut(segments, outDir, prerail); + await concatHardCut(segments, outDir, prerail, { joins, render }); await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat"); await applyRail(prerail, final, render, railPlan, null); } else if (chromePlan) { // Only the deck reaches here (the band refused above, and the deck // refuses a rail). Hard-cut concat to the prerail, then ONE overlay // re-encode to the final. - await concatHardCut(segments, outDir, prerail, { record: true }); + await concatHardCut(segments, outDir, prerail, { record: true, joins, render }); await assertConcatLength(prerail, schedule.total, render.fps, "hard-cut concat"); await applyChrome(prerail, final, render, chromePlan, null); } else { - await concatHardCut(segments, outDir, final); + await concatHardCut(segments, outDir, final, { joins, render }); } } else { - await concatWithXfade(segments, render, final, railPlan, chromePlan); + await concatWithXfade(segments, render, final, railPlan, chromePlan, joins); } // The deck's sequence is laid with shortest=1, so a sequence a frame short // would shorten the cut without a word; the schedule is the length to hold. @@ -2965,7 +3518,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // shortest=1), and a hang means an unbounded -loop 1. if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build"); - if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render)); + if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps, deckOn(render), joins); // A branded cut with a `thumbnail` gets one beside it. The cut is already // done, so a thumbnail that cannot be made is said, not thrown. diff --git a/umtool/report-to-video/check-availability.mjs b/umtool/report-to-video/check-availability.mjs @@ -22,10 +22,11 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_CHANNELS_DIR } from "./cues.mjs"; +import { ensureWriteDir } from "../lib/report/storage.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; @@ -149,7 +150,8 @@ export async function checkAvailability(manifestPath, { outDir, maxAgeDays = 0 } } const report = { manifest: path.resolve(manifestPath), checkedAt: new Date().toISOString(), sources }; - await mkdir(dir, { recursive: true }); + // ensureWriteDir, not mkdir: a project's out/ may belong on the media root. + await ensureWriteDir(dir); await writeFile(file, JSON.stringify(report, null, 2) + "\n", "utf8"); return { ...report, file }; } diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs @@ -66,6 +66,9 @@ const esc = (s) => const r4 = (v) => Math.round(v * 10000) / 10000; +/** The tracking of the QR's host label, in em: part of the length `fitHost` fits to the code. */ +export const HOST_TRACKING = 0.1; + /** The host a QR resolves to -- the one thing on the tile a viewer cannot read off the code. */ export function hostOf(url) { try { @@ -371,6 +374,9 @@ export function deckHtml(schedule, render, opts = {}) { window: windowed ? { from, dur } : null, titleSize: t.titleSize, floor: Math.ceil(t.titleSize * 0.6), + // The QR's host label is fitted to run the code's full height. + hostLength: qr?.size ?? 0, + hostTracking: HOST_TRACKING, ids: schedule.segments.map((s) => s.id), init, cues: cues.map(({ why, ...c }) => c), @@ -455,10 +461,13 @@ export function deckHtml(schedule, render, opts = {}) { height: 4px; border-radius: 2px; background: ${pal.accent}; } .deck-qr { position: absolute; left: ${qr?.x ?? 0}px; top: ${qr?.y ?? 0}px; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; transform-style: preserve-3d; } + /* The QR's host, reading up its left side. fitHost sizes it at load so + its ink runs the code's full height, bottom edge to top edge; 11px is + only what shows before the face is in. */ .deck-qr-host { position: absolute; left: ${(qr?.x ?? 0) - 30}px; top: ${qr?.y ?? 0}px; width: 18px; height: ${qr?.size ?? 0}px; writing-mode: vertical-rl; transform: rotate(180deg); - text-align: left; white-space: nowrap; font-size: 11px; line-height: 18px; - letter-spacing: 0.1em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } + text-align: start; white-space: nowrap; font-size: 11px; line-height: 18px; + letter-spacing: ${HOST_TRACKING}em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } .deck-qr img { display: block; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; border-radius: 6px; image-rendering: pixelated; backface-visibility: hidden; box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); } @@ -526,10 +535,36 @@ export function deckHtml(schedule, render, opts = {}) { } } const fitAll = () => document.querySelectorAll(".deck-title").forEach(fitTitle); + + // The QR's host runs up beside the code, and its INK is exactly as long + // as the code is tall, whatever the host. Every length in it scales with + // the font size (the tracking is in em), so one measurement in the + // loaded face solves it: the string's ink at a reference size, plus the + // tracking between its letters (not after the last), scaled to the + // code's height. The first letter's side bearing is indented away, so + // the ink starts on the code's bottom edge and ends on its top. + const hostCanvas = document.createElement("canvas").getContext("2d"); + function fitHost(node) { + // Measured as drawn: the CSS uppercases it. + const text = node.textContent.toUpperCase(); + if (!text || !(D.hostLength > 0)) return; + const ref = 100; + hostCanvas.font = ref + "px DeckSans"; + const m = hostCanvas.measureText(text); + const ink = m.actualBoundingBoxLeft + m.actualBoundingBoxRight + D.hostTracking * ref * (text.length - 1); + if (!(ink > 0)) return; + const k = D.hostLength / ink; + node.style.fontSize = ref * k + "px"; + node.style.textIndent = m.actualBoundingBoxLeft * k + "px"; + } const ready = Promise.all([ document.fonts.load(D.titleSize + "px DeckSansBold"), document.fonts.load("26px DeckSans"), - ]).catch(() => {}).then(() => { fitAll(); document.documentElement.dataset.fit = "1"; }); + ]).catch(() => {}).then(() => { + fitAll(); + document.querySelectorAll(".deck-qr-host").forEach(fitHost); + document.documentElement.dataset.fit = "1"; + }); const params = new URLSearchParams(location.search); // The review still: a seek and nothing else -- the same seek the renderer diff --git a/umtool/report-to-video/chrome-deck.test.mjs b/umtool/report-to-video/chrome-deck.test.mjs @@ -307,3 +307,40 @@ appendFileSync(${JSON.stringify(path.join(dir, "runs.log"))}, a.join(" ") + "\\n rmSync(dir, { recursive: true, force: true }); } }); + +const haveChromium = haveTools && spawnSync(process.env.CHROME ?? "/usr/bin/chromium", ["--version"], { stdio: "ignore" }).status === 0; + +test("the QR's host label: its ink runs the code's full height, top edge to bottom edge, for a long host and a short one", + { skip: !haveChromium }, async () => { + const { composeChrome } = await import("./compose-chrome.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-host-")); + try { + const manifest = { + slug: "t", title: "t", provenance: {}, + render: { ...RENDER, fontRegular: path.join(HERE, "fonts", "IBMPlexMono-Regular.ttf"), fontBold: path.join(HERE, "fonts", "IBMPlexMono-Bold.ttf") }, + timeline: [], + }; + const manifestPath = path.join(dir, "video.manifest.json"); + writeFileSync(manifestPath, JSON.stringify(manifest)); + const qr = deckLayout(RENDER).qr; + // c01's code goes to jasolyzer.pages.dev, c03's to youtube.com. + for (const [t, host] of [[9, "jasolyzer.pages.dev"], [28, "youtube.com"]]) { + const png = path.join(dir, `still-${t}.png`); + await composeChrome({ manifestPath, region: "deck", schedule: schedule(), still: t, png }); + // The column beside the code, as 8-bit grey rows, against the plate's own shade a little left of it. + const cols = { x: qr.x - 40, w: 36 }; + const raw = spawnSync("magick", [png, "-crop", `${cols.w}x190+${cols.x}+0`, "+repage", "-colorspace", "gray", "-depth", "8", "gray:-"], { maxBuffer: 1 << 24 }).stdout; + const rows = []; + for (let y = 0; y < 190; y += 1) { + const ref = raw[y * cols.w]; + for (let x = 4; x < cols.w; x += 1) if (raw[y * cols.w + x] - ref > 25) { rows.push(y); break; } + } + assert.ok(rows.length, `${host}: no label found`); + const top = Math.min(...rows), bottom = Math.max(...rows); + assert.ok(Math.abs(top - qr.y) <= 1, `${host}: ink starts at ${top}, the code at ${qr.y}`); + assert.ok(Math.abs(bottom - (qr.y + qr.size - 1)) <= 1, `${host}: ink ends at ${bottom}, the code at ${qr.y + qr.size - 1}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs @@ -53,10 +53,13 @@ const r4 = (v) => Math.round(v * 10000) / 10000; export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" }); /** - * The motion. Seconds; `rise` is how far (px) a card comes up as it fades in, - * `gap` the space between two cards in the column. + * The motion. Seconds. A card slides in from the frame's edge over `enter`; + * its accent glow flares to full over `glowUp`, starting `glowAt` into the + * entrance, then settles to `glowRest` over `glowDown`. `gap` is the space between two cards in the column. */ -export const POSTS_MOTION = Object.freeze({ enter: 0.35, slide: 0.35, rise: 18, gap: 14 }); +export const POSTS_MOTION = Object.freeze({ + enter: 0.55, slide: 0.35, gap: 14, glowAt: 0.3, glowUp: 0.18, glowDown: 1.1, glowRest: 0.3, +}); /** The schedule's posts for one window's segment, in slot order. */ export function windowPosts(schedule, segment) { @@ -82,16 +85,21 @@ export function embedFn(name, fn) { * * `posts` are `[{ id, appear, out: [a, b] }]` in slot order (oldest first), * times in the CUT's clock; `heights` are the cards' heights in px; `column` - * is the region's height. Card j enters at its `appear` (a fade and a rise of - * `rise` px over `enter` s); cards stack top-down `gap` apart; when card j - * would overflow the column, the oldest slide up and out (the whole stack - * moves up over `slide` s, the departing cards fading as they go); every card - * still up leaves over its `out` (a front-loaded fade and a slight shrink). + * is the region's height. Card j slides in at its `appear` from `enterX` px + * to the side (the frame's edge) over `enter` s, and its glow (`g<j>`) flares + * as it lands and settles to `glowRest`; cards stack top-down `gap` apart; + * when card j would overflow the column, the oldest slide up and out (the + * whole stack moves up over `slide` s, the departing cards fading as they go); + * every card still up leaves over its `out` (a front-loaded fade and a slight + * shrink). * * @returns {{ tops: number[], init: Record<string, object>, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} */ -export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slide = 0.35, rise = 18 }) { +export function postsCues({ + posts, heights, column, gap = 14, enter = 0.55, slide = 0.35, enterX = 624, + glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3, +}) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; const tops = []; @@ -101,7 +109,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid acc += (heights[j] || 0) + gap; } const init = { stack: { y: 0 } }; - for (let j = 0; j < posts.length; j += 1) init[`c${j}`] = { autoAlpha: 0, y: rise, scale: 1 }; + for (let j = 0; j < posts.length; j += 1) { + init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 }; + init[`g${j}`] = { opacity: 0 }; + } const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why }); @@ -120,7 +131,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid add("stack", t, slide, { y: -shift }, "power2.inOut", `slide for ${p.id}`); for (const g of gone) add(`c${g}`, t, slide, { autoAlpha: 0 }, "power1.in", `slide for ${p.id}`); } - add(`c${j}`, t, enter, { autoAlpha: 1, y: 0 }, "power3.out", `enter ${p.id}`); + add(`c${j}`, t, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${p.id}`); + // The flare, as the card lands; then it settles to a quiet rim. + add(`g${j}`, t + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${p.id}`); + add(`g${j}`, t + glowAt + glowUp, glowDown, { opacity: glowRest }, "power2.inOut", `settle ${p.id}`); visible.push(j); } for (const j of visible) { @@ -209,11 +223,12 @@ export function postsHtml(schedule, render, window, opts = {}) { const pad = 18; const plateW = set.qrSize + 2 * pad; - const metaSize = 17; - const textSize = 23; + const rail = 6; + const metaSize = 18; + const textSize = 24; const lineH = Math.round(textSize * 1.36); - const top = mix(pal.bg, pal.fg, 0.095); - const bottom = mix(pal.bg, pal.fg, 0.04); + const top = mix(mix(pal.bg, pal.fg, 0.1), pal.accent, 0.07); + const bottom = mix(mix(pal.bg, pal.fg, 0.045), pal.accent, 0.04); const cardHtml = posts .map((p, j) => { @@ -221,16 +236,17 @@ export function postsHtml(schedule, render, window, opts = {}) { const src = qrSrcs[p.id]; return ( `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` + - `<div class="edge"></div>` + `<div class="body">` + - `<div class="meta"><span class="who"><span class="handle">${esc(name)}</span>` + - (platform ? `<span class="sep">·</span><span class="platform">${esc(platform)}</span>` : "") + - `</span><span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + + `<div class="meta">` + + (platform ? `<span class="platform">${esc(platform)}</span>` : "") + + `<span class="who"><span class="handle">${esc(name)}</span></span>` + + `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + `</div>` + `<div class="plate">` + (src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") + `</div>` + + `<div class="glow" data-k="g${j}"></div>` + `</article>` ); }) @@ -247,7 +263,13 @@ export function postsHtml(schedule, render, window, opts = {}) { gap: POSTS_MOTION.gap, enter: POSTS_MOTION.enter, slide: POSTS_MOTION.slide, - rise: POSTS_MOTION.rise, + // From just past the frame's own edge on the column's side, so a card + // comes in from outside the picture, not out of the region's boundary. + enterX: set.position === "top-left" ? -(geo.x + W) : (render.width ?? 1920) - geo.x, + glowAt: POSTS_MOTION.glowAt, + glowUp: POSTS_MOTION.glowUp, + glowDown: POSTS_MOTION.glowDown, + glowRest: POSTS_MOTION.glowRest, ids: posts.map((p) => p.id), posts: posts.map((p) => ({ id: p.id, appear: p.appear, out: p.out })), }; @@ -275,30 +297,36 @@ export function postsHtml(schedule, render, window, opts = {}) { #posts-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } .stack { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } /* Hidden until the timeline places it: a frame taken before the faces - are in shows nothing rather than an unplaced card. */ + are in shows nothing rather than an unplaced card. A card is an + interruption, not a caption: lifted off the ground with a touch of + the accent, an accent rail down its leading edge. */ .post { position: absolute; left: 0; top: 0; width: ${W}px; min-height: ${set.qrSize + 2 * pad}px; visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden; + border-left: ${rail}px solid ${pal.accent}; background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%); - box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; transform-origin: 50% 0%; } - /* The deck's top edge, the same hairline: the accent in from the left, - settling to a quiet rule. */ - .edge { position: absolute; left: 0; top: 0; width: ${W}px; height: 2px; - background: linear-gradient(90deg, ${rgba(pal.accent, 0.95)} 0px, ${rgba(pal.accent, 0.4)} ${Math.round(W * 0.28)}px, - ${rgba(pal.fg, 0.14)} ${Math.round(W * 0.62)}px, ${rgba(pal.fg, 0.14)} ${W}px); } - .body { position: relative; width: ${W - plateW}px; padding: ${pad}px ${pad + 4}px ${pad + 2}px ${pad + 4}px; } - .meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; + box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.12)}; transform-origin: 50% 0%; } + /* The flare as a card lands, settling to a quiet accent rim. Last in the + card, over the QR's cell -- its blur stays inside the cell's padding, + clear of the code. */ + .glow { position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0; pointer-events: none; + box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.95)}, inset 0 0 14px ${rgba(pal.accent, 0.55)}; } + .body { position: relative; width: ${W - plateW - rail}px; padding: ${pad - 4}px ${pad + 2}px ${pad - 3}px ${pad + 2}px; } + .meta { display: flex; align-items: center; gap: 10px; font-size: ${metaSize}px; line-height: ${Math.round(metaSize * 1.3)}px; white-space: nowrap; } - .who { overflow: hidden; text-overflow: ellipsis; min-width: 0; } + .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; letter-spacing: 0.005em; } - .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; } - .platform { color: ${pal.muted}; } + /* Which platform, as a label you cannot miss. */ + .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: ${metaSize - 3}px; + line-height: ${metaSize + 3}px; letter-spacing: 0.03em; color: ${pal.fg}; + padding: 1px 10px 2px; border-radius: 999px; + background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; } .date { color: ${pal.muted}; flex: none; font-variant-numeric: tabular-nums; } - .text { margin-top: 10px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; } + .text { margin-top: 8px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; } /* Each paragraph is clamped to what is left of maxLines when the page measures (clampText), so the ellipsis always ends words, never a blank line. */ .para { white-space: pre-line; overflow-wrap: anywhere; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; } - .para + .para { margin-top: ${Math.round(lineH * 0.42)}px; } + .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; } .para.gone { display: none; } /* The source cell: the QR in a cell a shade down, as on the deck. */ .plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px; @@ -359,14 +387,15 @@ export function postsHtml(schedule, render, window, opts = {}) { } const ready = Promise.all([ - document.fonts.load("23px DeckSans"), - document.fonts.load("17px DeckSansBold"), + document.fonts.load("${textSize}px DeckSans"), + document.fonts.load("${metaSize}px DeckSansBold"), ]).catch(() => {}).then(() => { const cards = P.ids.map((_, j) => byK["c" + j]); cards.forEach(clampText); const heights = cards.map((c) => c.offsetHeight); const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap, - enter: P.enter, slide: P.slide, rise: P.rise }); + enter: P.enter, slide: P.slide, enterX: P.enterX, glowAt: P.glowAt, + glowUp: P.glowUp, glowDown: P.glowDown, glowRest: P.glowRest }); cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; }); for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]); // The cues are in the cut's clock; the window plays [from, from + dur] of it. diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs @@ -140,11 +140,24 @@ test("the page's times are postSchedule's: data, enter and leave cues", () => { assert.equal(d.from, win.from); near(d.dur, win.to - win.from, "the window's length"); // Whatever the heights, each card enters at its appear and leaves over its out. - const plan = postsCues({ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, rise: d.rise }); + const plan = postsCues({ + posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, + enterX: d.enterX, glowAt: d.glowAt, glowUp: d.glowUp, glowDown: d.glowDown, glowRest: d.glowRest, + }); + // The column is at the frame's right edge: a card comes in from past it. + assert.equal(d.enterX, 1920 - postsGeometry(RENDER).x); posts.forEach((p, j) => { const enter = plan.cues.find((c) => c.k === `c${j}` && c.why === `enter ${p.id}`); near(enter.at, p.appear, `${p.id} enters`); - assert.deepEqual(enter.to, { autoAlpha: 1, y: 0 }); + assert.deepEqual(enter.from, { autoAlpha: 0, x: d.enterX }); + assert.deepEqual(enter.to, { autoAlpha: 1, x: 0 }); + // The glow flares as it lands and settles, never to nothing while the card is up. + const flare = plan.cues.find((c) => c.k === `g${j}` && c.why === `glow ${p.id}`); + const settle = plan.cues.find((c) => c.k === `g${j}` && c.why === `settle ${p.id}`); + near(flare.at, p.appear + d.glowAt, `${p.id} flares`); + assert.deepEqual([flare.from, flare.to], [{ opacity: 0 }, { opacity: 1 }]); + assert.deepEqual([settle.from, settle.to], [{ opacity: 1 }, { opacity: d.glowRest }]); + assert.ok(d.glowRest > 0); const leave = plan.cues.find((c) => c.k === `c${j}` && c.why === `leave ${p.id}`); near(leave.at, p.out[0], `${p.id} leaves`); near(leave.at + leave.dur, p.out[1], `${p.id} gone`); diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -0,0 +1,397 @@ +// The teaser's composition: one full-frame HyperFrames page per `teaser` +// entry -- a season teaser's "coming soon" card, its words the manifest's. +// +// PURE, like chrome-deck.mjs: an entry and a render block in, an HTML string +// out. compose-chrome.mjs copies the face and GSAP in beside it, writes it and +// renders it; the build encodes the frames into the entry's segment. +// +// --------------------------------------------------------------------------- +// Why it is reached only through a dynamic import +// --------------------------------------------------------------------------- +// TEASER_FONT_FILE is `new URL(…, import.meta.url)`, which umtool's bundler +// turns into an asset reference. build-video must not import a page module at +// load (docs/quirks.md), and compose-chrome -- which umtool's preview helper +// imports statically -- loads this one only when a teaser is composed. +// +// --------------------------------------------------------------------------- +// Why the timeline is a cue list computed here +// --------------------------------------------------------------------------- +// The deck's reason (chrome-deck.mjs): a render is a seek per frame, from +// parallel workers, in any order. Every cue is a fromTo whose FROM is stated, +// carried forward from the cue before it on the same element; the page is a +// dumb interpreter of `teaserCues`, so the tests read every time it uses. +// The blur is a CSS variable (`--blur`) read by `filter`, tweened like any +// other number; the grain's jitter is a seeded sequence of instant sets. +import { fileURLToPath } from "node:url"; + +import { TEASER_MOTION, teaserLines, teaserTail, teaserTimes } from "./deck.mjs"; + +export { TEASER_MOTION }; +import { mix, rgba } from "./chrome-deck.mjs"; + +/** + * The display face: Archivo, a variable font (wght 100–900, wdth 62–125), + * vendored beside the cards' faces. Copied in as `assets/TeaserDisplay.ttf` + * under a private family name, as every chrome face is. + */ +export const TEASER_FONT_FILE = fileURLToPath(new URL("./fonts/Archivo[wdth,wght].ttf", import.meta.url)); + +/** The face's name in the page and in the project's assets. */ +export const TEASER_FONT_ASSET = "assets/TeaserDisplay.ttf"; + +/** Instant cues still take a millisecond, as on the deck. */ +const INSTANT = 0.001; + +const r4 = (v) => Math.round(v * 10000) / 10000; + +const esc = (s) => + String(s ?? "") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); + +/** A small seeded PRNG (mulberry32): the grain jitters the same way on every seek of every render. */ +export function seeded(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Everything the teaser's timeline does, as data. + * + * `lines` are `teaserLines(entry)`, `tail` the tail ("" for none), `seconds` + * the card's length. Keys name elements by `data-k`: `stage` (the slow push-in + * over the whole card), `barT`/`barB` (the letterbox closing in), `leak` (a + * soft light drifting across), `grain`, and per line i `l<i>.o` (its + * visibility), `l<i>` (the slam's scale), `l<i>.t` (its blur), `l<i>.flash`, + * `l<i>.streak`, `l<i>.rules` (an overline's accent rules), `l<i>.sub` and + * `l<i>.subt` (the second tier); `tail`, `tail.t`, `tail.glow`. + * + * @returns {{ init: Record<string, object>, cues: Array<{ k: string, at: number, dur: number, + * from: object, to: object, ease: string, why: string }>, beats: object, scale: number }} + */ +export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION }) { + const m = motion; + // The times are deck.mjs's, the same the build places the hits by. + const beats = teaserTimes(lines, tail, seconds, m); + const { T } = beats; + + const init = {}; + const put = (k, v) => { init[k] = { ...(init[k] ?? {}), ...v }; }; + const ev = []; + const add = (k, at, dur, to, ease, why) => ev.push({ k, at: r4(at), dur: r4(Math.max(INSTANT, dur)), to, ease, why }); + + // ---- the ground: letterbox, push-in, light, grain ---------------------- + put("stage", { scale: 1 }); + add("stage", 0, seconds, { scale: m.push }, "none", "push-in"); + put("barT", { yPercent: -100 }); + put("barB", { yPercent: 100 }); + add("barT", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); + add("barB", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); + put("leak", { x: -420, autoAlpha: 0 }); + add("leak", 0, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); + add("leak", T(1.4), Math.max(INSTANT, seconds - T(1.4)), { x: 420 }, "none", "leak drift"); + const rnd = seeded(0x7ea5e); + put("grain", { x: 0, y: 0 }); + const steps = Math.floor(seconds * m.grainHz); + for (let s = 1; s < steps; s += 1) { + add("grain", s / m.grainHz, INSTANT, { x: Math.round((rnd() - 0.5) * 360), y: Math.round((rnd() - 0.5) * 220) }, "none", "grain"); + } + + // ---- the lines, top to bottom ------------------------------------------ + lines.forEach((l, i) => { + const b = beats.lines[i]; + const why = `line ${i}`; + const slam = l.role === "overline" ? 1 + (m.slam - 1) * 0.6 : m.slam; + put(`l${i}.o`, { autoAlpha: 0 }); + put(`l${i}`, { scale: slam }); + put(`l${i}.t`, { "--blur": `${m.blur}px` }); + put(`l${i}.flash`, { autoAlpha: 0, scaleX: 0.55 }); + put(`l${i}.streak`, { autoAlpha: 0, scaleX: 0 }); + add(`l${i}.o`, b.at, T(0.12), { autoAlpha: 1 }, "power1.out", `${why} in`); + // The slam: down past rest by the hit, then a soft settle up to it. + add(`l${i}`, b.at, T(m.hit), { scale: m.under }, "power3.in", `${why} slam`); + add(`l${i}`, b.at + T(m.hit), T(m.settle), { scale: 1 }, "power2.out", `${why} settle`); + add(`l${i}.t`, b.at, T(m.hit + 0.12), { "--blur": "0px" }, "power2.out", `${why} focus`); + // The hit: a flash of the accent behind the words and a streak through them. + const hit = b.impact; + add(`l${i}.flash`, hit - T(0.04), T(0.08), { autoAlpha: 1, scaleX: 1 }, "power2.out", `${why} flash`); + add(`l${i}.flash`, hit + T(0.04), T(0.75), { autoAlpha: 0, scaleX: 1.25 }, "power2.out", `${why} flash out`); + add(`l${i}.streak`, hit - T(0.06), T(0.32), { autoAlpha: 1, scaleX: 1 }, "expo.out", `${why} streak`); + add(`l${i}.streak`, hit + T(0.26), T(0.5), { autoAlpha: 0 }, "power2.in", `${why} streak out`); + if (l.role === "overline") { + put(`l${i}.rules`, { scaleX: 0 }); + add(`l${i}.rules`, hit - T(0.04), T(0.6), { scaleX: 1 }, "expo.out", `${why} rules`); + } + if (l.sub && b.subAt != null) { + put(`l${i}.sub`, { autoAlpha: 0, y: 16, scale: 1.12 }); + put(`l${i}.subt`, { "--blur": "10px" }); + add(`l${i}.sub`, b.subAt, T(0.5), { autoAlpha: 1, y: 0, scale: 1 }, "expo.out", `${why} second tier`); + add(`l${i}.subt`, b.subAt, T(0.32), { "--blur": "0px" }, "power2.out", `${why} second tier focus`); + } + }); + + // ---- the tail: slowly, on its own, after the last line has settled ------ + if (tail && beats.tailAt != null) { + const d = beats.tailDur; + put("tail", { autoAlpha: 0, scale: 1.18 }); + put("tail.t", { "--blur": "12px" }); + put("tail.glow", { autoAlpha: 0 }); + add("tail", beats.tailAt, d, { autoAlpha: 1, scale: 1 }, "sine.inOut", "tail"); + add("tail.t", beats.tailAt, d * 0.85, { "--blur": "0px" }, "power2.out", "tail focus"); + add("tail.glow", beats.tailAt + d * 0.3, d * 0.9, { autoAlpha: 1 }, "sine.inOut", "tail glow"); + } + + // ---- order, clamp, state the froms (the deck's walk) -------------------- + ev.forEach((e, n) => { e.n = n; }); + ev.sort((x, y) => x.at - y.at || x.n - y.n); + const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); + const freeAt = new Map(); + const cues = []; + for (const e of ev) { + const free = freeAt.get(e.k) ?? 0; + let { at, dur } = e; + if (at < free) { + const end = at + dur; + at = r4(free); + dur = r4(Math.max(INSTANT, end - at)); + } + const cur = state[e.k] ?? (state[e.k] = {}); + const from = {}; + for (const p of Object.keys(e.to)) from[p] = cur[p]; + Object.assign(cur, e.to); + freeAt.set(e.k, r4(at + dur)); + cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); + } + const { T: _T, ...times } = beats; + return { init, cues, beats: times, scale: beats.scale }; +} + +/** Each role's type: size (px, the most it may be), weight, width (%), tracking (em), and the fit floor. */ +export const TEASER_TYPE = Object.freeze({ + overline: Object.freeze({ size: 34, weight: 600, stretch: 125, tracking: 0.48, floor: 18 }), + title: Object.freeze({ size: 148, weight: 900, stretch: 112, tracking: -0.006, floor: 56 }), + sub: Object.freeze({ size: 38, weight: 600, stretch: 125, tracking: 0.4, floor: 18 }), + kicker: Object.freeze({ size: 84, weight: 800, stretch: 118, tracking: 0.04, floor: 32 }), +}); + +/** + * The teaser composition's HTML: 1920×1080 (the render's frame), opaque, + * `seconds` long. `font` is the display face's asset path, `gsap` the + * vendored script's. `?still=<t>` seeks to t and holds, as the deck's does. + */ +export function teaserHtml(entry, render, opts = {}) { + const pal = render.palette; + const W = render.width ?? 1920; + const H = render.height ?? 1080; + const seconds = Number(entry.seconds); + if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`); + const font = opts.font ?? TEASER_FONT_ASSET; + const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; + const lines = teaserLines(entry); + if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`); + const tail = teaserTail(entry); + const { init, cues, beats } = teaserCues({ lines, tail, seconds }); + + // The ground: the palette's bg, lifted a touch toward the accent at the + // centre and falling toward black at the edges. + const black = "#000000"; + const core = mix(mix(pal.bg, pal.accent, 0.13), pal.fg, 0.02); + const mid = pal.bg; + const edge = mix(pal.bg, black, 0.62); + const bar = mix(pal.bg, black, 0.72); + const barH = Math.round(H * 0.105); + const maxW = Math.round(W * 0.8); + const ty = TEASER_TYPE; + + const lineHtml = lines + .map((l, i) => { + const k = `l${i}`; + const isLast = i === lines.length - 1; + const rules = l.role === "overline" + ? `<div class="rules" data-k="${k}.rules"><span class="rule l"></span><span class="rule r"></span></div>` + : ""; + const tailHtml = isLast && tail + ? `<span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` + + `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span>` + : ""; + return ( + `<div class="line ${l.role}" data-line="${i}" data-role="${l.role}" data-k="${k}.o">` + + `<div class="flash" data-k="${k}.flash"></div>` + + `<div class="streak" data-k="${k}.streak"></div>` + + rules + + `<div class="pop" data-k="${k}"><div class="row">` + + `<span class="txt" data-k="${k}.t">${esc(l.head)}</span>${l.sub ? "" : tailHtml}</div></div>` + + (l.sub + ? `<div class="sub" data-k="${k}.sub"><div class="row"><span class="subt" data-k="${k}.subt">${esc(l.sub)}</span>${tailHtml}</div></div>` + : "") + + `</div>` + ); + }) + .join("\n "); + + const data = { + seconds, + maxW, + init, + cues: cues.map(({ why, ...c }) => c), + beats, + floors: Object.fromEntries(Object.entries(ty).map(([r, t]) => [r, t.floor])), + }; + // `</script>` in a JSON string would close the tag; the words may say anything. + const json = JSON.stringify(data).replace(/</g, "\\u003c"); + const typeCss = (sel, t) => + `${sel} { font-size: ${t.size}px; font-weight: ${t.weight}; font-stretch: ${t.stretch}%; letter-spacing: ${t.tracking}em; }`; + + return `<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=${W}, height=${H}" /> + <script src="${esc(gsapSrc)}"></script> + <style> + /* One variable face under a private name, the real file beside the page: + a bare local() falls back silently in the render browser, and a real + family name in the stack is fetched from the network (docs/quirks.md). */ + @font-face { font-family: 'TeaserDisplay'; font-style: normal; font-weight: 100 900; font-stretch: 62% 125%; + src: url('${esc(font)}'); } + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: ${pal.bg}; } + body { font-family: 'TeaserDisplay', sans-serif; font-synthesis: none; color: ${pal.fg}; + -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; } + #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; } + #teaser-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; overflow: hidden; } + .ground { position: absolute; inset: 0; + background: radial-gradient(ellipse 62% 58% at 50% 47%, ${core} 0%, ${mid} 58%, ${edge} 100%); } + .stage { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; transform-origin: 50% 48%; } + .leak { position: absolute; left: ${Math.round(W * 0.08)}px; top: ${Math.round(-H * 0.32)}px; + width: ${Math.round(W * 0.7)}px; height: ${Math.round(H * 0.95)}px; border-radius: 50%; + background: radial-gradient(ellipse at center, ${rgba(pal.accent, 0.2)} 0%, ${rgba(pal.amber ?? pal.accent, 0.06)} 45%, ${rgba(pal.accent, 0)} 70%); + filter: blur(30px); mix-blend-mode: screen; } + .column { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; + display: flex; flex-direction: column; align-items: center; justify-content: center; } + .line { position: relative; display: flex; flex-direction: column; align-items: center; max-width: ${maxW}px; } + .pop, .sub { display: block; transform-origin: 50% 55%; } + .row { display: flex; align-items: baseline; justify-content: center; white-space: nowrap; } + .txt, .subt, .tail-t { display: inline-block; filter: blur(var(--blur, 0px)); } + .txt, .subt { text-transform: uppercase; white-space: nowrap; } + ${typeCss(".overline > .pop > .row", ty.overline)} + .overline .txt { color: ${mix(pal.muted, pal.fg, 0.4)}; padding-left: ${ty.overline.tracking}em; } + ${typeCss(".title > .pop > .row", ty.title)} + .title > .pop > .row { line-height: 1.02; } + .title .txt { color: ${pal.fg}; } + ${typeCss(".sub > .row", ty.sub)} + .sub > .row { line-height: 1.2; } + .sub .subt { color: ${mix(pal.fg, pal.muted, 0.25)}; padding-left: ${ty.sub.tracking}em; } + ${typeCss(".kicker > .pop > .row", ty.kicker)} + .kicker > .pop > .row { line-height: 1.1; } + .kicker .txt { color: ${pal.fg}; } + .overline { margin-bottom: 38px; } + .title + .title { margin-top: 10px; } + .title .sub { margin-top: 14px; } + .kicker { margin-top: 70px; } + /* An overline between two hairline rules in the accent. */ + .rules { position: absolute; left: -132px; right: -132px; top: 50%; height: 2px; transform-origin: 50% 50%; } + .rule { position: absolute; top: 0; width: 96px; height: 2px; border-radius: 1px; } + .rule.l { left: 0; background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${pal.accent} 100%); } + .rule.r { right: 0; background: linear-gradient(270deg, ${rgba(pal.accent, 0)} 0%, ${pal.accent} 100%); } + /* The hit: a bloom of the accent behind the words, a streak of light through them. */ + .flash { position: absolute; left: -18%; right: -18%; top: -55%; bottom: -55%; + background: radial-gradient(closest-side, ${rgba(pal.accent, 0.34)} 0%, ${rgba(pal.accent, 0.14)} 35%, ${rgba(pal.accent, 0.04)} 70%, ${rgba(pal.accent, 0)} 100%); + mix-blend-mode: screen; transform-origin: 50% 50%; } + .streak { position: absolute; left: -24%; right: -24%; top: 52%; height: 3px; margin-top: -1px; + background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${rgba(pal.accent, 0.9)} 30%, ${rgba(pal.fg, 0.95)} 50%, ${rgba(pal.accent, 0.9)} 70%, ${rgba(pal.accent, 0)} 100%); + box-shadow: 0 0 18px 3px ${rgba(pal.accent, 0.55)}; transform-origin: 50% 50%; mix-blend-mode: screen; } + /* The tail: apart from the words, in the accent, arriving on its own. */ + .tail { position: relative; display: inline-block; margin-left: 0.32em; transform-origin: 30% 60%; } + .tail-t { font-weight: 900; font-stretch: 112%; letter-spacing: 0; color: ${pal.accent}; font-size: 1.18em; line-height: 1; } + .tail-glow { position: absolute; left: -140%; right: -140%; top: -90%; bottom: -90%; + background: radial-gradient(ellipse closest-side at 50% 52%, ${rgba(pal.accent, 0.26)} 0%, ${rgba(pal.accent, 0.08)} 45%, ${rgba(pal.accent, 0)} 100%); } + .bar { position: absolute; left: 0; width: ${W}px; height: ${barH}px; background: ${bar}; } + .bar.t { top: 0; box-shadow: 0 1px 0 ${rgba(pal.fg, 0.05)}; } + .bar.b { bottom: 0; box-shadow: 0 -1px 0 ${rgba(pal.fg, 0.05)}; } + .vignette { position: absolute; inset: 0; + background: radial-gradient(ellipse 75% 70% at 50% 50%, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.55) 100%); } + .grain { position: absolute; left: -240px; top: -160px; width: ${W + 480}px; height: ${H + 320}px; + opacity: 0.11; mix-blend-mode: overlay; } + </style> + </head> + <body> + <div id="root" data-composition-id="teaser" data-start="0" data-duration="${r4(seconds)}" + data-width="${W}" data-height="${H}" data-entry="${esc(entry.id)}"> + <div id="teaser-clip" class="clip" data-start="0" data-duration="${r4(seconds)}" data-track-index="1"> + <div class="ground"></div> + <div class="stage" data-k="stage"> + <div class="leak" data-k="leak"></div> + <div class="column"> + ${lineHtml} + </div> + </div> + <div class="vignette"></div> + <svg class="grain" data-k="grain" width="${W + 480}" height="${H + 320}" aria-hidden="true"> + <filter id="teaser-grain"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="7" stitchTiles="stitch"/> + <feColorMatrix type="saturate" values="0"/></filter> + <rect width="100%" height="100%" filter="url(#teaser-grain)"/> + </svg> + <div class="bar t" data-k="barT"></div> + <div class="bar b" data-k="barB"></div> + </div> + </div> + + <script id="teaser-data" type="application/json">${json}</script> + <script> + const D = JSON.parse(document.getElementById("teaser-data").textContent); + const byK = {}; + for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el; + + // t = 0, then the cues: every one states its from, so any seek from + // anywhere lands on the same pixels. + for (const k of Object.keys(D.init)) if (byK[k]) gsap.set(byK[k], D.init[k]); + const tl = gsap.timeline({ paused: true }); + for (const c of D.cues) { + const el = byK[c.k]; + if (!el) continue; + tl.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at); + } + window.__timelines = window.__timelines || {}; + window.__timelines["teaser"] = tl; + + // The fit: a line wider than the column shrinks a pixel at a time, to + // its role's floor. It runs once the face is in -- measuring the + // fallback would fit the wrong glyphs -- and changes sizes only, never + // a time; the renderer waits on document.fonts.ready. + function fit(row) { + const role = row.parentElement.classList.contains("sub") ? "sub" : row.closest(".line").dataset.role; + let s = parseFloat(getComputedStyle(row).fontSize); + const floor = D.floors[role] || 12; + while (s > floor && row.scrollWidth > D.maxW + 0.5) { + s -= 1; + row.style.fontSize = s + "px"; + } + } + const ready = Promise.all([ + document.fonts.load("900 100px TeaserDisplay"), + document.fonts.load("600 30px TeaserDisplay"), + ]).catch(() => {}).then(() => { + document.querySelectorAll(".row").forEach(fit); + document.documentElement.dataset.fit = "1"; + }); + + const still = new URLSearchParams(location.search).get("still"); + if (still !== null) { + tl.seek(Number(still), false); + ready.then(() => tl.seek(Number(still), false)); + } + </script> + </body> +</html> +`; +} diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs @@ -0,0 +1,258 @@ +// The teaser: its validation, its title, the deck hiding over it, the page it +// draws, its cue times, and the render cache key that changed words change. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + CARD_TYPES, deckChoreography, deckSchedule, deckText, hidesDeck, resolveDeck, TEASER_MOTION, teaserHits, + teaserLines, teaserTimes, teaserTitle, validateTeaser, validateTeasers, +} from "./deck.mjs"; +import { teaserCues, teaserHtml } from "./chrome-teaser.mjs"; +import { chapterTitle, teaserAudioGraph, teaserSegmentKey } from "./build-video.mjs"; +import { composeChrome } from "./compose-chrome.mjs"; + +const FERRET = Object.freeze({ + type: "teaser", id: "fin", seconds: 7, + lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"], + tail: "?", +}); +const RENDER = { + width: 1920, height: 1080, fps: 30, audioRate: 48000, audioChannels: 2, + palette: { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }, +}; + +test("a valid teaser has nothing to say; every bad shape is a sentence", () => { + assert.deepEqual(validateTeaser(FERRET), []); + assert.deepEqual(validateTeaser({ ...FERRET, tail: undefined, hits: false }), []); + const bad = (patch) => validateTeaser({ ...FERRET, ...patch }).join(" | "); + assert.match(bad({ lines: [] }), /lines must be a list of 1 to 5/); + assert.match(bad({ lines: ["a", "b", "c", "d", "e", "f"] }), /1 to 5/); + assert.match(bad({ lines: ["ok", ""] }), /lines\[1\] must be words/); + assert.match(bad({ lines: ["two\nlines"] }), /one line/); + assert.match(bad({ lines: ["x".repeat(81)] }), /81 characters/); + assert.match(bad({ lines: [{ text: "Abc", brk: "c" }] }), /lines\[0\]\.brk is not a teaser line field/); + assert.match(bad({ lines: [{ text: "The big one", break: "small" }] }), /must be the end of its text/); + assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/); + assert.match(bad({ lines: [42] }), /string or \{ text, break \}/); + assert.match(bad({ seconds: 2 }), /seconds must be from 3 to 20/); + assert.match(bad({ seconds: 21 }), /from 3 to 20/); + assert.match(bad({ tail: "" }), /tail must be a short string/); + assert.match(bad({ tail: "?????????" }), /tail is 9 characters/); + assert.match(bad({ hits: "yes" }), /hits must be true or false/); + // What fits the frame at each role's floor (TEASER_LIMITS.fit): an + // 80-character title spilled past both edges. + const T80 = "The Largest Ferret Rescue Operation Ever Attempted Anywhere in the United States"; + assert.match(bad({ lines: ["Pirate Software", T80, "February 2027"] }), + /lines\[1\] is 80 characters, and at most 34 fit the frame as the title -- shorten it, or set its end as a break/); + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34), "February 2027"] }), []); + // The tail counts on the row that carries it: the last, or its second tier. + assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 36 characters with the tail/); + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34)], tail: undefined }), []); + assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 58 characters with the tail.*as the kicker/); + assert.match(bad({ lines: ["o".repeat(65), "Title", "Date"] }), /lines\[0\] is 65 characters.*as the overline/); + // A break: the head in the line's role, the break in the second tier. + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["P", { text: T80, break: "Operation Ever Attempted Anywhere in the United States" }, "D"] }), []); + assert.match(bad({ lines: ["P", { text: T80, break: "in the United States" }, "D"] }), + /lines\[1\] before its break is 59 characters, and at most 34 fit the frame as the title$/m); + assert.match(bad({ lines: ["P", { text: `A ${"s".repeat(66)}`, break: "s".repeat(66) }] }), + /lines\[1\]\.break is 68 characters with the tail, and at most 66 fit the frame as the second tier/); + assert.match(bad({ id: "../x" }), /id must be letters/); + // The manifest's validator names where. + const errs = validateTeasers({ timeline: [{ type: "clip", id: "c1" }, { ...FERRET, seconds: 1 }] }); + assert.equal(errs.length, 1); + assert.match(errs[0], /^timeline\[1\] \(fin\)\.seconds/); +}); + +test("lines: roles by position, the break is the second tier, the title joins them", () => { + const l = teaserLines(FERRET); + assert.deepEqual(l.map((x) => x.role), ["overline", "title", "kicker"]); + assert.equal(l[1].head, "The Largest Ferret Rescue"); + assert.equal(l[1].sub, "in the United States"); + assert.equal(l[0].sub, null); + assert.deepEqual(teaserLines({ lines: ["A", "B"] }).map((x) => x.role), ["overline", "title"]); + assert.deepEqual(teaserLines({ lines: ["A"] }).map((x) => x.role), ["title"]); + assert.deepEqual(teaserLines({ lines: ["A", "B", "C", "D"] }).map((x) => x.role), ["overline", "title", "title", "kicker"]); + assert.equal( + teaserTitle(FERRET), + "Pirate Software — The Largest Ferret Rescue in the United States — February 2027 ?", + ); + assert.equal(teaserTitle({ ...FERRET, tail: undefined }).endsWith("February 2027"), true); +}); + +test("the chapter is the teaser's title, with or without the deck; an authored chapter wins", async () => { + assert.equal(await chapterTitle(FERRET, 17, {}), teaserTitle(FERRET)); + assert.equal(await chapterTitle(FERRET, 17, {}, { deck: true }), teaserTitle(FERRET)); + assert.equal(await chapterTitle({ ...FERRET, chapter: "Next season" }, 17, {}, { deck: true }), "Next season"); +}); + +test("the deck slides away over a teaser whatever overCards says; no pip, no QR", () => { + assert.ok(CARD_TYPES.includes("teaser")); + for (const overCards of ["hide", "show"]) { + const deck = resolveDeck({ chrome: { engine: "hyperframes", layout: "deck", deck: { overCards } } }); + assert.equal(hidesDeck(FERRET, deck), true, overCards); + assert.equal(hidesDeck({ type: "card" }, deck), overCards === "hide"); + } + const render = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; + const clip = { type: "clip", id: "c20", video: "v", start: 10, end: 20, citeUrl: "https://example.org/c20" }; + const sched = deckSchedule({ entries: [clip, FERRET], durs: [10, 7], D: 0.5, render }); + const fin = sched.segments[1]; + assert.equal(fin.hideDeck, true); + assert.equal(fin.qrUrl, null); + assert.equal(fin.title, teaserTitle(FERRET)); + assert.equal(fin.subtitle, ""); + // Into the teaser the deck hides (a visibility change), no text handover. + const ch = deckChoreography(sched, render); + assert.equal(ch.handovers.length, 0); + assert.deepEqual(ch.visibility.map((v) => [v.i, v.hide]), [[1, true]]); + assert.deepEqual(deckText(FERRET, null, {}, resolveDeck(render), false), { title: teaserTitle(FERRET), subtitle: "" }); +}); + +test("the cues: one per pop at the shared times, top to bottom, every from stated", () => { + const lines = teaserLines(FERRET); + const { cues, init, beats } = teaserCues({ lines, tail: "?", seconds: 7 }); + const m = TEASER_MOTION; + // The ferret card needs no compression: the times are the motion's own. + assert.deepEqual(beats.lines.map((b) => b.at), [m.first, m.first + m.gap, m.first + m.gap + m.sub + m.gap]); + assert.equal(beats.lines[1].subAt, m.first + m.gap + m.sub); + // About 0.6–0.8 s apart, in order. + const ats = beats.lines.map((b) => b.at); + for (let i = 1; i < ats.length; i += 1) assert.ok(ats[i] - ats[i - 1] >= 0.6 && ats[i] - ats[i - 1] <= 1.0001); + // The tail starts after the date has settled and is in before the end fade's hold. + assert.ok(beats.tailAt >= beats.lines[2].impact + m.settle - 1e-9); + assert.ok(beats.tailAt + beats.tailDur <= 7 - m.endRoom + 1e-9); + // The slam lands on the impact, and the flash is centred on it. + lines.forEach((_, i) => { + const slam = cues.find((c) => c.why === `line ${i} slam`); + assert.equal(Math.round((slam.at + slam.dur) * 1e4) / 1e4, beats.lines[i].impact); + const flash = cues.find((c) => c.why === `line ${i} flash`); + assert.equal(Math.round((flash.at + flash.dur / 2) * 1e4) / 1e4, beats.lines[i].impact); + }); + // Every cue's from is stated, and is the state the element was left in. + const state = JSON.parse(JSON.stringify(init)); + for (const c of cues) { + for (const [p, v] of Object.entries(c.from)) assert.deepEqual(v, state[c.k][p], `${c.k}.${p} at ${c.at}`); + Object.assign(state[c.k], c.to); + } + // Seek-safe: no cue on an element starts before the one before it has ended. + const free = new Map(); + for (const c of cues) { + assert.ok(c.at >= (free.get(c.k) ?? 0) - 1e-9, `${c.k} at ${c.at}`); + free.set(c.k, c.at + c.dur); + } + // The end state: every line and the tail fully shown. + for (let i = 0; i < lines.length; i += 1) assert.equal(state[`l${i}.o`].autoAlpha, 1); + assert.equal(state.tail.autoAlpha, 1); + assert.equal(state["l1.sub"].autoAlpha, 1); +}); + +test("a short card plays every beat faster, and still leaves the end fade its room", () => { + const lines = teaserLines({ lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }); + const t = teaserTimes(lines, "?", 3); + assert.ok(t.scale < 1); + assert.ok(t.tailAt + t.tailDur <= 3 - TEASER_MOTION.endRoom + 1e-6); + const { cues } = teaserCues({ lines, tail: "?", seconds: 3 }); + assert.ok(cues.every((c) => c.at + c.dur <= 3 + 1e-6)); +}); + +test("the hits sit on the pops: the cue list's times, no second copy", () => { + const lines = teaserLines(FERRET); + const { beats } = teaserCues({ lines, tail: "?", seconds: 7 }); + const hits = teaserHits(FERRET); + assert.deepEqual(hits.filter((h) => h.kind === "hit").map((h) => [h.role, h.at]), [ + ["overline", beats.lines[0].impact], + ["title", beats.lines[1].impact], + ["sub", beats.lines[1].subAt], + ["kicker", beats.lines[2].impact], + ]); + // The main title's is the biggest; the second tier's the lightest and shortest. + const by = Object.fromEntries(hits.map((h) => [h.role, h])); + assert.ok(by.title.gain > by.kicker.gain && by.kicker.gain > by.sub.gain && by.overline.gain > by.sub.gain); + assert.ok(by.sub.decay < by.title.decay); + // The tail gets a swell, not a hit, starting with its fade. + assert.deepEqual([by.tail.kind, by.tail.at, by.tail.dur], ["swell", beats.tailAt, beats.tailDur]); + assert.deepEqual(teaserHits({ ...FERRET, hits: false }), []); +}); + +test("the page: every line's nodes, escaped words, the tail, nothing fetched from anywhere", () => { + const evil = { + ...FERRET, + lines: ["<b>Pirate</b> & \"Co\"", { text: "Title </script><script>x()</script> end", break: "end" }, "Feb's 2027"], + }; + const html = teaserHtml(evil, RENDER); + assert.ok(html.includes("&lt;b&gt;Pirate&lt;/b&gt; &amp; &quot;Co&quot;")); + assert.ok(html.includes("Feb&#39;s 2027")); + assert.ok(!html.includes("<b>Pirate")); + // One script open per script; the words cannot close the data block. + assert.equal((html.match(/<script/g) ?? []).length, 3); + assert.ok(!/<\/script><script>x\(\)/.test(html)); + for (let i = 0; i < 3; i += 1) { + for (const k of [`l${i}.o`, `l${i}`, `l${i}.t`, `l${i}.flash`, `l${i}.streak`]) { + assert.ok(html.includes(`data-k="${k}"`), k); + } + } + assert.ok(html.includes('data-k="l0.rules"')); + assert.ok(html.includes('data-k="l1.sub"') && html.includes('data-k="l1.subt"')); + assert.ok(html.includes('data-k="tail"') && html.includes('data-k="tail.glow"')); + // The tail sits in the LAST line. + assert.ok(html.indexOf('data-line="2"') < html.indexOf('data-k="tail"')); + // The contract: one composition, its duration, one paused timeline. + assert.match(html, /data-composition-id="teaser" data-start="0" data-duration="7"/); + assert.match(html, /window\.__timelines\["teaser"\] = tl/); + assert.match(html, /gsap\.timeline\(\{ paused: true \}\)/); + // No URL that leaves the project: the face and GSAP are local files. + assert.deepEqual(html.match(/\b(?:https?:|\/\/[a-z])[^\s"')]*/gi) ?? [], []); + assert.match(html, /url\('assets\/TeaserDisplay\.ttf'\)/); + assert.match(html, /<script src="assets\/gsap\.min\.js">/); + // The cue data in the page is the cue list. + const json = JSON.parse(html.match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]); + const { cues } = teaserCues({ lines: teaserLines(evil), tail: "?", seconds: 7 }); + assert.deepEqual(json.cues, cues.map(({ why, ...c }) => c)); + // No tail, no tail nodes. + assert.ok(!teaserHtml({ ...FERRET, tail: undefined }, RENDER).includes('data-k="tail"')); +}); + +test("the cache key: the composed page changes with the words, the segment key with the sound", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "teaser-")); + try { + const manifest = (entry) => ({ slug: "t", render: RENDER, timeline: [entry] }); + const mp = path.join(dir, "video.manifest.json"); + const compose = async (entry) => { + writeFileSync(mp, JSON.stringify(manifest(entry))); + return composeChrome({ manifestPath: mp, region: "teaser", segment: "fin", preview: false, doRender: false }); + }; + const a = await compose(FERRET); + const again = await compose(FERRET); + const b = await compose({ ...FERRET, lines: ["Pirate Software", "Another Arc", "February 2027"] }); + assert.equal(a.key, again.key); + assert.notEqual(a.key, b.key); + assert.equal(a.frameCount, 210); + assert.ok(a.projDir.endsWith(path.join("out", "sourced", "chrome", "teaser-fin"))); + assert.ok(existsSync(path.join(a.projDir, "assets", "TeaserDisplay.ttf"))); + assert.ok(existsSync(path.join(a.projDir, "assets", "gsap.min.js"))); + assert.ok(readFileSync(path.join(a.projDir, "index.html"), "utf8").includes("Another Arc")); + await assert.rejects( + () => composeChrome({ manifestPath: mp, region: "teaser", segment: "nope" }), + /no teaser entry nope/, + ); + // The sound is in the segment's key: hits on and off are different segments. + const on = teaserAudioGraph(teaserHits(FERRET), { seconds: 7, render: RENDER }); + const off = teaserAudioGraph(teaserHits({ ...FERRET, hits: false }), { seconds: 7, render: RENDER }); + const key = (k, g, r = RENDER) => teaserSegmentKey(k, g, r); + assert.notEqual(key(a.key, on), key(a.key, off)); + assert.notEqual(key(a.key, on), key(b.key, on)); + assert.equal(key(a.key, on), key(again.key, on)); + // So are the encode's parameters: a rebuild that re-encodes every clip + // re-encodes the teaser too. + assert.equal(key(a.key, on), key(a.key, on, { ...RENDER })); + for (const change of [{ crf: 30 }, { preset: "veryslow" }, { audioBitrate: "96k" }, { audioChannels: 6 }, { audioRate: 44100 }]) { + assert.notEqual(key(a.key, on), key(a.key, on, { ...RENDER, ...change }), JSON.stringify(change)); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -38,8 +38,9 @@ import path from "node:path"; import { ledgerTotals, dateKey } from "./ledger-totals.mjs"; import { selectVariant } from "./build-video.mjs"; +import { ensureWriteDir } from "../lib/report/storage.mjs"; import { - chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, + chromeCacheKey, deckLayout, frameCount, hyperframesCommand, postWindows, resolveDeck, sha256, validateTeaser, } from "./deck.mjs"; import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; @@ -613,7 +614,7 @@ async function copyFonts(render, assetsDir, names, { strict }) { * `projDir/assets`. The chart's branch is the band as it shipped; only where * its GSAP comes from has changed. */ -async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window }) { +async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser }) { if (region === "chart") { const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8")); const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false }); @@ -642,6 +643,14 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); return postsHtml(schedule, render, window, { fonts, qrSrcs }); } + if (region === "teaser") { + // A page module reached by a dynamic import, so nothing that imports this + // file -- umtool's preview helper, the build -- loads its face's URL + // unless a teaser is being composed (docs/quirks.md). + const { teaserHtml, TEASER_FONT_FILE, TEASER_FONT_ASSET } = await import("./chrome-teaser.mjs"); + await copyFile(TEASER_FONT_FILE, path.join(projDir, TEASER_FONT_ASSET)); + return teaserHtml(teaser, manifest.render, { font: TEASER_FONT_ASSET }); + } throw new Error(`unknown chrome region: ${region}`); } @@ -703,6 +712,14 @@ function runRenderer(cmd, args) { * their `.key`, cached exactly as the deck's are; * - `still` is in CUT seconds. * + * Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame, + * `seconds` long, drawn from the entry alone (no schedule): + * - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`), + * frames `chrome/teaser-<id>-frames/` and their `.key`, cached as the deck's + * are -- the key hashes the page, so changed words are a new render; + * - the build encodes the frames into `segments/<id>.mp4` (build-video's + * `buildTeaserSegment`). + * * `schedule` (an object) overrides reading `out/<variant>/schedule.json`. * * @returns {Promise<{ projDir: string, frames: string|null, still: string|null, @@ -721,12 +738,24 @@ export async function composeChrome({ const manifest = selectVariant(JSON.parse(await readFile(manifestPath, "utf8")), variant); // Absolute: the still is a file:// URL, and a relative one is no page at all. const base = path.resolve(outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant)); + // The project's out/ through ensureOutDir before anything lands under it: a + // link to the media root when UMTOOL_MEDIA_DIR is set, and a loud refusal + // when that link dangles. + await ensureWriteDir(base); from = Number(from ?? 0); - // The two regions drawn from the deck's schedule, and keyed by the render cache. - const keyed = region === "deck" || region === "posts"; + // The regions keyed by the render cache: the two drawn from the deck's + // schedule, and a teaser, drawn from its own timeline entry. + const keyed = region === "deck" || region === "posts" || region === "teaser"; + let teaser = null; + if (region === "teaser") { + teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null; + if (!teaser) throw new Error(`no teaser entry ${segment ?? "(none named)"} in the ${variant} cut`); + const errors = validateTeaser(teaser); + if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`); + } let sched = schedule; - if (keyed && !sched) { + if ((region === "deck" || region === "posts") && !sched) { const p = path.join(base, "schedule.json"); try { sched = JSON.parse(await readFile(p, "utf8")); @@ -735,7 +764,7 @@ export async function composeChrome({ } if (sched.kind !== "deck") throw new Error(`${p} is not a deck schedule (kind ${sched.kind ?? "missing"})`); } - const total = keyed ? sched.total : null; + const total = teaser ? Number(teaser.seconds) : keyed ? sched.total : null; const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); const windowed = region === "deck" && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); @@ -757,7 +786,9 @@ export async function composeChrome({ const projName = region === "posts" ? `posts-${preview ? "preview-" : ""}${win.segment}` - : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`; + : region === "teaser" + ? `teaser-${preview ? "preview-" : ""}${teaser.id}` + : region === "deck" && preview ? "deck-preview" : `${region}${suffix}`; const projDir = path.join(base, "chrome", projName); const assetsDir = path.join(projDir, "assets"); // The deck's assets are rebuilt every time: a QR from a clip that has since @@ -768,7 +799,7 @@ export async function composeChrome({ const html = await regionHtml(region, { manifest, base, projDir, assetsDir, schedule: sched, - duration: duration != null ? Number(duration) : null, from, window: win, + duration: duration != null ? Number(duration) : null, from, window: win, teaser, }); await writeFile(path.join(projDir, "index.html"), html, "utf8"); await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8"); @@ -809,7 +840,7 @@ export async function composeChrome({ // A sequence is a directory; every other format is a file. const sequence = format === "png-sequence"; - const stem = region === "posts" ? `posts-${win.segment}` : `${region}${suffix}`; + const stem = region === "posts" ? `posts-${win.segment}` : region === "teaser" ? `teaser-${teaser.id}` : `${region}${suffix}`; const target = sequence ? path.join(base, "chrome", `${stem}-frames`) : path.join(base, "chrome", `${stem}.${format}`); @@ -856,8 +887,8 @@ if (import.meta.url === `file://${process.argv[1]}`) { const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); if (!manifestPath) { console.error( - "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts] [--variant sourced|full]\n" + - " [--segment <id>] (posts: the clip whose window to compose)\n" + + "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|posts|teaser] [--variant sourced|full]\n" + + " [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" + " [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" + " [--still <s> --png <path>]\n" + " [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]", @@ -879,7 +910,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { still: num("--still"), png: flag("--png"), // The deck renders four-wide by default; the band keeps the renderer's own default. - workers: num("--workers") ?? (region === "deck" ? 4 : region === "posts" ? 2 : null), + workers: num("--workers") ?? (region === "deck" || region === "teaser" ? 4 : region === "posts" ? 2 : null), quality: flag("--quality") ?? "high", format: flag("--format") ?? "png-sequence", fps: num("--fps"), diff --git a/umtool/report-to-video/cut-edits.test.mjs b/umtool/report-to-video/cut-edits.test.mjs @@ -0,0 +1,400 @@ +// Tests for the cut's edits made where it is joined (slice B2): a clip's +// `muteFrom` (source seconds → the segment's clock, through the cut record +// the build writes beside each segment) and `render.endFade` on the cut's +// last segment. The chains as strings, unchanged without them; the mapping; +// validation; and real ffmpeg runs showing the sound after `muteFrom` is +// digital silence, the picture is untouched, the end fade reaches bg and +// silence on the last frame, and the length and A/V sync do not move. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + concatListText, concatRecordText, cutJoins, cutRecordPath, endFadeAudioFilter, endFadeFrames, endFadeVideoFilter, + hardCutFilterArgs, joinInputChain, muteAudioFilter, sameConcatList, withCutEdits, xfadeGraph, yuv601, +} from "./build-video.mjs"; +import { + endFadeOf, MUTE_FADE, muteSegmentSeconds, playWindow, validateChrome, validateCutEdits, validateEndFade, + validateMuteFrom, +} from "./deck.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const RENDER = { + width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, + audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, +}; +const CLIP = { id: "c20", type: "clip", video: "B36", start: 24022.6, end: 24029.6 }; + +// ---- validation ------------------------------------------------------------- + +test("validateMuteFrom: a number of source seconds within the clip's extent; only on a clip", () => { + assert.deepEqual(validateMuteFrom(CLIP), []); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: null }), []); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24029.3 }), []); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24022.6 }), [], "the start is inside"); + assert.deepEqual(validateMuteFrom({ ...CLIP, muteFrom: 24029.6 }), [], "the end is inside"); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: 24029.7 })[0], /muteFrom 24029\.7 is outside the clip's 24022\.6–24029\.6/); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: 3 })[0], /outside/); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: "24029" })[0], /must be a number of source seconds/); + assert.match(validateMuteFrom({ ...CLIP, muteFrom: NaN })[0], /must be a number/); + assert.match(validateMuteFrom({ id: "t1", type: "card", muteFrom: 2 })[0], /only a clip has sound to mute/); +}); + +test("validateEndFade and validateCutEdits: seconds from 0 to 10; every entry named by its place", () => { + assert.deepEqual(validateEndFade({}), []); + assert.deepEqual(validateEndFade({ endFade: 0 }), []); + assert.deepEqual(validateEndFade({ endFade: 1.5 }), []); + for (const bad of [-1, 11, "1", NaN]) assert.match(validateEndFade({ endFade: bad })[0], /render\.endFade must be from 0 to 10 seconds/); + assert.equal(endFadeOf({}), 0); + assert.equal(endFadeOf({ endFade: 1 }), 1); + assert.equal(endFadeOf({ endFade: -1 }), 0); + const errs = validateCutEdits({ + render: { endFade: 20 }, + timeline: [CLIP, { ...CLIP, id: "c21", muteFrom: 1 }], + }); + assert.equal(errs.length, 2); + assert.match(errs[0], /^timeline\[1\] \(c21\)\.muteFrom 1 is outside/); + assert.match(errs[1], /render\.endFade/); + assert.deepEqual(validateCutEdits({ render: {}, timeline: [CLIP] }), []); + // The deck's validator refuses a bad end fade too; a good one changes nothing. + assert.ok(validateChrome(RENDER.chrome, { ...RENDER, endFade: 99 }).some((e) => /render\.endFade/.test(e))); + assert.deepEqual(validateChrome(RENDER.chrome, { ...RENDER, endFade: 1 }), []); +}); + +// ---- the mapping, source → segment ------------------------------------------- + +test("muteSegmentSeconds: from the cut record's snapped start when it matches the segment", () => { + const record = { version: 1, id: "c20", video: "B36", start: 24022.5, end: 24029.5 }; + assert.deepEqual( + muteSegmentSeconds({ entry: { ...CLIP, muteFrom: 24029.3 }, record, render: RENDER, seconds: 7 }), + { at: 6.8, source: "record" }, + ); + // A muteFrom before the segment's real start mutes it from its first sample. + assert.equal(muteSegmentSeconds({ entry: { ...CLIP, muteFrom: 24022.6 }, record: { ...record, start: 24022.7, end: 24029.7 }, render: RENDER, seconds: 7 }).at, 0); +}); + +test("muteSegmentSeconds: no record, a stale one or another video's -- the unsnapped start, and a note that says so", () => { + const entry = { ...CLIP, muteFrom: 24029.3 }; + const none = muteSegmentSeconds({ entry, record: null, render: RENDER, seconds: 7 }); + assert.equal(none.at, 6.7); + assert.equal(none.source, "window"); + assert.match(none.note, /^c20: no cut record beside the segment — muteFrom measured from the unsnapped start 24022\.6; the real start may differ by up to 1\.6s/); + const stale = muteSegmentSeconds({ entry, record: { video: "B36", start: 24020, end: 24030 }, render: RENDER, seconds: 7 }); + assert.equal(stale.source, "window"); + assert.match(stale.note, /the cut record does not match the segment/); + assert.equal(muteSegmentSeconds({ entry, record: { video: "other", start: 24022.5, end: 24029.5 }, render: RENDER, seconds: 7 }).source, "window"); + // Within two frames of the segment's length is a match. + assert.equal(muteSegmentSeconds({ entry, record: { video: "B36", start: 24022.6, end: 24029.65 }, render: RENDER, seconds: 7 }).source, "record"); + // A clip with a tight cut plays from cutStart less the lead-in. + const cut = { ...CLIP, cutStart: 24025, cutEnd: 24029, muteFrom: 24028 }; + assert.deepEqual(playWindow(cut, RENDER), { from: 24024.6, to: 24029 }); + assert.equal(muteSegmentSeconds({ entry: cut, render: RENDER }).at, 3.4); +}); + +// ---- the chains, as strings ------------------------------------------------- + +test("muteAudioFilter: afade out ENDING at the mute point, silent after; volume=0 from the first sample", () => { + assert.equal(MUTE_FADE, 0.04); + assert.equal(muteAudioFilter(6.7), "afade=t=out:st=6.66:d=0.04"); + assert.equal(muteAudioFilter(0.02), "afade=t=out:st=0:d=0.02"); + assert.equal(muteAudioFilter(0), "volume=0"); +}); + +test("the end fade: a yuv blend toward bg from frame s = last − n, so the LAST frame is bg; silence at that frame's time", () => { + assert.deepEqual(yuv601("#12101a"), { y: 31, u: 132, v: 128 }, "what pad wrote into the ferret segments"); + assert.deepEqual(yuv601("#000000"), { y: 16, u: 128, v: 128 }); + assert.deepEqual(yuv601("#ffffff"), { y: 235, u: 128, v: 128 }); + const fade = { seconds: 1, lastFrame: 284 }; // 7 s + 2.5 s hold at 30 fps = 285 frames + assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-8.4667)/1,0,1)+0.5':enable='gte(t,8.4667)'"); + assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=8.4667:d=1"); +}); + +test("the end fade: longer than its segment, it is the segment -- frames and seconds clamped alike, so the last frame is bg", () => { + // A 3 s segment at 30 fps (frames 0..89) under endFade 5: the fade spans + // the segment, from frame 0 (weight 0) to frame 89 (weight 1). + const fade = { seconds: 5, lastFrame: 89 }; + assert.equal(endFadeFrames(fade, 30), 89); + assert.equal(endFadeFrames({ seconds: 1, lastFrame: 89 }, 30), 30, "a shorter fade is its own length"); + assert.equal(endFadeFrames({ seconds: 5, lastFrame: 0 }, 30), 1, "never zero frames"); + assert.equal(endFadeVideoFilter(fade, RENDER), "geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-0)/2.9667,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-0)/2.9667,0,1)+0.5':enable='gte(t,0)'"); + assert.equal(endFadeAudioFilter(fade, RENDER), "afade=t=out:st=0:d=2.9667", "the sound over the same frames"); +}); + +test("joinInputChain: a mute alone is a chain on the sound only; the picture is the input's own", () => { + assert.deepEqual(joinInputChain(2, { hold: 0, move: null, mute: 6.7 }, RENDER), { + parts: ["[2:a]afade=t=out:st=6.66:d=0.04[j2a]"], v: "[2:v]", a: "[j2a]", + }); + // Mute, then the hold's silence, then the end fade, in that order; the + // picture holds, moves (none here) and fades. + const fade = { seconds: 1, lastFrame: 284 }; + assert.deepEqual(joinInputChain(0, { hold: 2.5, move: null, mute: 6.7, fade }, RENDER), { + parts: [ + "[0:v]tpad=stop_mode=clone:stop_duration=2.5,geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-8.4667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-8.4667)/1,0,1)+0.5':enable='gte(t,8.4667)'[j0v]", + "[0:a]afade=t=out:st=6.66:d=0.04,apad=pad_dur=2.5,afade=t=out:st=8.4667:d=1[j0a]", + ], + v: "[j0v]", + a: "[j0a]", + }); +}); + +test("withCutEdits: nothing to add leaves the joins as they were (null stays null); edits land on their segments", () => { + assert.equal(withCutEdits(null, 3), null); + const joins = [null, { hold: 2.5, move: null }, null]; + assert.equal(withCutEdits(joins, 3, {}), joins, "the same joins, not a copy"); + const fade = { seconds: 1, lastFrame: 209 }; + const out = withCutEdits(joins, 3, { mutes: new Map([[1, 4], [2, 6.7]]), fade }); + assert.deepEqual(out, [ + null, + { hold: 2.5, move: null, mute: 4 }, + { hold: 0, move: null, mute: 6.7, fade }, + ]); + assert.deepEqual(joins[1], { hold: 2.5, move: null }, "the schedule's joins are not written to"); + assert.deepEqual(withCutEdits(null, 2, { fade }), [null, { hold: 0, move: null, fade }]); +}); + +test("the graphs: unchanged without edits; the hard cut's record names a mute and a fade, so a changed one is never reused", () => { + // No edits: the plain crossfade graph (each sound pinned to its picture). + assert.deepEqual(xfadeGraph([10, 12], 0.5, withCutEdits(null, 2), RENDER).parts, [ + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + "[1:a]apad=whole_dur=12.000000,atrim=end=12.000000,asetpts=PTS-STARTPTS[p1a]", + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + ]); + const segs = ["/s/a.mp4", "/s/b.mp4"]; + const holdOnly = [null, { hold: 2.5, move: null }]; + assert.equal(concatRecordText(segs, withCutEdits(null, 2)), concatListText(segs)); + assert.equal( + concatRecordText(segs, holdOnly), + concatListText(segs) + '# join 1 {"hold":2.5,"move":null}\n', + "a hold's record line is the one it always was", + ); + const fade = { seconds: 1, lastFrame: 359 }; + const edited = withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3]]), fade }); + const rec = concatRecordText(segs, edited); + assert.match(rec, /^# join 0 \{"hold":0,"move":null,"mute":3\}$/m); + assert.match(rec, /^# join 1 \{"hold":2\.5,"move":null,"fade":\{"seconds":1,"lastFrame":359\}\}$/m); + assert.equal(sameConcatList(rec, segs, edited), true); + assert.equal(sameConcatList(rec, segs, holdOnly), false); + assert.equal(sameConcatList(rec, segs, withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3.5]]), fade })), false); + assert.equal(sameConcatList(rec, segs, withCutEdits(holdOnly, 2, { mutes: new Map([[0, 3]]) })), false); + const fc = hardCutFilterArgs(segs, edited, RENDER, "/o.mp4"); + assert.equal(fc[fc.indexOf("-filter_complex") + 1], + "[0:a]afade=t=out:st=2.96:d=0.04[j0a];" + + "[1:v]tpad=stop_mode=clone:stop_duration=2.5,geq=lum='lum(X,Y)+(31-lum(X,Y))*clip((T-10.9667)/1,0,1)+0.5':cb='cb(X,Y)+(132-cb(X,Y))*clip((T-10.9667)/1,0,1)+0.5':cr='cr(X,Y)+(128-cr(X,Y))*clip((T-10.9667)/1,0,1)+0.5':enable='gte(t,10.9667)'[j1v];" + + "[1:a]apad=pad_dur=2.5,afade=t=out:st=10.9667:d=1[j1a];" + + "[0:v][j0a][j1v][j1a]concat=n=2:v=1:a=1[vc][ac]"); +}); + +// ---- ffmpeg, for real ------------------------------------------------------- + +const haveFfmpeg = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0; +const R = { width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast", audioRate: 48000, audioChannels: 2 }; +const ff = (args, opts = {}) => { + const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts }); + assert.equal(r.status, 0, String(r.stderr)); + return r.stdout; +}; +const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#")) + .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim()); + +/** Three 2 s segments, framed as the deck frames them, each with a tone. */ +function segments(dir, ext = "mov") { + const make = (name, src, hz) => { + const f = path.join(dir, `${name}.${ext}`); + const codec = ext === "mov" ? ["-c:v", "ffv1", "-c:a", "pcm_s16le"] : ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac"]; + ff([ + "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`, + "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`, + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", ...codec, f, + ]); + return f; + }; + return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; +} + +/** Run a graph to mono s16 PCM (the picture sunk), and to frame hashes. */ +const pcmOf = (inputs, parts, v, a) => + ff([...inputs, "-filter_complex", `${parts.join(";")};${v}nullsink`, "-map", a, "-f", "s16le", "-ac", "1", "-ar", "48000", "-"], { encoding: "buffer" }); +const framesOf = (inputs, parts, v, a) => + md5s(ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink`, "-map", v, "-f", "framemd5", "-"])); +const sample = (pcm, i) => pcm.readInt16LE(i * 2); +const peak = (pcm, a, b) => { + let m = 0; + for (let i = Math.round(a * 48000); i < Math.min(pcm.length / 2, Math.round(b * 48000)); i += 1) m = Math.max(m, Math.abs(sample(pcm, i))); + return m; +}; + +test("ffmpeg: after muteFrom the sound is digital silence; before its fade it is the clip's own; the picture is untouched", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-mute-")); + try { + const [a, b, c] = segments(dir); + const inputs = [a, b, c].flatMap((s) => ["-i", s]); + const D = 0.5; + const plain = xfadeGraph([2, 2, 2], D, null, R); + const joins = withCutEdits(null, 3, { mutes: new Map([[1, 1.2]]) }); + const muted = xfadeGraph([2, 2, 2], D, joins, R); + // The picture: frame for frame the graph without the mute. + assert.deepEqual(framesOf(inputs, muted.parts, muted.vlab, muted.alab), framesOf(inputs, plain.parts, plain.vlab, plain.alab)); + const p0 = pcmOf(inputs, plain.parts, plain.vlab, plain.alab); + const p1 = pcmOf(inputs, muted.parts, muted.vlab, muted.alab); + assert.equal(p1.length, p0.length, "the sound is as long as it was"); + // b plays from 1.5 s in the cut, so its mute point is 2.7 s; the dissolve + // into c starts at 3.0 s. Before the fade (2.66 s), every sample is the + // unmuted cut's -- nothing moved, so A/V sync is what it was. + const fadeAt = Math.round((1.5 + 1.2 - MUTE_FADE) * 48000); + assert.ok(p1.subarray(0, fadeAt * 2).equals(p0.subarray(0, fadeAt * 2)), "untouched before the fade"); + assert.ok(peak(p0, 2.7, 3.0) > 1000, "b sounds there without the mute"); + assert.equal(peak(p1, 2.7, 3.0), 0, "digital silence from the mute point to the dissolve"); + assert.ok(peak(p1, 2.66, 2.7) > 0 && peak(p1, 2.66, 2.7) < peak(p0, 2.66, 2.7), "a fade, not a click"); + // From the dissolve on, only c's sound: the cut after it is c's own. + const cOnly = pcmOf(["-i", c], ["[0:a]anull[a]"], "[0:v]", "[a]"); + const tail = p1.subarray(Math.round(3.5 * 48000) * 2, Math.round(5.5 * 48000) * 2); + const own = cOnly.subarray(Math.round(0.5 * 48000) * 2, Math.round(2.5 * 48000) * 2); + assert.ok(peak(p1, 3.6, 5.4) > 1000); + assert.equal(tail.length, own.length); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: the end fade -- the last frame is bg and the sound silent there; with a hold and a hard cut, the length is unchanged", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-fade-")); + try { + const segs = segments(dir); + const inputs = segs.flatMap((s) => ["-i", s]); + // c is 60 frames, held 0.5 s (15 frames): its last frame is 74. + const base = [null, null, { hold: 0.5, move: null }]; + const fade = { seconds: 1, lastFrame: 74 }; + const joins = withCutEdits(base, 3, { fade }); + const plainArgs = hardCutFilterArgs(segs, base, R, "-"); + const fadeArgs = hardCutFilterArgs(segs, joins, R, "-"); + const fc = (args) => [args[args.indexOf("-filter_complex") + 1]]; + const f0 = framesOf(inputs, fc(plainArgs), "[vc]", "[ac]"); + const f1 = framesOf(inputs, fc(fadeArgs), "[vc]", "[ac]"); + assert.equal(f1.length, f0.length, "as many frames as without the fade"); + assert.equal(f1.length, 60 + 60 + 75); + assert.deepEqual(f1.slice(0, 120 + 44), f0.slice(0, 120 + 44), "every frame before the fade is the same"); + assert.notDeepEqual(f1[120 + 50], f0[120 + 50], "fading"); + // The last frame, decoded: bg everywhere. + const last = ff([...inputs, "-filter_complex", `${fc(fadeArgs).join(";")};[ac]anullsink;[vc]select=eq(n\\,194)[o]`, + "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const bg = [0x12, 0x10, 0x1a]; + let worst = 0; + for (let i = 0; i < last.length; i += 1) worst = Math.max(worst, Math.abs(last[i] - bg[i % 3])); + assert.ok(worst <= 2, `the last frame is bg (worst channel off by ${worst})`); + // The sound: as long as without the fade, the same up to it, silent from the last frame's time. + const p0 = pcmOf(inputs, fc(plainArgs), "[vc]", "[ac]"); + const p1 = pcmOf(inputs, fc(fadeArgs), "[vc]", "[ac]"); + assert.equal(p1.length, p0.length); + // The hold is silent already; give c's own tone the fade instead. + const toneJoins = withCutEdits([null, null, null], 3, { fade: { seconds: 1, lastFrame: 59 } }); + const t0 = pcmOf(inputs, fc(hardCutFilterArgs(segs, [null, null, null], R, "-")), "[vc]", "[ac]"); + const t1 = pcmOf(inputs, fc(hardCutFilterArgs(segs, toneJoins, R, "-")), "[vc]", "[ac]"); + assert.equal(t1.length, t0.length); + const lastAt = 4 + 59 / 30; // c starts at 4 s in the hard cut + const fadeStart = Math.round((lastAt - 1) * 48000); + assert.ok(t1.subarray(0, fadeStart * 2).equals(t0.subarray(0, fadeStart * 2)), "the same sound up to the fade"); + assert.ok(peak(t1, lastAt - 0.9, lastAt - 0.8) < peak(t0, lastAt - 0.9, lastAt - 0.8), "fading"); + assert.equal(peak(t1, lastAt, 6), 0, "silent from the last frame on"); + assert.ok(peak(t0, lastAt, 6) > 1000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: an end fade longer than the last segment -- a 3 s segment under endFade 5 still ends on bg and in silence", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-longfade-")); + try { + const seg = path.join(dir, "t.mov"); + ff([ + "-f", "lavfi", "-i", "testsrc2=s=280x150:r=30:d=3", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=3", + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", seg, + ]); + const joins = withCutEdits([null], 1, { fade: { seconds: 5, lastFrame: 89 } }); + const { parts, v, a } = joinInputChain(0, joins[0], R); + const inputs = ["-i", seg]; + assert.equal(framesOf(inputs, parts, v, a).length, 90, "the length is unchanged"); + const frame = (n) => ff([...inputs, "-filter_complex", `${parts.join(";")};${a}anullsink;${v}select=eq(n\\,${n})[o]`, + "-map", "[o]", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const bg = [0x12, 0x10, 0x1a]; + const off = (buf) => { + let worst = 0; + for (let i = 0; i < buf.length; i += 1) worst = Math.max(worst, Math.abs(buf[i] - bg[i % 3])); + return worst; + }; + assert.ok(off(frame(89)) <= 2, `the last frame is bg (worst channel off by ${off(frame(89))})`); + assert.ok(off(frame(44)) > 20, "halfway, still fading"); + const pcm = pcmOf(inputs, parts, v, a); + const lastAt = 89 / 30; + assert.equal(peak(pcm, lastAt, 3), 0, "silent from the last frame's time"); + assert.ok(peak(pcm, 0.1, 0.2) > 1000, "sounding at the start"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("cutJoins: the record beside the segment places muteFrom; the end fade counts the last segment's frames and hold", + { skip: !haveFfmpeg }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-joins-")); + try { + const segs = segments(dir, "mp4"); + const entries = [ + { id: "a", type: "clip", video: "va", start: 100, end: 102 }, + { id: "b", type: "clip", video: "vb", start: 200, end: 203, muteFrom: 201.5 }, + { id: "c", type: "clip", video: "vc", start: 300, end: 302 }, + ]; + // No record for b: the unsnapped start (200), 1.5 s in. + assert.equal(await cutJoins({ entries: entries.slice(0, 1), segments: segs.slice(0, 1), render: R }), null, "nothing to join"); + let j = await cutJoins({ entries, segments: segs, render: R }); + assert.deepEqual(j, [null, { hold: 0, move: null, mute: 1.5 }, null]); + // b's record says it was cut from 200.3: 1.2 s in. + writeFileSync(cutRecordPath(segs[1]), JSON.stringify({ version: 1, id: "b", video: "vb", start: 200.3, end: 202.3 })); + j = await cutJoins({ entries, segments: segs, render: R }); + assert.equal(j[1].mute, 1.2); + // The end fade on c, with the deck's hold on it: 60 + 15 frames. + const schedule = { segments: [{ id: "a" }, { id: "b" }, { id: "c", hold: 0.5 }] }; + j = await cutJoins({ schedule, entries, segments: segs, render: { ...R, endFade: 1 } }); + assert.deepEqual(j[2], { hold: 0.5, move: null, fade: { seconds: 1, lastFrame: 74 } }); + assert.equal(j[0], null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("cutJoins: a muteFrom past the cut's end says it will not be heard, not that the clip is muted from there", + { skip: !haveFfmpeg }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "cut-latemute-")); + const said = []; + const log = console.log; + console.log = (line) => said.push(String(line)); + try { + const segs = segments(dir, "mp4"); + // b's segment is 2 s, cut from 200; the clip's extent runs to 203, so a + // mark at 202.5 validates but lies 2.5 s into a 2 s segment. + writeFileSync(cutRecordPath(segs[1]), JSON.stringify({ version: 1, id: "b", video: "vb", start: 200, end: 202 })); + const entries = [ + { id: "a", type: "clip", video: "va", start: 100, end: 102, muteFrom: 101 }, + { id: "b", type: "clip", video: "vb", start: 200, end: 203, cutEnd: 202, muteFrom: 202.5 }, + ]; + const j = await cutJoins({ entries, segments: segs.slice(0, 2), render: R }); + assert.equal(j[1].mute, 2.5); + const b = said.filter((l) => l.startsWith("b:")); + assert.equal(b.length, 1, b.join("\n")); + assert.match(b[0], /^b: muteFrom 202\.5 will not be heard -- it lies 2\.5s into a segment 2s long, past the cut's end/); + assert.ok(said.some((l) => /^a: muted from 1s into its segment/.test(l)), said.join("\n")); + } finally { + console.log = log; + rmSync(dir, { recursive: true, force: true }); + } + }); diff --git a/umtool/report-to-video/deck-overlay.test.mjs b/umtool/report-to-video/deck-overlay.test.mjs @@ -86,8 +86,10 @@ test("previewFromSegmentsArgs: the window's segments crossfaded as the full conc assert.equal( args[args.indexOf("-filter_complex") + 1], [ + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + "[1:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p1a]", "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", - "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", "[v1]trim=start=2.500:duration=10.000,setpts=PTS-STARTPTS[vw]", "[a1]atrim=start=2.500:duration=10.000,asetpts=PTS-STARTPTS[aw]", "[2:v]format=rgba[hfa0];[vw][hfa0]overlay=x=0:y=890:format=yuv444:shortest=1[hf0];[hf0]format=yuv420p[vout]", diff --git a/umtool/report-to-video/deck-room.test.mjs b/umtool/report-to-video/deck-room.test.mjs @@ -0,0 +1,469 @@ +// Tests for "room for posts" in the build (slice R1): the hold and the footage +// move on a carrying clip's input chain, where the cut is joined; the +// hard-cut concat that hosts them; the record a cached concat keeps of them; +// the cut's lengths with holds; and real ffmpeg runs showing a held segment +// freezes for exactly hold·fps frames in silence while every other input's +// frames pass through untouched. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + concatListText, concatRecordText, hardCutFilterArgs, holdAudioFilter, holdVideoFilter, joinInputChain, + moveFilter, previewFromSegmentsArgs, sameConcatList, segmentJoins, windowSegments, xfadeGraph, +} from "./build-video.mjs"; +import { deckGeometry, deckSchedule, scheduleFrom, shiftedFootage } from "./deck.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const RENDER = { + width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, crf: 21, preset: "slow", + audioRate: 48000, audioChannels: 2, chrome: { engine: "hyperframes", layout: "deck", deck: {} }, +}; +const PROV = { siteOrigin: "https://example.test", channelSlug: "chan" }; +const CLIPS = [ + { id: "c1", type: "clip", video: "v1", start: 0, end: 10, date: "2024-09-05" }, + { id: "c2", type: "clip", video: "v2", start: 0, end: 12, date: "2024-10-01" }, + { id: "c3", type: "clip", video: "v3", start: 0, end: 4, date: "2025-06-01" }, +]; +const POST = (id, date) => ({ + id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, +}); +const MOVE = (segmentAt, extra = {}) => ({ + segment: "c2", at: 9.5 + segmentAt, segmentAt, seconds: 0.6, + from: deckGeometry(RENDER).footage, to: shiftedFootage(RENDER), ...extra, +}); + +// ---- the join plan ---------------------------------------------------------- + +test("segmentJoins: null without posts; a hold and a move on each carrying clip, aligned with the segments", () => { + const durs = [10, 12, 4]; + const plain = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV }); + assert.equal(segmentJoins(plain), null); + assert.equal(segmentJoins(null), null); + const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const joins = segmentJoins(s); + assert.equal(joins.length, 3); + assert.equal(joins[0], null); + assert.equal(joins[2], null); + assert.equal(joins[1].hold, 2.5); + assert.deepEqual(joins[1].move, s.moves[0]); + // A hold alone (shift off) and a move alone (hold 0) are each a join. + const noShift = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shift: false } } } }; + const h = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noShift, provenance: PROV, posts: [POST("a", "2024-10-19")] })); + assert.deepEqual(h[1], { hold: 2.5, move: null }); + const noHold = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0 } } } }; + const m = segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: noHold, provenance: PROV, posts: [POST("a", "2024-10-19")] })); + assert.equal(m[1].hold, 0); + assert.ok(m[1].move); + // Neither: no joins at all. + const neither = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { hold: 0, shift: false } } } }; + assert.equal(segmentJoins(deckSchedule({ entries: CLIPS, durs, D: 0.5, render: neither, provenance: PROV, posts: [POST("a", "2024-10-19")] })), null); +}); + +test("the cut's lengths: the schedule's starts are the probed lengths plus the holds, as the joins carry them", () => { + const durs = [10, 12, 4]; + const s = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const joins = segmentJoins(s); + // cutOffsets' sum, done by hand: probed + hold, then scheduleFrom. + const cut = scheduleFrom(durs.map((d, i) => d + (joins[i]?.hold ?? 0)), 0.5); + assert.deepEqual(cut.starts, s.segments.map((x) => x.start)); + assert.equal(cut.total, s.total); + assert.equal(s.total, 10 + 12 + 4 - 1 + 2.5); +}); + +test("a first post inside the hold: the move's segmentAt is past the clip's own last frame, in the held clock", () => { + // c2 is 12 s on disk; one post at 2 s with the 2.5 s hold appears 12.0 s + // into the segment -- inside the hold. The move runs after the hold, so + // that is a moment its clock reaches. + const two = { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { seconds: 2 } } } }; + const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0.5, render: two, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + const [m] = s.moves; + assert.equal(m.segment, "c2"); + assert.equal(m.segmentAt, 12); + assert.equal(s.segments[1].duration, 14.5); + assert.ok(m.segmentAt + m.seconds <= s.segments[1].duration - 0.5, "the glide lands before the dissolve out"); + const c = joinInputChain(1, segmentJoins(s)[1], two); + assert.ok(c.parts[0].indexOf("tpad=") < c.parts[0].indexOf("perspective="), "hold, then move"); +}); + +test("holds are whole frames: 2.5 s at 25 fps is 63 frames (2.52 s), and the cut's length counts that", () => { + const r25 = { ...RENDER, fps: 25 }; + const s = deckSchedule({ + entries: CLIPS, durs: [10, 12, 4], D: 0, render: r25, provenance: PROV, + posts: [POST("a", "2024-10-19"), POST("b", "2025-07-01")], + }); + assert.equal(s.segments[1].hold, 2.52); + assert.equal(s.segments[2].hold, 2.52); + assert.equal(s.total, 10 + 12 + 4 + 2 * 2.52); + assert.equal(holdVideoFilter(segmentJoins(s)[1].hold), "tpad=stop_mode=clone:stop_duration=2.52"); + // At 30 fps 2.5 s is already 75 frames: unchanged. + const s30 = deckSchedule({ entries: CLIPS, durs: [10, 12, 4], D: 0, render: RENDER, provenance: PROV, posts: [POST("a", "2024-10-19")] }); + assert.equal(s30.segments[1].hold, 2.5); +}); + +test("freezeSamples: between the hold's start (or the move's end) and the dissolve; skipped when under three frames", async () => { + const { freezeSamples } = await import("./verify-build.mjs"); + const seg = { id: "c2", start: 9.5, end: 24, hold: 2.5 }; + const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`); + const a = freezeSamples(seg, { fps: 30, D: 0.5 }); + close(a.at[0], 21.5 + 0.05); + close(a.at[1], 23.5 - 0.05); + // A move that ends inside the hold: the still span starts where it lands. + close(freezeSamples(seg, { fps: 30, D: 0.5, moveEnd: 22.1 }).at[0], 22.1 + 0.05); + // Hold 0.5 under a 0.5 s crossfade: all of it is the dissolve. + assert.match(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0.5 }).skip, /^0\.000s of still picture/); + // Hard cut: up to the end. The last segment: up to its end fade. + close(freezeSamples({ ...seg, hold: 0.5 }, { fps: 30, D: 0 }).at[1], 24 - 0.05); + close(freezeSamples(seg, { fps: 30, D: 0.5, last: true, endFade: 1 }).at[1], 23 - 0.05); + assert.ok(freezeSamples({ ...seg, hold: 1 }, { fps: 30, D: 0.5, last: true, endFade: 1 }).skip); +}); + +// ---- the chains, as strings ------------------------------------------------- + +test("joinInputChain: no join, no chain -- the input's own labels", () => { + assert.deepEqual(joinInputChain(3, null, RENDER), { parts: [], v: "[3:v]", a: "[3:a]" }); +}); + +test("joinInputChain: a hold is tpad clone on the picture and apad silence on the sound", () => { + assert.equal(holdVideoFilter(2.5), "tpad=stop_mode=clone:stop_duration=2.5"); + assert.equal(holdAudioFilter(2.5), "apad=pad_dur=2.5"); + assert.deepEqual(joinInputChain(1, { hold: 2.5, move: null }, RENDER), { + parts: ["[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v]", "[1:a]apad=pad_dur=2.5[j1a]"], + v: "[j1v]", + a: "[j1a]", + }); +}); + +test("joinInputChain: the move goes after the hold, so its clock counts the held frames; a move alone leaves the sound alone", () => { + const both = joinInputChain(1, { hold: 2.5, move: MOVE(6) }, RENDER); + assert.equal(both.parts.length, 2); + assert.equal(both.parts[0], `[1:v]tpad=stop_mode=clone:stop_duration=2.5,${moveFilter(MOVE(6), RENDER)}[j1v]`); + assert.equal(both.parts[1], "[1:a]apad=pad_dur=2.5[j1a]"); + const move = joinInputChain(1, { hold: 0, move: MOVE(6) }, RENDER); + assert.deepEqual(move, { parts: [`[1:v]${moveFilter(MOVE(6), RENDER)}[j1v]`], v: "[j1v]", a: "[1:a]" }); +}); + +test("moveFilter: perspective places the frame's corners so the footage box eases from `from` to `to`", () => { + const f = moveFilter(MOVE(6), RENDER); + const [fill, persp] = f.split(/,(?=perspective=)/); + assert.equal(fill, "fillborders=left=2:right=2:top=2:bottom=2:mode=fixed:color=#12101a"); + assert.match(persp, /:interpolation=linear:sense=destination:eval=frame$/); + // 1574×886 at (173,2) → 1354×762 at (24,64): the input frame's corners at e = 1. + const e = "st(0,clip(((in-1)/30-6)/0.6,0,1))"; + const ease = "*ld(0)*ld(0)*(3-2*ld(0))"; + assert.equal( + persp, + "perspective=" + [ + `x0='${e};0+(-124.8196)${ease}'`, `y0='${e};0+(62.2799)${ease}'`, + `x1='${e};W+(-393.1804)${ease}'`, `y1='${e};0+(62.2799)${ease}'`, + `x2='${e};0+(-124.8196)${ease}'`, `y2='${e};H+(-88.8713)${ease}'`, + `x3='${e};W+(-393.1804)${ease}'`, `y3='${e};H+(-88.8713)${ease}'`, + ].join(":") + ":interpolation=linear:sense=destination:eval=frame", + ); + // The corner offsets ARE the box map: the from box's corners land on the to box's. + const F = deckGeometry(RENDER).footage; + const T = shiftedFootage(RENDER); + const X = (u) => -124.8196 + (u * (1920 - 393.1804 - -124.8196)) / 1920; + const Y = (v) => 62.2799 + (v * (1080 - 88.8713 - 62.2799)) / 1080; + near(X(F.x), T.x, "left"); + near(X(F.x + F.width), T.x + T.width, "right"); + near(Y(F.y), T.y, "top"); + near(Y(F.y + F.height), T.y + T.height, "bottom"); + // No easing time: a cut. + assert.match(moveFilter(MOVE(6, { seconds: 0 }), RENDER), /x0='st\(0,gte\(\(in-1\)\/30,6\)\);0\+/); +}); + +function near(a, b, msg) { assert.ok(Math.abs(a - b) < 0.01, `${msg}: ${a} != ${b}`); } + +test("xfadeGraph: without joins, the graph the crossfade concat always wrote", () => { + const g = xfadeGraph([10, 12, 4], 0.5, null, RENDER); + assert.deepEqual(g.parts, [ + // Each input's sound pinned to its picture's length before the crossfade. + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + "[1:a]apad=whole_dur=12.000000,atrim=end=12.000000,asetpts=PTS-STARTPTS[p1a]", + "[2:a]apad=whole_dur=4.000000,atrim=end=4.000000,asetpts=PTS-STARTPTS[p2a]", + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=21.000[v2]", + "[a1][p2a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ]); + assert.equal(g.vlab, "[v2]"); + assert.equal(g.alab, "[a2]"); + // An all-null join list is the same graph. + assert.deepEqual(xfadeGraph([10, 12, 4], 0.5, [null, null, null], RENDER), g); +}); + +test("xfadeGraph: a held input joins through its chain, and the offsets after it move by the hold", () => { + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const g = xfadeGraph([10, 14.5, 4], 0.5, joins, RENDER); + const chain = joinInputChain(1, joins[1], RENDER).parts; + assert.deepEqual(g.parts, [ + "[0:a]apad=whole_dur=10.000000,atrim=end=10.000000,asetpts=PTS-STARTPTS[p0a]", + ...chain, + // The held input's sound is pinned to its length WITH the hold. + "[j1a]apad=whole_dur=14.500000,atrim=end=14.500000,asetpts=PTS-STARTPTS[p1a]", + "[2:a]apad=whole_dur=4.000000,atrim=end=4.000000,asetpts=PTS-STARTPTS[p2a]", + "[0:v][j1v]xfade=transition=fade:duration=0.5:offset=9.500[v1]", + "[p0a][p1a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=23.500[v2]", + "[a1][p2a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ]); +}); + +test("hardCutFilterArgs: the concat filter over each input's chain, one encode", () => { + const joins = [null, { hold: 2.5, move: null }, null]; + const args = hardCutFilterArgs(["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], joins, RENDER, "/o/x.prerail-hardcut.mp4"); + assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]); + assert.equal( + args[args.indexOf("-filter_complex") + 1], + "[1:v]tpad=stop_mode=clone:stop_duration=2.5[j1v];[1:a]apad=pad_dur=2.5[j1a];" + + "[0:v][0:a][j1v][j1a][2:v][2:a]concat=n=3:v=1:a=1[vc][ac]", + ); + assert.deepEqual(args.slice(args.indexOf("-map"), args.indexOf("-map") + 4), ["-map", "[vc]", "-map", "[ac]"]); + assert.equal(args[args.indexOf("-c:v") + 1], "libx264"); + assert.equal(args[args.indexOf("-c:a") + 1], "aac"); + assert.equal(args.at(-1), "/o/x.prerail-hardcut.mp4"); +}); + +test("the hard-cut record: the list alone without joins; the joins with them, so a changed hold is never reused", () => { + const segs = ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"]; + assert.equal(concatRecordText(segs, null), concatListText(segs)); + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const rec = concatRecordText(segs, joins); + assert.ok(rec.startsWith(concatListText(segs))); + assert.match(rec, /^# join 1 \{"hold":2\.5,"move":\{"segment":"c2"/m); + assert.equal(sameConcatList(rec, segs, joins), true); + // The list a deck without posts records is not the record of a joined one, either way round. + assert.equal(sameConcatList(rec, segs), false); + assert.equal(sameConcatList(concatListText(segs), segs, joins), false); + // A changed hold, a changed move, a hold moved to another clip: all stale. + assert.equal(sameConcatList(rec, segs, [null, { hold: 3, move: MOVE(6) }, null]), false); + assert.equal(sameConcatList(rec, segs, [null, { hold: 2.5, move: MOVE(6.5) }, null]), false); + assert.equal(sameConcatList(rec, segs, [{ hold: 2.5, move: MOVE(6) }, null, null]), false); +}); + +// ---- window maths with holds ------------------------------------------------ + +test("a preview window over a held clip: picked by the cut's lengths, its inputs joined as the full concat's", () => { + const joins = [null, { hold: 2.5, move: MOVE(6) }, null]; + const durs = [10, 14.5, 4]; // c2 is 12 s on disk, 14.5 in the cut + const { starts } = scheduleFrom(durs, 0.5); + assert.deepEqual(starts, [0, 9.5, 23.5]); + // 21.5–23.0 is c2's hold: by the files' own lengths it would reach into c3. + assert.deepEqual(windowSegments(starts, durs, 21.5, 1.5), { first: 1, last: 1, offset: 12 }); + const plan = { regions: [], outLabel: "[hfout]" }; + const args = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 20, dur: 6, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, + }); + assert.deepEqual(args.filter((_, i) => args[i - 1] === "-i"), ["/s/b.mp4", "/s/c.mp4"]); + const fc = args[args.indexOf("-filter_complex") + 1]; + // b is input 0 here, joined as input 1 is in the full concat. + assert.ok(fc.startsWith(joinInputChain(0, joins[1], RENDER).parts.join(";") + ";")); + assert.match(fc, /\[j0v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=14\.000\[v1\]/); + assert.match(fc, /\[v1\]trim=start=10\.500:duration=6\.000/); + // One held segment alone: its chain, then the trim. + const one = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4", "/s/c.mp4"], durs, starts, D: 0.5, at: 21.5, dur: 1.5, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", joins, + }); + assert.match(one[one.indexOf("-filter_complex") + 1], /\[j0v\]trim=start=12\.000:duration=1\.500,setpts=PTS-STARTPTS\[vw\];\[j0a\]atrim=/); + const plain = previewFromSegmentsArgs({ + segments: ["/s/a.mp4", "/s/b.mp4"], durs: [10, 10], starts: [0, 9.5], D: 0.5, at: 8, dur: 4, + render: RENDER, chromePlan: plan, outPath: "/p.mp4", + }); + // Without joins, the window's graph is the crossfade concat's: each sound + // pinned to its picture, then the fades. + assert.match(plain[plain.indexOf("-filter_complex") + 1], /^\[0:a\]apad=whole_dur=10\.000000,atrim=end=10\.000000,asetpts=PTS-STARTPTS\[p0a\];\[1:a\]apad=whole_dur=10\.000000[^;]*\[p1a\];\[0:v\]\[1:v\]xfade=transition=fade:duration=0\.5:offset=9\.500\[v1\];\[p0a\]\[p1a\]acrossfade/); +}); + +// ---- ffmpeg, for real ------------------------------------------------------- + +const have = (b, a) => spawnSync(b, a, { stdio: "ignore" }).status === 0; +const haveFfmpeg = have("ffmpeg", ["-version"]); + +const R = { + width: 320, height: 180, fps: 30, palette: PALETTE, crf: 21, preset: "veryfast", + audioRate: 48000, audioChannels: 2, +}; +const ff = (args, opts = {}) => { + const r = spawnSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args], { maxBuffer: 1 << 28, ...opts }); + assert.equal(r.status, 0, String(r.stderr)); + return r.stdout; +}; +/** framemd5's hashes for one output stream (the picture is stream 0). */ +const md5s = (out, stream = 0) => String(out).split("\n").filter((l) => l && !l.startsWith("#")) + .map((l) => l.split(",")).filter((f) => Number(f[0]) === stream).map((f) => f.at(-1).trim()); + +/** Three 2 s segments as the deck frames them: footage in a box over bg, a tone under it. */ +function segments(dir) { + const make = (name, src, hz) => { + const f = path.join(dir, `${name}.mov`); + ff([ + "-f", "lavfi", "-i", `${src}=s=280x150:r=30:d=2`, + "-f", "lavfi", "-i", `sine=frequency=${hz}:sample_rate=48000:duration=2`, + "-filter_complex", `[0:v]pad=320:180:20:10:color=${PALETTE.bg},format=yuv420p[v];[1:a]aformat=channel_layouts=stereo[a]`, + "-map", "[v]", "-map", "[a]", "-c:v", "ffv1", "-c:a", "pcm_s16le", f, + ]); + return f; + }; + return [make("a", "testsrc2", 440), make("b", "smptebars", 550), make("c", "rgbtestsrc", 660)]; +} + +test("ffmpeg: a held segment's last frame repeats for exactly hold·fps frames, in silence; the other inputs pass through untouched", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-")); + try { + const segs = segments(dir); + const own = segs.map((s) => md5s(ff(["-i", s, "-map", "0:v", "-f", "framemd5", "-"]))); + own.forEach((m) => assert.equal(m.length, 60)); + const joins = [null, { hold: 1, move: null }, null]; + // The hard cut's graph, run to frame hashes instead of an encode. + const args = hardCutFilterArgs(segs, joins, R, "-"); + const fc = args[args.indexOf("-filter_complex") + 1]; + const inputs = segs.flatMap((s) => ["-i", s]); + const out = md5s(ff([...inputs, "-filter_complex", fc, "-map", "[vc]", "-map", "[ac]", "-f", "framemd5", "-"])); + assert.equal(out.length, 60 + 60 + 30 + 60, "30 held frames and not one more"); + // a and c: their own frames, bit for bit. + assert.deepEqual(out.slice(0, 60), own[0]); + assert.deepEqual(out.slice(150), own[2]); + // b, then its last frame 15 more times. + assert.deepEqual(out.slice(60, 120), own[1]); + assert.deepEqual(out.slice(119, 150), Array(31).fill(own[1][59])); + // The sound under the hold is silence; around it, the tones. + const pcm = ff([...inputs, "-filter_complex", `${fc};[vc]nullsink`, "-map", "[ac]", "-f", "s16le", "-ac", "1", "-"], { encoding: "buffer" }); + const at = (s) => pcm.readInt16LE(Math.round(s * 48000) * 2); + const peak = (a, b) => { + let m = 0; + for (let i = Math.round(a * 48000); i < Math.round(b * 48000); i += 1) m = Math.max(m, Math.abs(pcm.readInt16LE(i * 2))); + return m; + }; + assert.equal(pcm.length / 2, 5 * 48000 + 96000, "the sound is held as long as the picture"); + assert.equal(peak(4.0, 5.0), 0, "silence under the hold"); + assert.ok(peak(3.5, 4.0) > 1000, "b's tone before it"); + assert.ok(peak(5.0, 5.5) > 1000, "c's tone after it"); + assert.ok(Number.isFinite(at(0))); + + // The crossfade concat (xfade hands on its own pixel format, so frames + // are compared with the graph the build ran before there were joins): + // a and c come out exactly as they did -- c 30 frames later -- and b's + // last frame is held up to the next dissolve. + const D = 0.5; + const legacy = md5s(ff([...inputs, "-filter_complex", [ + "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=1.500[v1]", + "[0:a][1:a]acrossfade=d=0.5:c1=tri:c2=tri[a1]", + "[v1][2:v]xfade=transition=fade:duration=0.5:offset=3.000[v2]", + "[a1][2:a]acrossfade=d=0.5:c1=tri:c2=tri[a2]", + ].join(";"), "-map", "[v2]", "-map", "[a2]", "-f", "framemd5", "-"])); + assert.equal(legacy.length, 150); + const xg = xfadeGraph([2, 3, 2], D, joins, R); + const xo = md5s(ff([...inputs, "-filter_complex", xg.parts.join(";"), "-map", xg.vlab, "-map", xg.alab, "-f", "framemd5", "-"])); + assert.equal(xo.length, 60 + 90 + 60 - 30); + assert.deepEqual(xo.slice(0, 60), legacy.slice(0, 60), "a and the dissolve into b"); + assert.deepEqual(xo.slice(60, 90), legacy.slice(60, 90), "b, to where the old cut dissolved"); + assert.deepEqual(xo.slice(104, 120), Array(16).fill(xo[104]), "b's last frame, held to the next dissolve"); + assert.deepEqual(xo.slice(135), legacy.slice(105), "c after its dissolve, 30 frames later"); + + // Without joins the graph is exactly the one above. + const plain = xfadeGraph([2, 2, 2], D, null, R); + assert.deepEqual( + md5s(ff([...inputs, "-filter_complex", plain.parts.join(";"), "-map", plain.vlab, "-map", plain.alab, "-f", "framemd5", "-"])), + legacy, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: the move is the identity before it starts, lands on the target box, and leaves the uncovered ground bg", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mv-")); + try { + const [, b] = segments(dir); + const move = { segment: "b", at: 0, segmentAt: 0.5, seconds: 0.6, + from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; + const c = joinInputChain(0, { hold: 0, move }, R); + const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", + "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const src = (n) => ff(["-i", b, "-vf", `select=eq(n\\,${n})`, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; + // Before the move, inside the 2 px border, the frame is the input's. + const pre = rgb(10), pin = src(10); + for (const [x, y] of [[20, 10], [160, 90], [299, 159], [100, 40]]) assert.deepEqual(px(pre, x, y), px(pin, x, y), `pre ${x},${y}`); + // After it (0.5 + 0.6 s → frame 33 on), the footage's top-left corner is + // at (8,20) and everything right of and below the target box is ground. + const post = rgb(45); + const bg = [0x12, 0x10, 0x1a]; + const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); + for (const [x, y] of [[300, 10], [310, 170], [4, 4], [160, 160], [260, 100], [100, 15]]) { + assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}: ${px(post, x, y)}`); + } + // The footage's own first column/row (smptebars' left edge) now starts at the target corner. + assert.ok(!close(px(post, 10, 22), bg), "footage at the target box"); + assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("ffmpeg: a move that starts inside the hold glides the frozen frame to the target box", + { skip: !haveFfmpeg }, () => { + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-mh-")); + try { + const [, b] = segments(dir); + // b is 2 s (60 frames), held 1 s; the move starts at 2.1 s -- inside the hold. + const move = { segment: "b", at: 0, segmentAt: 2.1, seconds: 0.6, + from: { x: 20, y: 10, width: 280, height: 150 }, to: { x: 8, y: 20, width: 240, height: 128 } }; + const c = joinInputChain(0, { hold: 1, move }, R); + c.parts.push(`${c.a}anullsink`); + const all = md5s(ff(["-i", b, "-filter_complex", c.parts.join(";"), "-map", c.v, "-f", "framemd5", "-"])); + assert.equal(all.length, 90, "the hold's 30 frames are all there"); + const rgb = (n) => ff(["-i", b, "-filter_complex", `${c.parts.join(";")};${c.v}select=eq(n\\,${n})[o]`, "-map", "[o]", + "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const px = (buf, x, y) => [...buf.subarray((y * 320 + x) * 3, (y * 320 + x) * 3 + 3)]; + const bg = [0x12, 0x10, 0x1a]; + const close = (p, q, tol = 3) => p.every((v, i) => Math.abs(v - q[i]) <= tol); + // Frozen and not yet moved at 2.0 s; moving from 2.1 s; landed by 2.7 s. + assert.equal(all[60], all[62], "held, before the move"); + assert.ok(!close(px(rgb(62), 290, 150), bg), "footage still at the from box"); + assert.notEqual(all[66], all[62], "the held frame moves"); + const post = rgb(85); + for (const [x, y] of [[300, 10], [310, 170], [260, 100]]) assert.ok(close(px(post, x, y), bg), `ground at ${x},${y}`); + assert.ok(!close(px(post, 245, 145), bg), "footage to the target box's far corner"); + assert.deepEqual(all.slice(82), Array(8).fill(all[82]), "landed, then still"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + +test("verify-build: a held clip's freeze is found in the file, and a cut that dropped it is refused", { skip: !haveFfmpeg }, async () => { + const { verifyHolds } = await import("./verify-build.mjs"); + const dir = mkdtempSync(path.join(tmpdir(), "deck-room-vb-")); + try { + const render = { ...R, width: 640, height: 360, chrome: { engine: "hyperframes", layout: "deck", deck: { height: 120, posts: { width: 320 } } } }; + const held = path.join(dir, "held.mp4"); + const moving = path.join(dir, "moving.mp4"); + const enc = ["-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18"]; + ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=2", "-vf", "tpad=stop_mode=clone:stop_duration=1", ...enc, held]); + ff(["-f", "lavfi", "-i", "testsrc2=s=640x360:r=30:d=3", ...enc, moving]); + const schedule = { fps: 30, total: 3, segments: [{ id: "c1", start: 0, duration: 3, end: 3, hold: 1 }] }; + const ok = []; + const got = await verifyHolds(held, schedule, render, ok); + assert.deepEqual(ok, []); + assert.equal(got.length, 1); + assert.ok(got[0].diff <= 1.5, `diff ${got[0].diff}`); + const bad = []; + await verifyHolds(moving, schedule, render, bad); + assert.equal(bad.length, 1); + assert.match(bad[0], /c1 is held 1s but its picture moves during the hold/); + // Nothing held, nothing to check. + assert.deepEqual(await verifyHolds(moving, { ...schedule, segments: [{ id: "c1", start: 0, duration: 3, end: 3 }] }, render, bad), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -38,14 +38,25 @@ export const DECK_DEFAULTS = Object.freeze({ motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }), // The manifest's `posts`, drawn as cards over the footage at the end of the // clip each one is attached to. position | "top-left". - posts: Object.freeze({ show: true, seconds: 2, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24 }), + // `hold` freezes the last frame of a clip that carries posts for that long, + // so the last of them can be read before the next clip; `shift` moves the + // footage away from the posts column (scaled to `scale`, over `seconds`) + // while they are up, or is `false`. + posts: Object.freeze({ + show: true, seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, inset: 24, + shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), + }), }); /** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */ export const SUBTITLE_TOKENS = Object.freeze(["channel", "title", "date", "clock"]); -/** Segment types the deck slides away over when `overCards: "hide"`. */ -export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger"]); +/** + * Segment types the deck slides away over when `overCards: "hide"`. A + * `teaser` is one too, and the deck slides away over it whatever `overCards` + * says: it is a full-frame finale, never framed into the footage box. + */ +export const CARD_TYPES = Object.freeze(["card", "scroll", "chart", "ledger", "teaser"]); /** Does this render block ask for the deck? Absent, nothing in this file runs. */ export function deckOn(render) { @@ -63,6 +74,12 @@ export function resolveDeck(render) { ? { ...base, ...v } : v; } + // `posts.shift` is the one setting two levels down: `false` turns it off, + // an object fills from the default's. + const sh = d.posts?.shift; + if (sh !== undefined && sh !== null) { + merged.posts = { ...merged.posts, shift: sh === false ? false : { ...DECK_DEFAULTS.posts.shift, ...sh } }; + } return merged; } @@ -101,6 +118,9 @@ export function validateChrome(chrome, render = {}) { if (render.chromeEngine !== undefined) { errors.push("render.chrome replaces render.chromeEngine — remove chromeEngine"); } + // The end fade is a render key the deck's cut is finished with; checked + // here too, so a writer that validates the deck refuses a bad one. + errors.push(...validateEndFade(render)); const d = chrome.deck ?? {}; if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"]; const w = "render.chrome.deck"; @@ -167,7 +187,16 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); - sub("posts", ["show", "seconds", "position", "width", "qrSize", "maxLines", "inset"], (p) => { + sub("posts", ["show", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => { + if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`); + if (p.shift !== undefined && p.shift !== false) { + if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`); + else { + unknownKeys(p.shift, ["scale", "seconds"], `${w}.posts.shift`, errors); + if (p.shift.scale !== undefined && !numIn(p.shift.scale, 0.5, 1)) errors.push(`${w}.posts.shift.scale must be from 0.5 to 1`); + if (p.shift.seconds !== undefined && !numIn(p.shift.seconds, 0, 3)) errors.push(`${w}.posts.shift.seconds must be from 0 to 3`); + } + } if (p.show !== undefined && typeof p.show !== "boolean") errors.push(`${w}.posts.show must be true or false`); if (p.seconds !== undefined && !numIn(p.seconds, 0.5, 10)) errors.push(`${w}.posts.seconds must be from 0.5 to 10`); if (p.position !== undefined && !["top-right", "top-left"].includes(p.position)) { @@ -369,16 +398,30 @@ export function scheduleFrom(durs, D) { */ export function estimatedDuration(entry, render = {}) { if (entry.type === "clip") { - const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); - const lead = render.leadIn ?? 0.4; - const a = hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start; - const b = hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end; - return Math.max(1, b - a); + const { from, to } = playWindow(entry, render); + return Math.max(1, to - from); } if (entry.type === "image") return Number(entry.seconds ?? 4); return Number(entry.seconds ?? 5); } +/** + * The SOURCE seconds a clip asks to play, before silence snapping: the cut + * (`cutStart`/`cutEnd`) with the lead-in breath before it, clamped into the + * extent, when there is one; else the extent (`start`/`end`). The build + * snaps each end to a nearby silence, so the segment's true start is this + * `from` moved by up to `snapWindow` -- which is why a build records the real + * one beside the segment (`<id>.cut.json`). + */ +export function playWindow(entry, render = {}) { + const hasCut = Number.isFinite(entry.cutStart) && Number.isFinite(entry.cutEnd); + const lead = render.leadIn ?? 0.4; + return { + from: hasCut ? Math.max(entry.start, entry.cutStart - lead) : entry.start, + to: hasCut ? Math.min(entry.end, entry.cutEnd) : entry.end, + }; +} + /** The crossfade a build of this render block will use. */ export function transitionOf(render, { noXfade = false } = {}) { const t = render?.transition ?? 0.5; @@ -398,6 +441,7 @@ export function isMultiChannel(entries, provenance = {}) { /** Is the deck hidden over this segment? */ export function hidesDeck(entry, deck) { + if (entry.type === "teaser") return true; return deck.overCards === "hide" && CARD_TYPES.includes(entry.type); } @@ -409,6 +453,11 @@ export function deckText(entry, meta, provenance, deck, multiChannel) { if (subtitle === undefined) { if (entry.type === "clip") { subtitle = deckSubtitle(attributionParts(entry, meta ?? {}, provenance), deck.subtitle, multiChannel); + } else if (entry.type === "teaser") { + // The deck is never up over a teaser; its words are what a table of + // the cut (umtool's On-screen rows, the schedule) names it by. + if (!title) title = teaserTitle(entry); + subtitle = ""; } else if (entry.type === "image") { subtitle = deckSubtitle( { channel: "", title: String(entry.title ?? "").trim(), date: String(entry.date ?? "").trim(), at: null }, @@ -457,11 +506,18 @@ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [], }) { const deck = resolveDeck(render); - const { starts, total } = scheduleFrom(durs, D); + // A clip that carries posts is held on its last frame for `posts.hold`: the + // hold is part of the segment's length in the cut, so every start, the total + // and the posts' timing below are measured with it. `durs` are the segments' + // own (probed or estimated) lengths. + const holds = deck.posts.show ? postHolds({ posts, entries, metas, render }) : new Map(); + const full = durs.map((d, i) => d + (holds.get(entries[i].id) ?? 0)); + const { starts, total } = scheduleFrom(full, D); const multiChannel = isMultiChannel(entries, provenance); const round = (v) => Math.round(v * 1000) / 1000; - const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: durs[i] })); + const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] })); const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : []; + const moves = placed.length ? footageMoves({ posts: placed, segments: segs, render }) : []; return { version: 1, kind: "deck", @@ -476,8 +532,10 @@ export function deckSchedule({ id: e.id, type: e.type, start: round(starts[i]), - duration: round(durs[i]), - end: round(starts[i] + durs[i]), + duration: round(full[i]), + end: round(starts[i] + full[i]), + // Only on a held clip, so a cut without posts writes the schedule it always did. + ...(holds.get(e.id) ? { hold: round(holds.get(e.id)) } : {}), title, subtitle, qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null, @@ -487,6 +545,7 @@ export function deckSchedule({ // Present only when there are posts to draw, so a cut without them writes // the schedule it always did. ...(placed.length ? { posts: placed.map((p) => ({ ...p, appear: round(p.appear), out: p.out.map(round) })) } : {}), + ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; } @@ -773,14 +832,404 @@ export function snapWindow(window, { fps, total }) { * height less the insets. Cards stack top-down inside it. */ export function postsGeometry(render) { - const { footage: f } = deckGeometry(render); + const { W, footage: f } = deckGeometry(render); const p = resolveDeck(render).posts; const width = even(p.width); const height = even(f.height - 2 * p.inset); - const x = p.position === "top-left" ? f.x + p.inset : f.x + f.width - p.inset - width; + const left = p.position === "top-left"; + // With `shift` the footage makes room, so the column sits at the FRAME's + // edge; without it, inside the footage box as before. + const x = p.shift + ? (left ? p.inset : W - p.inset - width) + : (left ? f.x + p.inset : f.x + f.width - p.inset - width); return { x: Math.round(x), y: Math.round(f.y + p.inset), width, height }; } +/** + * The footage box while a clip's posts are up (`posts.shift`): scaled by + * `shift.scale` about nothing in particular, its far edge `inset` from the + * frame edge AWAY from the posts column, centred in the height above the deck. + * null when shift is off. + */ +export function shiftedFootage(render) { + const { W, H, deck: d, footage: f } = deckGeometry(render); + const p = resolveDeck(render).posts; + if (!p.shift) return null; + const width = even(f.width * p.shift.scale); + const height = even(f.height * p.shift.scale); + const x = p.position === "top-left" ? W - p.inset - width : p.inset; + return { x: Math.round(x), y: Math.floor((H - d.height - height) / 2), width, height }; +} + +/** + * How long each clip that carries posts is held on its last frame (entry id → + * seconds), in WHOLE FRAMES: `tpad` clones a whole number of frames (2.5 s at + * 25 fps is 63, not 62.5) while `apad` pads exactly, so an unrounded hold + * would make a hard cut with several held clips longer than its schedule. + */ +export function postHolds({ posts = [], entries = [], metas = [], render }) { + const fps = render?.fps ?? 30; + const hold = Math.round(resolveDeck(render).posts.hold * fps) / fps; + const out = new Map(); + if (!(hold > 0)) return out; + for (const a of attachPosts({ posts, entries, metas })) out.set(a.entryId, hold); + return out; +} + +/** + * When the footage moves aside for a clip's posts: one move per carrying clip, + * starting as its first post appears (`at`, cut clock; `segmentAt`, the + * segment's own clock, its hold included) and easing over `shift.seconds` + * from the footage box to `shiftedFootage`. It stays there to the end of the + * segment; the next segment comes in at the normal box through the + * transition. `segmentAt` may fall inside the hold: the build runs the move + * after the hold, so the frozen frame moves too. + * + * @returns {Array<{ segment, at, segmentAt, seconds, from, to }>} + */ +export function footageMoves({ posts, segments, render }) { + const to = shiftedFootage(render); + if (!to) return []; + const from = deckGeometry(render).footage; + const seconds = resolveDeck(render).posts.shift.seconds; + const first = new Map(); + for (const p of posts) first.set(p.segment, Math.min(first.get(p.segment) ?? Infinity, p.appear)); + return segments + .filter((s) => first.has(s.id)) + .map((s) => ({ segment: s.id, at: first.get(s.id), segmentAt: first.get(s.id) - s.start, seconds, from, to })); +} + +// --------------------------------------------------------------------------- +// Cut edits made where the cut is joined, like the hold: a clip's `muteFrom` +// and the cut's `render.endFade`. Neither touches a segment file, so +// `--chrome-only` changes either without rebuilding a clip. Pure here: the +// validators (umtool's writers and the build share them) and the arithmetic. +// --------------------------------------------------------------------------- + +/** The fade into a `muteFrom`'s silence (`mute.mjs`, dependency-free so a client bundle can take it alone). */ +export { MUTE_FADE } from "./mute.mjs"; + +/** `render.endFade`'s upper bound, in seconds. */ +export const END_FADE_MAX = 10; + +/** + * Why one timeline entry's `muteFrom` cannot be built, as sentences. It is in + * SOURCE seconds, like `start`/`end`/`cutEnd`: a number within the clip's + * extent. Absent (or null) is fine. + */ +export function validateMuteFrom(entry, where = `timeline entry ${entry?.id ?? "?"}`) { + const v = entry?.muteFrom; + if (v === undefined || v === null) return []; + if (entry.type !== "clip") return [`${where}.muteFrom: only a clip has sound to mute`]; + if (typeof v !== "number" || !Number.isFinite(v)) return [`${where}.muteFrom must be a number of source seconds`]; + if (v < entry.start || v > entry.end) { + return [`${where}.muteFrom ${v} is outside the clip's ${entry.start}–${entry.end}`]; + } + return []; +} + +/** Why `render.endFade` cannot be built, as sentences: seconds from 0 (off) to END_FADE_MAX. */ +export function validateEndFade(render) { + const v = render?.endFade; + if (v === undefined || v === null) return []; + if (!numIn(v, 0, END_FADE_MAX)) return [`render.endFade must be from 0 to ${END_FADE_MAX} seconds`]; + return []; +} + +/** Every `muteFrom` in the timeline and `render.endFade`, checked: the build refuses with these before it fetches. */ +export function validateCutEdits(manifest) { + const errors = []; + (manifest?.timeline ?? []).forEach((e, i) => errors.push(...validateMuteFrom(e, `timeline[${i}] (${e?.id ?? "?"})`))); + errors.push(...validateEndFade(manifest?.render)); + return errors; +} + +/** The end fade a render block asks for, in seconds (0 = none). */ +export const endFadeOf = (render) => (numIn(render?.endFade, 0, END_FADE_MAX) ? render.endFade : 0); + +/** + * Where a clip's `muteFrom` falls in its SEGMENT's clock, from the source + * second the segment really starts at. + * + * The build cuts a segment from the snapped start, and records that start + * beside it (`<id>.cut.json`: `{ video, start, end }`, source seconds). A + * record is believed when it names this clip's video and is as long as the + * segment (`seconds`, its probed length) to within two frames; otherwise -- + * a segment built before records existed, or one copied without its record -- + * the start is the unsnapped `playWindow` start, and `note` says so: snapping + * may have moved the true start by up to `snapWindow` seconds. + * + * @returns {{ at: number, source: "record" | "window", note?: string }} + * `at` in segment seconds, never below 0 (a muteFrom before the segment's + * start mutes it from its first sample). + */ +export function muteSegmentSeconds({ entry, record = null, render = {}, seconds = null }) { + const fps = render.fps ?? 30; + const ok = record && record.video === entry.video && + Number.isFinite(record.start) && Number.isFinite(record.end) && + (seconds == null || Math.abs(record.end - record.start - seconds) <= 2 / fps); + const at = (from) => Math.max(0, Math.round((entry.muteFrom - from) * 1000) / 1000); + if (ok) return { at: at(record.start), source: "record" }; + const { from } = playWindow(entry, render); + return { + at: at(from), + source: "window", + note: + `${entry.id}: ${record ? "the cut record does not match the segment" : "no cut record beside the segment"} — ` + + `muteFrom measured from the unsnapped start ${from}; the real start may differ by up to ` + + `${render.snapWindow ?? 1.6}s. Rebuild the clip to record it.`, + }; +} + +// --------------------------------------------------------------------------- +// The teaser: a full-frame graphic card -- a season teaser's "coming soon" +// screen -- whose words are the manifest's. Its segment is a HyperFrames +// render (chrome-teaser.mjs draws it, compose-chrome renders it, the build +// encodes it). Pure here: what the words are, and why they cannot be drawn. +// +// { "type": "teaser", "id": "fin", "seconds": 7, +// "lines": ["Pirate Software", +// { "text": "The Largest Ferret Rescue in the United States", +// "break": "in the United States" }, +// "February 2027"], +// "tail": "?", "hits": true } +// +// A line is a string, or `{ text, break }`: `break` is the END of `text` set +// as a smaller second tier under the rest, a beat later. The tail is appended +// to the last line and fades in on its own. `hits` (default true) puts a +// trailer hit under each pop and a swell under the tail; false is silence. +// Roles follow position: with three +// or more lines the first is the overline and the last the kicker (a date), +// everything between is a title; two lines are an overline and a title; one +// is a title. +// --------------------------------------------------------------------------- + +/** + * The teaser's limits: lines, seconds, characters per line, the tail's length, + * and `fit`: the characters one ROW may hold in its role -- the first tier + * (`head`) in the line's role, a `break` in `sub`, the tail and its gap + * counted on the row that carries it. A row wider than 80 % of the frame + * shrinks to its role's floor (TEASER_TYPE in chrome-teaser.mjs) and no + * further, so a longer row spilled past the frame's edges. Measured at the + * floor in the face, on ordinary headline words in capitals: the title holds + * 36–37 there, the kicker 58–60, the overline 65–67, the second tier 69–71; + * each limit is a little under. A row of only wide capitals (M, W) can still + * spill at these counts. + */ +export const TEASER_LIMITS = Object.freeze({ + lines: [1, 5], seconds: [3, 20], chars: 80, tail: 8, + fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }), +}); + +const LINE_KEYS = ["text", "break"]; + +/** + * A teaser's lines, normalised: `{ text, head, sub, role }` each, `head` the + * part drawn on the first tier and `sub` the second tier (`break`) or null. + * Trims; assumes `validateTeaser` passed. + * + * @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker" }>} + */ +export function teaserLines(entry) { + const lines = Array.isArray(entry?.lines) ? entry.lines : []; + const n = lines.length; + return lines.map((l, i) => { + const text = String(isObj(l) ? l.text ?? "" : l ?? "").trim(); + const brk = isObj(l) && typeof l.break === "string" ? l.break.trim() : ""; + const sub = brk && text.endsWith(brk) && text.length > brk.length ? brk : null; + const head = sub ? text.slice(0, text.length - sub.length).trim() : text; + const role = n >= 3 ? (i === 0 ? "overline" : i === n - 1 ? "kicker" : "title") + : n === 2 ? (i === 0 ? "overline" : "title") + : "title"; + return { text, head, sub, role }; + }); +} + +/** The teaser's tail, trimmed, or "" for none. */ +export const teaserTail = (entry) => (typeof entry?.tail === "string" ? entry.tail.trim() : ""); + +/** + * What a teaser is called where a cut names its entries -- its chapter, and + * its row in umtool: the lines joined with " — ", the tail after the last. + */ +export function teaserTitle(entry) { + const texts = teaserLines(entry).map((l) => l.text).filter(Boolean); + const tail = teaserTail(entry); + if (tail && texts.length) texts[texts.length - 1] = `${texts[texts.length - 1]} ${tail}`; + return texts.join(" — "); +} + +/** + * The teaser's motion, in seconds -- ONE copy, read by the composition (the + * cues, chrome-teaser.mjs) and by the build (the hits under them). The first + * line lands `first` into the card (after the incoming dissolve); each next + * one `gap` after the one before, or after its second tier, which pops `sub` + * after its first. A line slams in from `slam`× its size and blurred and hits + * -- undershooting to `under` -- `hit` after it starts, then settles to rest + * over `settle`. The tail starts `tailAfter` after the last line's pop and + * fades in over `tailDur`. The last `endRoom` seconds hold still for the + * cut's end fade; a card too short for all of it plays every beat + * proportionally faster (`teaserTimes`). + */ +export const TEASER_MOTION = Object.freeze({ + first: 0.55, gap: 0.7, sub: 0.3, slam: 1.42, under: 0.968, hit: 0.2, settle: 0.5, + blur: 18, tailAfter: 0.8, tailDur: 1.7, endRoom: 1.2, push: 1.065, grainHz: 12, +}); + +/** + * When everything in a teaser happens, in the card's clock: per line its + * start (`at`), its impact (`impact` = at + hit, where the slam lands, the + * flash fires and the hit sounds) and its second tier's pop (`subAt`, null + * without one); the tail's start and length; and `scale` (< 1 when the beats + * were compressed to fit the card). `T` scales any motion length the same way. + * + * @returns {{ lines: Array<{ at: number, impact: number, subAt: number|null }>, + * tailAt: number|null, tailDur: number, end: number, scale: number, T: (v: number) => number }} + */ +export function teaserTimes(lines, tail, seconds, m = TEASER_MOTION) { + let t = m.first; + const raw = []; + lines.forEach((l, i) => { + if (i > 0) t += m.gap; + const at = t; + const subAt = l.sub ? at + m.sub : null; + if (subAt != null) t = subAt; + raw.push({ at, subAt }); + }); + const tailRaw = tail ? t + m.tailAfter : null; + const endRaw = tailRaw != null ? tailRaw + m.tailDur : t + m.hit + m.settle; + const room = Math.max(0.5, seconds - m.endRoom); + const scale = endRaw > room ? room / endRaw : 1; + const r = (v) => Math.round(v * 10000) / 10000; + const T = (v) => r(v * scale); + return { + lines: raw.map((b) => ({ at: T(b.at), impact: r(T(b.at) + T(m.hit)), subAt: b.subAt == null ? null : T(b.subAt) })), + tailAt: tailRaw == null ? null : T(tailRaw), + tailDur: T(m.tailDur), + end: T(endRaw), + scale: r(scale), + T, + }; +} + +/** + * The teaser's sound design, as data: one trailer hit under each pop, at the + * moment the composition says it lands, and a low swell under the tail's + * slow entrance. Empty when `hits: false`. + * + * Gains are relative (the main title is 1): a title's hit is the biggest, an + * overline's and a kicker's a little smaller, a second tier's lighter and + * shorter. The build turns this into one ffmpeg graph (`teaserAudioGraph`). + * + * @returns {Array<{ kind: "hit"|"swell", at: number, role: string, gain: number, + * decay: number, f0: number, f1: number, dur?: number }>} + */ +export function teaserHits(entry) { + if (entry?.hits === false) return []; + const lines = teaserLines(entry); + const tail = teaserTail(entry); + const times = teaserTimes(lines, tail, Number(entry.seconds)); + const out = []; + const HIT = { + title: { gain: 1, decay: 0.42, f0: 92, f1: 40 }, + overline: { gain: 0.72, decay: 0.34, f0: 96, f1: 44 }, + kicker: { gain: 0.8, decay: 0.36, f0: 94, f1: 42 }, + sub: { gain: 0.42, decay: 0.16, f0: 120, f1: 64 }, + }; + lines.forEach((l, i) => { + out.push({ kind: "hit", at: times.lines[i].impact, role: l.role, ...HIT[l.role] }); + if (l.sub && times.lines[i].subAt != null) out.push({ kind: "hit", at: times.lines[i].subAt, role: "sub", ...HIT.sub }); + }); + if (tail && times.tailAt != null) { + out.push({ kind: "swell", at: times.tailAt, role: "tail", gain: 0.34, decay: 0.7, f0: 46, f1: 62, dur: times.tailDur }); + } + return out; +} + +/** + * Why one teaser entry cannot be built, as sentences (empty: it can). + * + * @returns {string[]} + */ +export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"}`) { + const errors = []; + const [lo, hi] = TEASER_LIMITS.lines; + const [slo, shi] = TEASER_LIMITS.seconds; + if (typeof entry?.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.id)) { + errors.push(`${where}.id must be letters, digits, dashes or underscores (it names the segment's file)`); + } + if (!numIn(entry?.seconds, slo, shi)) errors.push(`${where}.seconds must be from ${slo} to ${shi}`); + const oneLine = (s, w) => { + if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; } + if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; } + if (s.trim().length > TEASER_LIMITS.chars) { + errors.push(`${w} is ${s.trim().length} characters (at most ${TEASER_LIMITS.chars})`); + return false; + } + return true; + }; + const lines = entry?.lines; + if (!Array.isArray(lines) || lines.length < lo || lines.length > hi) { + errors.push(`${where}.lines must be a list of ${lo} to ${hi} lines`); + } else { + lines.forEach((l, i) => { + const w = `${where}.lines[${i}]`; + if (typeof l === "string") { oneLine(l, w); return; } + if (!isObj(l)) { errors.push(`${w} must be a string or { text, break }`); return; } + for (const k of Object.keys(l)) if (!LINE_KEYS.includes(k)) errors.push(`${w}.${k} is not a teaser line field`); + if (!oneLine(l.text, `${w}.text`)) return; + if (l.break === undefined || l.break === null) return; + if (!oneLine(l.break, `${w}.break`)) return; + const text = l.text.trim(); + const brk = l.break.trim(); + if (!text.endsWith(brk)) errors.push(`${w}.break must be the end of its text ("${brk}" is not how "${text}" ends)`); + else if (!text.slice(0, text.length - brk.length).trim()) errors.push(`${w}.break leaves nothing for the first tier`); + }); + } + if (entry?.hits !== undefined && typeof entry.hits !== "boolean") errors.push(`${where}.hits must be true or false`); + if (entry?.tail !== undefined && entry?.tail !== null) { + if (typeof entry.tail !== "string" || !entry.tail.trim()) errors.push(`${where}.tail must be a short string, or absent`); + else if (/[\r\n]/.test(entry.tail)) errors.push(`${where}.tail must be one line`); + else if (entry.tail.trim().length > TEASER_LIMITS.tail) { + errors.push(`${where}.tail is ${entry.tail.trim().length} characters (at most ${TEASER_LIMITS.tail})`); + } + } + // What fits the frame, row by row, once every line and the tail are sound. + if (!errors.length) { + const rows = teaserLines(entry); + const tail = teaserTail(entry); + const fit = TEASER_LIMITS.fit; + rows.forEach((l, i) => { + const w = `${where}.lines[${i}]`; + const tailHere = tail && i === rows.length - 1 ? tail.length + 1 : 0; + const head = l.head.length + (l.sub ? 0 : tailHere); + if (head > fit[l.role]) { + errors.push( + `${w} ${l.sub ? "before its break " : ""}is ${head} characters${!l.sub && tailHere ? " with the tail" : ""}, ` + + `and at most ${fit[l.role]} fit the frame as the ${l.role}` + + (l.sub ? "" : " -- shorten it, or set its end as a break"), + ); + } + if (l.sub && l.sub.length + tailHere > fit.sub) { + errors.push( + `${w}.break is ${l.sub.length + tailHere} characters${tailHere ? " with the tail" : ""}, ` + + `and at most ${fit.sub} fit the frame as the second tier`, + ); + } + }); + } + return errors; +} + +/** Every teaser in the timeline, checked: the build refuses with these before it fetches. */ +export function validateTeasers(manifest) { + const errors = []; + (manifest?.timeline ?? []).forEach((e, i) => { + if (e?.type === "teaser") errors.push(...validateTeaser(e, `timeline[${i}] (${e.id ?? "?"})`)); + }); + return errors; +} + // --------------------------------------------------------------------------- // The render cache and the renderer command. // --------------------------------------------------------------------------- diff --git a/umtool/report-to-video/deck.test.mjs b/umtool/report-to-video/deck.test.mjs @@ -241,8 +241,10 @@ test("hyperframesCommand: pinned by default, overridable", () => { }); // ---- posts ----------------------------------------------------------------- -import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, validatePosts } from "./deck.mjs"; +import { attachPosts, clipDay, postSchedule, postsGeometry, postWindows, shiftedFootage, validatePosts } from "./deck.mjs"; +// The first posts release's settings, which the timing tests below were written for. +const POSTS2 = { ...RENDER, chrome: { ...CHROME, deck: { posts: { seconds: 2, hold: 0, shift: false } } } }; const POST = (id, date, extra = {}) => ({ id, platform: "bluesky", date, text: `post ${id}`, url: `https://bsky.app/profile/a/post/${id}`, ...extra, }); @@ -290,7 +292,7 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr { id: "c3", start: 24.5, duration: 4 }, ]; const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27"), POST("z", "2026-01-22")]; - const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER }); + const s = postSchedule({ posts, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: POSTS2 }); // c2 carries a and b; its leave is the dissolve into k1 at 21. assert.deepEqual(s.filter((p) => p.segment === "c2").map((p) => [p.id, p.slot, p.of, p.appear, p.out]), [ ["a", 0, 2, 17, [21, 21.5]], @@ -305,11 +307,11 @@ test("postSchedule: stacked from the end, one every `seconds`, leaving in the tr const h = postSchedule({ posts, entries: CLIPS, metas: METAS, segments: [ { id: "c1", start: 0, duration: 10 }, { id: "c2", start: 10, duration: 12 }, { id: "k1", start: 22, duration: 4 }, { id: "c3", start: 26, duration: 4 }, - ], D: 0, total: 30, render: RENDER }); + ], D: 0, total: 30, render: POSTS2 }); assert.deepEqual(h.find((p) => p.id === "b").out, [21.7, 22]); // Too short for k × seconds: what is left after the incoming dissolve is shared. const many = ["m1", "m2", "m3", "m4"].map((id) => POST(id, "2026-01-01")); - const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: RENDER }); + const sq = postSchedule({ posts: many, entries: CLIPS, metas: METAS, segments, D: 0.5, total: 28.5, render: POSTS2 }); assert.deepEqual(sq.map((p) => Number(p.appear.toFixed(3))), [25, 25.8, 26.6, 27.4]); // Windows: one per carrying clip, first appearance to the end of the leave. assert.deepEqual(postWindows({ posts: s }), [ @@ -329,10 +331,12 @@ test("deckSchedule carries posts only when there are some; posts.show false drop assert.equal("posts" in estimateSchedule({ render: off, provenance: PROV, timeline, posts: [POST("p", "2026-01-01")] }), false); }); -test("postsGeometry: a column inside the footage box", () => { - assert.deepEqual(postsGeometry(RENDER), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 }); - const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10 } } } }; +test("postsGeometry: a column inside the footage box (no shift), at the frame's edge (shift)", () => { + assert.deepEqual(postsGeometry(POSTS2), { x: 173 + 1574 - 24 - 600, y: 26, width: 600, height: 838 }); + const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left", width: 500, inset: 10, shift: false } } } }; assert.deepEqual(postsGeometry(left), { x: 183, y: 12, width: 500, height: 866 }); + // Shift on (the default): the column moves to the frame's right edge. + assert.deepEqual(postsGeometry(RENDER), { x: 1920 - 24 - 600, y: 26, width: 600, height: 838 }); }); test("validatePosts and the posts settings refuse in sentences", () => { @@ -366,3 +370,46 @@ test("posts fit: a deck without posts is never refused for the column; drawn pos // ...unless it switches them off. assert.deepEqual(validateChrome({ ...CHROME, deck: { footageScale: 0.5, posts: { show: false } } }, narrow), []); }); + +test("posts hold: a carrying clip is held on its last frame, and every start after it moves", () => { + // CLIPS: c1 (2024-09-05), c2 (2024-09-29 by record), k1 card, c3 (2025-12-08). + const posts = [POST("a", "2024-10-19"), POST("z", "2026-01-22")]; + const durs = [10, 12, 4, 4]; + const base = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS }); + const held = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts }); + assert.equal("hold" in base.segments[1], false); + assert.deepEqual(held.segments.map((x) => x.hold ?? 0), [0, 2.5, 0, 2.5]); + assert.deepEqual(held.segments.map((x) => x.duration), [10, 14.5, 4, 6.5]); + assert.deepEqual(held.segments.map((x) => x.start), [0, 9.5, 23.5, 27]); + assert.equal(held.total, base.total + 5); + // Four seconds each by default: c2's one post is up for its last 4 s, hold included. + const a = held.posts.find((p) => p.id === "a"); + assert.deepEqual([a.segment, a.appear, a.out], ["c2", 19.5, [23.5, 24]]); + // hold 0: nothing held, the schedule's lengths are the segments'. + const none = { ...RENDER, chrome: { ...CHROME, deck: { posts: { hold: 0 } } } }; + const h0 = deckSchedule({ entries: CLIPS, durs, D: 0.5, render: none, provenance: PROV, metas: METAS, posts }); + assert.deepEqual(h0.segments.map((x) => x.duration), durs); +}); + +test("posts shift: the footage moves aside from the column while a clip's posts are up", () => { + const to = shiftedFootage(RENDER); + // 86 % of the 1574×886 box, 24 px from the left, centred above the deck. + assert.deepEqual(to, { x: 24, y: 64, width: 1354, height: 762 }); + // Room: the column at the frame edge starts at 1296; the footage ends at 1378. + assert.equal(postsGeometry(RENDER).x - (to.x + to.width), -82); + const left = { ...RENDER, chrome: { ...CHROME, deck: { posts: { position: "top-left" } } } }; + assert.equal(shiftedFootage(left).x, 1920 - 24 - 1354); + assert.equal(shiftedFootage(POSTS2), null); + const posts = [POST("a", "2024-10-19"), POST("b", "2024-11-27")]; + const s = deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: RENDER, provenance: PROV, metas: METAS, posts }); + assert.deepEqual(s.moves, [{ + segment: "c2", at: 15.5, segmentAt: 6, seconds: 0.6, + from: { x: 173, y: 2, width: 1574, height: 886 }, to, + }]); + assert.equal("moves" in deckSchedule({ entries: CLIPS, durs: [10, 12, 4, 4], D: 0.5, render: POSTS2, provenance: PROV, metas: METAS, posts }), false); + // Validation of the new keys. + assert.deepEqual(validateChrome({ ...CHROME, deck: { posts: { shift: false, hold: 0 } } }, RENDER), []); + assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { scale: 0.2 } } } }, RENDER)[0], /shift.scale/); + assert.match(validateChrome({ ...CHROME, deck: { posts: { shift: { speed: 1 } } } }, RENDER)[0], /speed is not a deck setting/); + assert.match(validateChrome({ ...CHROME, deck: { posts: { hold: 20 } } }, RENDER)[0], /hold/); +}); diff --git a/umtool/report-to-video/mute.mjs b/umtool/report-to-video/mute.mjs @@ -0,0 +1,7 @@ +// The mute mark's fade, alone: no imports, so a client bundle (umtool's clip +// bench previews the mute as the build makes it) can take it without pulling +// in deck.mjs and what deck.mjs imports (node:crypto, attribution). +// deck.mjs re-exports it; the build and the bench read the same number. + +/** The fade into a `muteFrom`'s silence, in seconds: long enough not to click, short enough to keep the next word out. */ +export const MUTE_FADE = 0.04; diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -24,6 +24,7 @@ "./cues": "./cues.mjs", "./deck": "./deck.mjs", "./ledger-totals": "./ledger-totals.mjs", + "./mute": "./mute.mjs", "./package.json": "./package.json", "./render-cards": "./render-cards.mjs", "./resolve-windows": "./resolve-windows.mjs", diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs @@ -38,6 +38,7 @@ import { brandFaces, brandManifest, brandSvgFace, childOpts } from "./brand.mjs" import { BRAND_CARD_STYLES, renderBrandCard } from "./brand-cards.mjs"; import { FIRA_SANS, textWidth } from "./svg-faces.mjs"; import { deckOn, resolveDeck } from "./deck.mjs"; +import { ensureWriteDir } from "../lib/report/storage.mjs"; const execFileP = promisify(execFile); @@ -1607,6 +1608,7 @@ async function main() { const outDir = flag("--out") ?? path.join(path.dirname(path.resolve(manifestPath)), "out"); const only = flag("--only"); + await ensureWriteDir(outDir); // a project's out/ may be a link to the media root await mkdir(path.join(outDir, "cards"), { recursive: true }); const cards = manifest.timeline.filter( diff --git a/umtool/report-to-video/teaser-audio.test.mjs b/umtool/report-to-video/teaser-audio.test.mjs @@ -0,0 +1,75 @@ +// The teaser's sound, through real ffmpeg: a hit starts on the frame its pop +// lands on, nothing clips, and `hits: false` is digital silence. +// +// The onset of hit i is measured as the first sample where the graph WITH it +// differs from the same graph WITHOUT it: the hits overlap (the second tier +// lands 0.1 s into the title's decay), so "the first loud sample after the +// cue" would find the previous hit's tail. Every layer up to the limiter is +// linear and the noise is a hash of the sample number, so the difference is +// hit i alone until the limiter engages -- after its onset. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { teaserAudioGraph } from "./build-video.mjs"; +import { teaserHits } from "./deck.mjs"; + +const have = spawnSync("ffmpeg", ["-version"]).status === 0; +const RENDER = { fps: 30, audioRate: 48000, audioChannels: 2 }; +const FERRET = Object.freeze({ + type: "teaser", id: "fin", seconds: 7, + lines: ["Pirate Software", { text: "The Largest Ferret Rescue in the United States", break: "in the United States" }, "February 2027"], + tail: "?", +}); + +/** The graph rendered to raw float samples, channel 0. */ +function samples(graph) { + const r = spawnSync("ffmpeg", [ + "-nostdin", "-v", "error", "-filter_complex", graph, "-map", "[ta]", "-f", "f32le", "-ac", "2", "-", + ], { maxBuffer: 1 << 26 }); + assert.equal(r.status, 0, String(r.stderr)); + const f = new Float32Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 4); + const ch0 = new Float32Array(f.length / 2); + for (let i = 0; i < ch0.length; i += 1) ch0[i] = f[2 * i]; + return { ch0, all: f }; +} + +test("each hit's onset lands within a frame of its pop", { skip: !have && "no ffmpeg" }, () => { + const hits = teaserHits(FERRET); + const full = samples(teaserAudioGraph(hits, { seconds: 7, render: RENDER })).ch0; + assert.equal(full.length, 7 * 48000); + const frame = 1 / RENDER.fps; + hits.forEach((h, i) => { + if (h.kind !== "hit") return; + const without = samples(teaserAudioGraph(hits.filter((_, j) => j !== i), { seconds: 7, render: RENDER })).ch0; + let first = -1; + for (let n = 0; n < full.length; n += 1) { + if (Math.abs(full[n] - without[n]) > 1e-4) { first = n; break; } + } + assert.ok(first >= 0, `${h.role} made no sound`); + const onset = first / 48000; + assert.ok(Math.abs(onset - h.at) <= frame, `${h.role}: onset ${onset.toFixed(4)}s, pop ${h.at}s`); + }); +}); + +test("nothing clips: the sum stays under −6 dBFS (about) and well under full scale", { skip: !have && "no ffmpeg" }, () => { + const { all } = samples(teaserAudioGraph(teaserHits(FERRET), { seconds: 7, render: RENDER })); + let peak = 0; + for (const v of all) peak = Math.max(peak, Math.abs(v)); + assert.ok(peak > 0.2, `peak ${peak}`); // it is not silent + assert.ok(peak <= 0.5 * 1.03, `peak ${peak} (${(20 * Math.log10(peak)).toFixed(2)} dBFS)`); + // A short card packs the hits together; they still sum cleanly. + const short = { ...FERRET, seconds: 3, lines: ["A", { text: "B c", break: "c" }, "D", "E", "F"] }; + const s = samples(teaserAudioGraph(teaserHits(short), { seconds: 3, render: RENDER })).all; + let p2 = 0; + for (const v of s) p2 = Math.max(p2, Math.abs(v)); + assert.ok(p2 <= 0.5 * 1.03, `short card peak ${p2}`); +}); + +test("hits: false is digital silence, exactly as long", { skip: !have && "no ffmpeg" }, () => { + const { all } = samples(teaserAudioGraph(teaserHits({ ...FERRET, hits: false }), { seconds: 7, render: RENDER })); + assert.equal(all.length, 7 * 48000 * 2); + assert.ok(all.every((v) => v === 0)); +}); diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -19,10 +19,11 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { postsRegions, selectVariant, variantPaths } from "./build-video.mjs"; -import { deckOn, frameCount } from "./deck.mjs"; +import { deckGeometry, deckOn, frameCount, postsGeometry, resolveDeck } from "./deck.mjs"; const execFileP = promisify(execFile); const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe"; +const FFMPEG = process.env.FFMPEG_BIN ?? "ffmpeg"; export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } = {}) { // The SAME filter the build ran. Verifying the whole manifest against one @@ -89,15 +90,20 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } if (deckOn(manifest.render)) { deck = await verifyDeck(path.join(root, variant), manifest.render, file, problems); } + const teasers = await verifyTeasers(path.join(root, variant), manifest, problems); - return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, problems }; + return { + ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, deck, + ...(teasers.length ? { teasers } : {}), problems, + }; } /** * The deck's half of the check: schedule.json is there and is a measured deck * schedule, `chrome/deck-frames` holds frameCount(total, fps) frames, and the - * file is as long as the schedule. When the schedule carries posts, each - * window's `chrome/posts-<segment>-frames` holds that window's frame count. + * file is as long as the schedule -- the SCHEDULE's total, holds included. + * When the schedule carries posts, each window's `chrome/posts-<segment>-frames` + * holds that window's frame count, and each held clip's freeze is in the file. */ export async function verifyDeck(variantDir, render, file, problems) { const schedPath = path.join(variantDir, "schedule.json"); @@ -139,12 +145,121 @@ export async function verifyDeck(variantDir, render, file, problems) { problems.push(`${r.frames} holds ${got} frames; the posts window on ${r.segment} is ${r.frameCount}`); } } + const holds = await verifyHolds(file, schedule, render, problems); return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length, ...(posts.length ? { posts } : {}), + ...(holds.length ? { holds } : {}), }; } +/** + * Each `teaser` entry's segment is the render it claims to be: its frames + * (`chrome/teaser-<id>-frames`) are `frameCount(seconds, fps)` long, and the + * record beside its segment (`<id>.teaser.json`) names those frames' key -- a + * segment encoded from an older render (changed words) fails here. + */ +export async function verifyTeasers(variantDir, manifest, problems) { + const fps = Number(manifest.render?.fps ?? 30); + const out = []; + for (const e of manifest.timeline ?? []) { + if (e.type !== "teaser") continue; + const dir = path.join(variantDir, "chrome", `teaser-${e.id}-frames`); + const frames = await readdir(dir).then((fs) => fs.filter((f) => /^frame_\d+\.png$/.test(f)).length, () => 0); + const want = frameCount(Number(e.seconds), fps); + const key = await readFile(path.join(dir, ".key"), "utf8").then((s) => s.trim(), () => null); + const seg = path.join(variantDir, "segments", `${e.id}.mp4`); + const rec = await readFile(seg.replace(/\.mp4$/, ".teaser.json"), "utf8").then(JSON.parse, () => null); + if (frames !== want) problems.push(`${dir} holds ${frames} frames; the teaser ${e.id} is ${want} (${e.seconds}s at ${fps} fps)`); + if (!rec) problems.push(`the teaser ${e.id} has no record beside ${seg} — rebuild it`); + else if (key && rec.frames !== key) problems.push(`the teaser ${e.id}'s segment was encoded from another render of it — rebuild it`); + out.push({ id: e.id, frames, expectedFrames: want, current: !!rec && rec.frames === key }); + } + return out; +} + +/** The mean absolute difference allowed between two frames of one freeze (8-bit luma; re-encoding noise). */ +export const FREEZE_TOLERANCE = 1.5; + +/** + * Where a held segment's freeze can be sampled: the still span is from the + * later of the hold's start and the end of the footage move (the move runs + * after the hold, so a late one glides over the frozen frame) to the start of + * the outgoing dissolve (`end − D`), or the end fade on the last segment + * (`end − endFade`), or the segment's end. The two samples sit a frame and a + * half inside it. A span under three frames has nothing still to compare -- + * at hold 0.5 under a 0.5 s crossfade the dissolve takes all of it -- and is + * skipped, with the reason. + * + * @returns {{ at: [number, number] } | { skip: string }} + */ +export function freezeSamples(segment, { fps, D = 0, last = false, endFade = 0, moveEnd = -Infinity }) { + const lo = Math.max(segment.end - segment.hold, moveEnd); + const hi = last ? segment.end - (endFade > 0 ? endFade : 0) : segment.end - D; + if (!(hi - lo >= 3 / fps)) { + return { + skip: `${Math.max(0, hi - lo).toFixed(3)}s of still picture between ${lo.toFixed(3)}s and ${hi.toFixed(3)}s ` + + `(the rest of the hold is under the ${last ? "end fade" : "dissolve"}${moveEnd > segment.end - segment.hold ? " or the move" : ""})`, + }; + } + return { at: [lo + 1.5 / fps, hi - 1.5 / fps] }; +} + +/** + * Each held segment's freeze is in the file: two frames inside its still span + * (`freezeSamples`) are the same frame. Compared over the picture outside the + * deck's panel and the posts column -- both still move during a hold (the + * deck's progress fuse burns on) -- on luma, within FREEZE_TOLERANCE of + * re-encoding noise. A cut whose holds were dropped plays on there and differs + * by far more. + */ +export async function verifyHolds(file, schedule, render, problems) { + const segs = schedule.segments ?? []; + const held = segs.filter((s) => s.hold > 0); + if (!held.length) return []; + const fps = Number(schedule.fps ?? render.fps); + const D = Number(schedule.transition ?? 0); + const endFade = Number(render.endFade ?? 0); + const moveEnd = new Map((schedule.moves ?? []).map((m) => [m.segment, m.at + m.seconds])); + const g = deckGeometry(render); + const col = postsGeometry(render); + const left = resolveDeck(render).posts.position === "top-left"; + const crop = left + ? { x: col.x + col.width, y: 0, w: g.W - col.x - col.width, h: g.deck.y } + : { x: 0, y: 0, w: col.x, h: g.deck.y }; + const luma = async (t) => { + const { stdout } = await execFileP(FFMPEG, [ + "-nostdin", "-v", "error", "-ss", t.toFixed(3), "-i", file, "-frames:v", "1", + "-vf", `crop=${crop.w}:${crop.h}:${crop.x}:${crop.y},format=gray`, "-f", "rawvideo", "-", + ], { encoding: "buffer", maxBuffer: 1 << 26 }); + return stdout; + }; + const out = []; + for (const s of held) { + const span = freezeSamples(s, { + fps, D, last: s === segs.at(-1), endFade, moveEnd: moveEnd.get(s.id) ?? -Infinity, + }); + if (span.skip) { + out.push({ segment: s.id, hold: s.hold, skipped: span.skip }); + continue; + } + const [a, b] = span.at; + const [x, y] = await Promise.all([luma(a), luma(b)]); + let diff = 0; + if (x.length !== y.length || !x.length) diff = Infinity; + else { + for (let i = 0; i < x.length; i += 1) diff += Math.abs(x[i] - y[i]); + diff /= x.length; + } + out.push({ segment: s.id, hold: s.hold, at: [Number(a.toFixed(3)), Number(b.toFixed(3))], diff: Number(diff.toFixed(3)) }); + if (!(diff <= FREEZE_TOLERANCE)) { + problems.push(`${s.id} is held ${s.hold}s but its picture moves during the hold ` + + `(frames at ${a.toFixed(3)}s and ${b.toFixed(3)}s differ by ${diff.toFixed(2)} on average)`); + } + } + return out; +} + async function main() { const argv = process.argv.slice(2); const manifestPath = argv.find((a) => !a.startsWith("--")); @@ -170,6 +285,14 @@ async function main() { for (const w of res.deck.posts ?? []) { console.log(` posts on ${w.segment}: ${w.frames}/${w.expectedFrames} frame(s) at ${w.at.toFixed(3)}s`); } + for (const h of res.deck.holds ?? []) { + console.log(h.skipped + ? ` hold on ${h.segment}: ${h.hold}s, not checked — ${h.skipped}` + : ` hold on ${h.segment}: ${h.hold}s, frozen (${h.at.join("s ≈ ")}s, mean diff ${h.diff})`); + } + } + for (const t of res.teasers ?? []) { + console.log(` teaser ${t.id}: ${t.frames}/${t.expectedFrames} frame(s)${t.current ? ", segment encoded from them" : ""}`); } for (const p of res.problems) console.log(` ** ${p}`); if (res.ok) console.log(" ok");