ossclip 0.1.30 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/edit.ts CHANGED
@@ -19,7 +19,11 @@ import {
19
19
  ThumbnailConceptSchema,
20
20
  approvedOverlayText,
21
21
  buildThumbnailPrompt,
22
+ captionForProvider,
23
+ createPostizProvider,
24
+ alignRestamp,
22
25
  emptyOverrideDoc,
26
+ extractAudioSpan,
23
27
  // Static import is fine here: the @google/genai SDK load is LAZY inside
24
28
  // this function (core's near-zero-dep rule), so the server pays for it
25
29
  // only when a regenerate actually runs.
@@ -30,7 +34,12 @@ import {
30
34
  PORTRAIT_MIME_TYPES,
31
35
  portraitMimeType,
32
36
  readCoverProvenance,
37
+ runWhisper,
33
38
  SegmentSchema,
39
+ spliceTranscript,
40
+ TranscriptSchema,
41
+ whisperPromptFor,
42
+ wordsInSpan,
34
43
  type Segment,
35
44
  thumbnailImageCacheName,
36
45
  type CoverProvenance,
@@ -60,6 +69,10 @@ import {
60
69
  type RecordedCommand,
61
70
  } from "./cover";
62
71
  import { expandHome } from "./paths";
72
+ // The model-path and implied-language rules, from THE source doctor, setup and
73
+ // produce all resolve through — a second copy here would send a user to a
74
+ // model file the rest of the tool never looks for.
75
+ import { modelImpliedLanguage, whisperModelPath } from "./setup/manifest";
63
76
  import {
64
77
  PORTRAIT_OVERRIDE_BASENAME,
65
78
  portraitExtensionForMime,
@@ -68,6 +81,7 @@ import {
68
81
  type ResolvedPortrait,
69
82
  } from "./portrait-override";
70
83
  import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
84
+ import { buildPublishPosts, publishConfigured, publishReceiptPath, readPublishReceipt } from "./publish";
71
85
 
72
86
  /**
73
87
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -94,6 +108,78 @@ export function resolveEditorPageDir(): string | null {
94
108
  return candidates[0] ?? null;
95
109
  }
96
110
 
111
+ /**
112
+ * The config keys `/api/retranscribe-range` needs. Spelled structurally
113
+ * rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
114
+ * stub that supplies only what a case is about — the same shape the youtube/
115
+ * portrait keys above it are declared with.
116
+ */
117
+ export interface RetranscribeConfig {
118
+ ffmpegPath?: string;
119
+ ffprobePath?: string;
120
+ whisperPath?: string;
121
+ model?: string;
122
+ modelDir?: string;
123
+ language?: unknown;
124
+ dictionary?: unknown;
125
+ }
126
+
127
+ /**
128
+ * How to spawn whisper for a range re-decode, or why we cannot.
129
+ *
130
+ * Pure — the `openCommand`/`openInBrowser` split — so the whole
131
+ * config × missing-binary × malformed-dictionary matrix is testable without
132
+ * whisper.cpp or a model on the runner.
133
+ *
134
+ * FROM THE CONFIG, not from the workdir's `transcript-key.json`, and that is
135
+ * a knowing limitation: reading the key would mean importing produce.ts,
136
+ * which statically pulls in @ossclip/renderer AND imports this module back
137
+ * (a cycle), for a server whose whole point is to start instantly. The
138
+ * failure when the two disagree is safe and REPORTED rather than silent: a
139
+ * range re-decoded with a different model produces words that match nothing,
140
+ * and `alignRestamp` answers "matched none — stamps left as they were".
141
+ *
142
+ * The dictionary is validated all-or-nothing, `validDictionary`'s rule
143
+ * (produce.ts): a hand-edited list with a number in it biases whisper with a
144
+ * vocabulary the user never reviewed, so the whole key is dropped instead.
145
+ * Same for `language`: typeof+trim, never truthiness, never coerced.
146
+ */
147
+ export function retranscribeSettings(
148
+ cfg: RetranscribeConfig,
149
+ ):
150
+ | {
151
+ tools: { ffmpegPath: string; ffprobePath: string };
152
+ whisperPath: string;
153
+ modelPath: string;
154
+ language?: string;
155
+ prompt?: string;
156
+ }
157
+ | { error: string } {
158
+ if (!cfg.ffmpegPath || !cfg.ffprobePath || !cfg.whisperPath || !cfg.model || !cfg.modelDir) {
159
+ return {
160
+ error:
161
+ "ffmpeg or whisper is not configured — run `ossclip doctor` to see what is missing, " +
162
+ "then `ossclip setup` to install it.",
163
+ };
164
+ }
165
+ const dict = Array.isArray(cfg.dictionary)
166
+ && cfg.dictionary.length > 0
167
+ && cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
168
+ ? (cfg.dictionary as string[]).map((t) => t.trim())
169
+ : [];
170
+ const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
171
+ ? cfg.language.trim()
172
+ : modelImpliedLanguage(cfg.model);
173
+ const prompt = whisperPromptFor(dict);
174
+ return {
175
+ tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
176
+ whisperPath: cfg.whisperPath,
177
+ modelPath: whisperModelPath(cfg.model, cfg.modelDir),
178
+ ...(language !== undefined ? { language } : {}),
179
+ ...(prompt !== undefined ? { prompt } : {}),
180
+ };
181
+ }
182
+
97
183
  /**
98
184
  * The editor's backend: a handful of endpoints and a static file server,
99
185
  * deliberately dependency-free. It reads the workdir a `produce` run left
@@ -158,6 +244,14 @@ const MIME: Record<string, string> = {
158
244
  // the type is stated while the change is one line rather than a debugging
159
245
  // session.
160
246
  ".wav": "audio/wav",
247
+ // The `--cover-in-video` overlay: produce stages the cover into the workdir
248
+ // as well as the render's public dir, and the Player fetches it through
249
+ // `/media/`. Browsers do sniff an image served as octet-stream, but a
250
+ // preview that depends on sniffing is a preview that breaks the first time
251
+ // something in front of it (a proxy, a stricter engine) declines to.
252
+ ".jpg": "image/jpeg",
253
+ ".jpeg": "image/jpeg",
254
+ ".png": "image/png",
161
255
  };
162
256
 
163
257
  /**
@@ -254,7 +348,19 @@ export async function startEditServer(
254
348
  /** Config seam for the thumbnail panel — tests inject `() => ({})` so a
255
349
  * run never reads the runner's real ~/.ossclip/config.json (the
256
350
  * `recentDir` rule applied to reads). */
257
- loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
351
+ loadCfg?: () => {
352
+ youtube?: unknown;
353
+ portrait?: unknown;
354
+ thumbnailModel?: unknown;
355
+ postizUrl?: string;
356
+ } & RetranscribeConfig;
357
+ /** Env seam for the publish endpoints — tests inject their own so the
358
+ * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
359
+ * test (the loadCfg rule applied to the environment). */
360
+ publishEnv?: NodeJS.ProcessEnv;
361
+ /** Fetch seam for the publish endpoints — tests stub Postiz instead of
362
+ * needing an instance on the runner (createPostizProvider's fetchImpl). */
363
+ publishFetch?: typeof fetch;
258
364
  /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
259
365
  * observe the revealed path instead of popping a real Finder/Explorer
260
366
  * window on the runner. */
@@ -266,6 +372,14 @@ export async function startEditServer(
266
372
  * is also why cover.ts keeps that import lazy in the first place.
267
373
  */
268
374
  renderCover?: CoverSeams["renderCover"];
375
+ /**
376
+ * The two shell-outs `/api/retranscribe-range` makes — the
377
+ * `generateThumbnail` seam pattern, twice: tests stub a decode instead of
378
+ * needing ffmpeg, whisper.cpp and a multi-gigabyte model on the runner
379
+ * (`edit-server.test.ts` must stay a unit test).
380
+ */
381
+ sliceAudio?: typeof extractAudioSpan;
382
+ runWhisper?: typeof runWhisper;
269
383
  } = {},
270
384
  ): Promise<EditServer> {
271
385
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -355,6 +469,14 @@ export async function startEditServer(
355
469
  // headless browser, and a double-click must not run two renders at the same
356
470
  // destination. `thumbnailBusy`'s rule, for the same reason.
357
471
  let coverBusy = false;
472
+ /**
473
+ * One range re-decode at a time (`/api/retranscribe-range`). whisper is a
474
+ * CPU-bound spawn AND the endpoint read-modify-writes transcript.json, so
475
+ * two in flight is both a stalled box and a lost splice — the second write
476
+ * would be built on a transcript read before the first one landed.
477
+ * `coverBusy`'s rule with a second reason on top.
478
+ */
479
+ let retranscribeBusy = false;
358
480
  /** Where the JPEG lives right now: the destination the last cover used,
359
481
  * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
360
482
  * recorded destination that was never rendered is a real state (the panel
@@ -369,6 +491,11 @@ export async function startEditServer(
369
491
  * file behind this URL). */
370
492
  const coverImageUrl = (image: string | null): string | null =>
371
493
  image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
494
+ /** Where the Preview button's one-off render lands (handoff-cover-panel
495
+ * §3): a fixed name IN the workdir — never a client-named path, and never
496
+ * the canonical `.cover.jpg` a real Apply owns. Each preview overwrites the
497
+ * last; the file is scratch, like the frame-sampler's cache. */
498
+ const coverPreviewPath = (): string => join(workdir!, "cover-preview.jpg");
372
499
 
373
500
  // ---- Portrait override (editor face swap, 2026-08-17) -------------------
374
501
  // A per-project `portrait-override.<ext>` in the workdir that outranks the
@@ -550,6 +677,157 @@ export async function startEditServer(
550
677
  });
551
678
  }
552
679
 
680
+ if (url.pathname === "/api/transcript" && req.method === "GET") {
681
+ // The FULL transcript, source-timed words included (cut-review
682
+ // rework follow-up): the editor rebuilds the caption track over
683
+ // REVIVED material with produce's own `buildCaptionLines`, and
684
+ // render-props' captionLines only cover what the last render kept
685
+ // — the revived words exist nowhere else client-side. Lenient like
686
+ // /api/cleanup: a missing or corrupt transcript.json degrades to
687
+ // null (captions over revived material stay absent, never a 500).
688
+ if (!workdir) return send(409, { error: "no workdir open" });
689
+ try {
690
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
691
+ language?: unknown;
692
+ words?: unknown;
693
+ };
694
+ if (!Array.isArray(raw.words)) return send(200, { transcript: null });
695
+ return send(200, { transcript: raw });
696
+ } catch {
697
+ return send(200, { transcript: null });
698
+ }
699
+ }
700
+
701
+ if (url.pathname === "/api/retranscribe-range" && req.method === "POST") {
702
+ // Re-decode ONE source span and re-stamp the words already there
703
+ // (Phase A, 2026-08-26): inside a kept retake whisper mis-POSITIONS
704
+ // words — the caption says "has its" while the audio says "could
705
+ // read 50 files" — because the first decode ran over material the
706
+ // cut had removed. See restamp.ts for why the splice is
707
+ // stamps-only.
708
+ if (!workdir) return send(409, { error: "no workdir open" });
709
+ if (retranscribeBusy) {
710
+ return send(409, { error: "a re-transcription is already running" });
711
+ }
712
+ const chunks: Buffer[] = [];
713
+ for await (const c of req) chunks.push(c as Buffer);
714
+ // TWO steerable values, both times, and nothing else — the cover
715
+ // regenerate rule: unknown keys are stripped by the parse, and an
716
+ // ordered non-negative pair is checked HERE rather than trusted,
717
+ // since an inverted range would slice a negative duration out of
718
+ // ffmpeg (CLAUDE.md's parse-never-coerce).
719
+ const parsed = z
720
+ .object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() })
721
+ .refine((v) => v.srcOut > v.srcIn, { message: "srcOut must be after srcIn" })
722
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
723
+ if (!parsed.success) return send(400, { error: parsed.error.message });
724
+ const { srcIn, srcOut } = parsed.data;
725
+ // Every path server-derived, never from the body — the regenerate
726
+ // endpoint's stance. `whisper-range` is a distinct outBase from
727
+ // produce's `whisper`, so a range decode can never overwrite the
728
+ // artefact of the full one.
729
+ const dir = workdir;
730
+ const tmpWav = join(dir, "whisper-range.wav");
731
+ const outBase = join(dir, "whisper-range");
732
+ retranscribeBusy = true;
733
+ try {
734
+ const audio = join(dir, "audio.wav");
735
+ if (!existsSync(audio)) {
736
+ return send(200, {
737
+ ok: false,
738
+ error: "this workdir has no audio.wav to re-decode — re-run `ossclip produce`.",
739
+ });
740
+ }
741
+ const transcriptPath = join(dir, "transcript.json");
742
+ if (!existsSync(transcriptPath)) {
743
+ return send(200, {
744
+ ok: false,
745
+ error: "this workdir has no transcript.json to re-stamp — re-run `ossclip produce`.",
746
+ });
747
+ }
748
+ const settings = retranscribeSettings((opts.loadCfg ?? loadConfig)());
749
+ if ("error" in settings) return send(200, { ok: false, error: settings.error });
750
+ if (!existsSync(settings.modelPath)) {
751
+ // The `--transcript`-only install: whisper was never needed to
752
+ // make this project, so say what to run rather than 500ing.
753
+ return send(200, {
754
+ ok: false,
755
+ error:
756
+ `whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
757
+ `to download it.`,
758
+ });
759
+ }
760
+ // Parsed, not cast: this file is about to be rewritten, and a
761
+ // truncated one must fail loudly here rather than become the new
762
+ // transcript.
763
+ const transcript = TranscriptSchema.parse(
764
+ JSON.parse(await readFile(transcriptPath, "utf8")),
765
+ );
766
+ const range = wordsInSpan(transcript.words, srcIn, srcOut);
767
+ if (range.to === range.from) {
768
+ // Silence, or a range whose words all straddle its edges.
769
+ // Nothing to re-stamp is a SUCCESS with an empty mapping — the
770
+ // editor's no-op — not a failure the user has to read.
771
+ return send(200, {
772
+ ok: true,
773
+ mapping: [],
774
+ reports: ["no transcript words lie wholly inside that range"],
775
+ });
776
+ }
777
+ await (opts.sliceAudio ?? extractAudioSpan)(
778
+ settings.tools,
779
+ audio,
780
+ tmpWav,
781
+ srcIn,
782
+ srcOut - srcIn,
783
+ );
784
+ const fresh = await (opts.runWhisper ?? runWhisper)(
785
+ {
786
+ whisperPath: settings.whisperPath,
787
+ modelPath: settings.modelPath,
788
+ outBase,
789
+ ...(settings.language !== undefined ? { language: settings.language } : {}),
790
+ ...(settings.prompt !== undefined ? { prompt: settings.prompt } : {}),
791
+ },
792
+ tmpWav,
793
+ );
794
+ const restamped = alignRestamp(
795
+ transcript.words.slice(range.from, range.to),
796
+ fresh.words,
797
+ srcIn,
798
+ );
799
+ const next = spliceTranscript(transcript, range, restamped.words);
800
+ // Atomic, like the overrides write: produce may read this file at
801
+ // any moment (its cache-reuse branch reads it verbatim and writes
802
+ // back what it read), and a half-written transcript is worse than
803
+ // a stale one.
804
+ const tmp = `${transcriptPath}.tmp`;
805
+ await writeFile(tmp, JSON.stringify(next, null, 2));
806
+ await rename(tmp, transcriptPath);
807
+ // `overrides.json` IS NOT TOUCHED HERE, on purpose: the doc is
808
+ // client-owned — the editor holds unsaved edits and an undo stack
809
+ // over it — so the caption RE-KEY rides back as `mapping` and is
810
+ // applied by the `useEdits` reducer, one commit, one undo step.
811
+ return send(200, { ok: true, mapping: restamped.mapping, reports: restamped.reports });
812
+ } catch (err) {
813
+ // 200 {ok:false}, the cover regenerate posture: a missing whisper
814
+ // binary or a failed decode is a sentence the panel shows, not a
815
+ // dead 500 — and the editor keeps working on the old stamps.
816
+ const message = err instanceof Error ? err.message : String(err);
817
+ return send(200, {
818
+ ok: false,
819
+ error: `${message} — run \`ossclip doctor\` if ffmpeg or whisper is the problem.`,
820
+ });
821
+ } finally {
822
+ retranscribeBusy = false;
823
+ // Both artefacts are per-request scratch. `.catch()` because a
824
+ // decode that never got as far as writing one must not turn a
825
+ // reported failure into an unhandled rejection.
826
+ await unlink(tmpWav).catch(() => {});
827
+ await unlink(`${outBase}.json`).catch(() => {});
828
+ }
829
+ }
830
+
553
831
  if (url.pathname === "/api/cleanup" && req.method === "GET") {
554
832
  // The labeled removals: since cut review step 3 this serves the
555
833
  // PROPOSAL (`cutlistProposed` — the automatic cutlist before the
@@ -1180,6 +1458,123 @@ export async function startEditServer(
1180
1458
  return send(200, { ok: true, mdPath });
1181
1459
  }
1182
1460
 
1461
+ // ---- Publish (2026-08-26) -----------------------------------------
1462
+ // The server's FIRST outbound-network endpoints, against the
1463
+ // roadmap's "replay-only by deliberate design" posture — allowed
1464
+ // because all three gates hold: they exist behaviorally only when the
1465
+ // user configured their own Postiz instance (unconfigured → a hint,
1466
+ // never a control), they fire only on an explicit button press, and
1467
+ // the API key never reaches the browser — the server (already running
1468
+ // in the user's shell env) does the upload.
1469
+ if (url.pathname === "/api/publish" && req.method === "GET") {
1470
+ if (!workdir) return send(409, { error: "no workdir open" });
1471
+ const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1472
+ if (!configured.ok) {
1473
+ return send(200, { configured: false, reason: configured.message });
1474
+ }
1475
+ const cmd = await readCommandRecord();
1476
+ const out = cmd ? recordedOutPath(cmd) : null;
1477
+ const pack = await currentYoutubePack();
1478
+ let integrations: Array<{ id: string; provider: string; name: string; caption: string }> = [];
1479
+ try {
1480
+ const provider = createPostizProvider({
1481
+ baseUrl: configured.baseUrl,
1482
+ apiKey: configured.apiKey,
1483
+ fetchImpl: opts.publishFetch,
1484
+ });
1485
+ const targets = await provider.listTargets();
1486
+ integrations = targets.map((t) => ({
1487
+ ...t,
1488
+ // The caption the publish WOULD use — authored-else-derived —
1489
+ // so the panel previews truth, not a guess of it.
1490
+ caption: pack !== null ? captionForProvider(pack, t.provider) : "",
1491
+ }));
1492
+ } catch (err) {
1493
+ return send(200, {
1494
+ configured: true,
1495
+ reachable: false,
1496
+ reason: err instanceof Error ? err.message : String(err),
1497
+ });
1498
+ }
1499
+ return send(200, {
1500
+ configured: true,
1501
+ reachable: true,
1502
+ integrations,
1503
+ packAvailable: pack !== null,
1504
+ outPathExists: out !== null && existsSync(out),
1505
+ receipt: await readPublishReceipt(workdir),
1506
+ });
1507
+ }
1508
+
1509
+ if (url.pathname === "/api/publish" && req.method === "POST") {
1510
+ if (!workdir) return send(409, { error: "no workdir open" });
1511
+ const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1512
+ if (!configured.ok) return send(412, { error: configured.message });
1513
+ const chunks: Buffer[] = [];
1514
+ for await (const c of req) chunks.push(c as Buffer);
1515
+ const parsed = z
1516
+ .object({
1517
+ integrationIds: z.array(z.string()).min(1),
1518
+ // ISO-8601, validated as a real FUTURE instant below — zod can
1519
+ // say "string", only a clock can say "future".
1520
+ at: z.string().optional(),
1521
+ // Per-integration caption overrides typed in the panel; absent
1522
+ // ids fall back to the pack's authored-else-derived caption.
1523
+ captions: z.record(z.string(), z.string()).optional(),
1524
+ force: z.boolean().optional(),
1525
+ })
1526
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1527
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1528
+ let when: { kind: "now" } | { kind: "at"; iso: string } = { kind: "now" };
1529
+ if (parsed.data.at !== undefined) {
1530
+ const ms = Date.parse(parsed.data.at);
1531
+ if (Number.isNaN(ms)) return send(400, { error: `not an ISO-8601 time: "${parsed.data.at}"` });
1532
+ if (ms <= Date.now()) return send(400, { error: `schedule time already passed: "${parsed.data.at}"` });
1533
+ when = { kind: "at", iso: new Date(ms).toISOString() };
1534
+ }
1535
+ const cmd = await readCommandRecord();
1536
+ const out = cmd ? recordedOutPath(cmd) : null;
1537
+ if (out === null || !existsSync(out)) {
1538
+ return send(412, { error: "no finished render to publish — render first" });
1539
+ }
1540
+ const pack = await currentYoutubePack();
1541
+ if (pack === null) {
1542
+ return send(412, { error: "no YouTube pack — approve one in the SEO panel first" });
1543
+ }
1544
+ const receipt = await readPublishReceipt(workdir);
1545
+ if (receipt !== null && parsed.data.force !== true) {
1546
+ return send(412, {
1547
+ error: `already published on ${receipt.publishedAt} — pass force to publish again`,
1548
+ receipt,
1549
+ });
1550
+ }
1551
+ try {
1552
+ const provider = createPostizProvider({
1553
+ baseUrl: configured.baseUrl,
1554
+ apiKey: configured.apiKey,
1555
+ fetchImpl: opts.publishFetch,
1556
+ });
1557
+ const targets = await provider.listTargets();
1558
+ const picked = parsed.data.integrationIds.map((id) => {
1559
+ const hit = targets.find((t) => t.id === id);
1560
+ if (!hit) throw new Error(`no integration with id "${id}" in Postiz`);
1561
+ return hit;
1562
+ });
1563
+ const posts = buildPublishPosts(pack, picked).map((p) => ({
1564
+ ...p,
1565
+ caption: parsed.data.captions?.[p.target.id] ?? p.caption,
1566
+ }));
1567
+ const result = await provider.publish({ videoPath: out, posts, when });
1568
+ await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
1569
+ return send(200, { ok: true, receipt: result });
1570
+ } catch (err) {
1571
+ // Fail loud, verbatim — Postiz's own validation message is the
1572
+ // most specific thing anyone has (per-provider settings are its
1573
+ // domain, not ours).
1574
+ return send(502, { error: err instanceof Error ? err.message : String(err) });
1575
+ }
1576
+ }
1577
+
1183
1578
  if (url.pathname === "/api/cover" && req.method === "GET") {
1184
1579
  // The cover panel's one status call (2026-08-19): the provenance to
1185
1580
  // prefill and where the current image is. All reads — the panel
@@ -1193,6 +1588,27 @@ export async function startEditServer(
1193
1588
  // throw for want of a destination, and the panel says so up front
1194
1589
  // rather than after a click.
1195
1590
  const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
1591
+ // The finished mp4's OWN span set — the RESOLVED cutlist, never the
1592
+ // proposal /api/cleanup serves: the panel converts the playhead
1593
+ // between the output clock and the source clock
1594
+ // (handoff-cover-panel §1), and a proposal the user's vetoes already
1595
+ // changed is the wrong ruler. Lenient per-span parse, [] on a
1596
+ // missing or corrupt production.json — a cover panel that cannot
1597
+ // convert must still open.
1598
+ let coverCutlist: Segment[] = [];
1599
+ try {
1600
+ const production = JSON.parse(
1601
+ await readFile(join(workdir, "production.json"), "utf8"),
1602
+ ) as { cutlist?: unknown };
1603
+ if (Array.isArray(production.cutlist)) {
1604
+ coverCutlist = production.cutlist.flatMap((s) => {
1605
+ const p = SegmentSchema.safeParse(s);
1606
+ return p.success ? [p.data] : [];
1607
+ });
1608
+ }
1609
+ } catch {
1610
+ // degrade — same as /api/cleanup
1611
+ }
1196
1612
  return send(200, {
1197
1613
  status: outPath === null ? "unavailable" : "ready",
1198
1614
  ...(outPath === null
@@ -1203,6 +1619,7 @@ export async function startEditServer(
1203
1619
  provenance,
1204
1620
  outPath,
1205
1621
  imageUrl: coverImageUrl(image),
1622
+ cutlist: coverCutlist,
1206
1623
  });
1207
1624
  }
1208
1625
 
@@ -1283,6 +1700,78 @@ export async function startEditServer(
1283
1700
  }
1284
1701
  }
1285
1702
 
1703
+ if (url.pathname === "/api/cover/preview-image" && req.method === "GET") {
1704
+ if (!workdir) return send(409, { error: "no workdir open" });
1705
+ const image = coverPreviewPath();
1706
+ if (!existsSync(image)) return send(404, { error: "no preview image" });
1707
+ // /api/cover/image's exact posture, for its exact reason: every
1708
+ // preview REPLACES the file behind a URL the panel busts with ?ts,
1709
+ // and a cached 200 would show the old preview against the new ts.
1710
+ const bytes = await readFile(image);
1711
+ res.writeHead(200, {
1712
+ "content-type": "image/jpeg",
1713
+ "cache-control": "no-store",
1714
+ "content-length": String(bytes.length),
1715
+ });
1716
+ res.end(bytes);
1717
+ return;
1718
+ }
1719
+
1720
+ if (url.pathname === "/api/cover/preview" && req.method === "POST") {
1721
+ // The regenerate handler above, verbatim, up to the outPath: a
1722
+ // preview is a full cover render — it boots the same headless
1723
+ // browser (hence the same coverBusy gate), takes the same three
1724
+ // steerable values and nothing else, and fails with the same
1725
+ // 200-with-ok:false verbatim-message posture.
1726
+ if (!workdir) return send(409, { error: "no workdir open" });
1727
+ if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
1728
+ const chunks: Buffer[] = [];
1729
+ for await (const c of req) chunks.push(c as Buffer);
1730
+ const parsed = z
1731
+ .object({
1732
+ text: z.string().optional(),
1733
+ atSec: CoverAtSecondsSchema.optional(),
1734
+ from: CoverFromSchema.optional(),
1735
+ })
1736
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1737
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1738
+ coverBusy = true;
1739
+ try {
1740
+ const notes: string[] = [];
1741
+ await regenerateCover(
1742
+ workdir,
1743
+ {
1744
+ text: parsed.data.text,
1745
+ atSec: parsed.data.atSec,
1746
+ from: parsed.data.from,
1747
+ // The ONE-OFF path, derived HERE and never from the body (the
1748
+ // regenerate handler's comment owns why). Same machinery as
1749
+ // `ossclip cover --out` (handoff-cover-panel §3): the
1750
+ // canonical cover is untouched, provenance updates to
1751
+ // describe the previewed frame — which is what makes a
1752
+ // follow-up blank-field Apply adopt it via the cheap path,
1753
+ // and the one-off note riding back is the panel's disclosure
1754
+ // of exactly that.
1755
+ outPath: coverPreviewPath(),
1756
+ },
1757
+ { renderCover: opts.renderCover, log: (line) => notes.push(line) },
1758
+ );
1759
+ return send(200, {
1760
+ ok: true,
1761
+ notes,
1762
+ // coverImageUrl's mtime rule, aimed at the preview file: the
1763
+ // URL changes exactly when the file does.
1764
+ previewImageUrl: `/api/cover/preview-image?ts=${Math.round(
1765
+ statSync(coverPreviewPath()).mtimeMs,
1766
+ )}`,
1767
+ });
1768
+ } catch (err) {
1769
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1770
+ } finally {
1771
+ coverBusy = false;
1772
+ }
1773
+ }
1774
+
1286
1775
  if (url.pathname.startsWith("/media/")) {
1287
1776
  if (!workdir) return send(409, { error: "no workdir open" });
1288
1777
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));