ossclip 0.1.33 → 0.1.35

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
@@ -1,7 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
4
- import { copyFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -17,10 +17,29 @@ import {
17
17
  type YoutubePack,
18
18
  ThumbnailConceptApprovedSchema,
19
19
  ThumbnailConceptSchema,
20
+ appendUsageRun,
21
+ // The SFX plan route's word-space derivation (Phase 4) — the repaired
22
+ // transcript rebuilt from production.json's own stored pair, which is the
23
+ // index space `production.sfx.placements[].word` counts in.
24
+ applyRepairs,
25
+ ProductionSfxSchema,
20
26
  approvedOverlayText,
21
27
  buildThumbnailPrompt,
28
+ captionCap,
22
29
  captionForProvider,
30
+ checkDurationCaps,
23
31
  createPostizProvider,
32
+ createProvider,
33
+ encodeEta,
34
+ formatUsageLine,
35
+ generateCaptionRegen,
36
+ generateYoutubePack,
37
+ YOUTUBE_PROMPT_VERSION,
38
+ deliveryEncodePlan,
39
+ ensureDeliveryFile,
40
+ PLATFORM_DURATION_CAPS_SEC,
41
+ PLATFORM_SIZE_CAP_BYTES,
42
+ probe,
24
43
  alignRestamp,
25
44
  emptyOverrideDoc,
26
45
  extractAudioSpan,
@@ -29,6 +48,14 @@ import {
29
48
  // only when a regenerate actually runs.
30
49
  generateThumbnailImage,
31
50
  loadConfig,
51
+ // The SFX palette's one source of sounds (2026-08-29): the same loader
52
+ // produce plans against, so the dropdown can never offer a sound a render
53
+ // would not find.
54
+ loadSfxLibrary,
55
+ // …and the config gate that decides whether the bundled pack is part of it.
56
+ // Resolved here too, not just in produce: an editor that offered stock sounds
57
+ // the config excludes would let a user pick one the next render drops.
58
+ resolveSfxBundledPack,
32
59
  outInsideInputFolderMessage,
33
60
  outPathInsideInput,
34
61
  PORTRAIT_MIME_TYPES,
@@ -38,8 +65,10 @@ import {
38
65
  SegmentSchema,
39
66
  spliceTranscript,
40
67
  TranscriptSchema,
68
+ ungroundedTokens,
41
69
  whisperPromptFor,
42
70
  wordsInSpan,
71
+ type ModelPrice,
43
72
  type Segment,
44
73
  thumbnailImageCacheName,
45
74
  type CoverProvenance,
@@ -51,7 +80,13 @@ import {
51
80
  // drags in @ossclip/core's process runner and llm-detect, worth deferring off
52
81
  // server startup — open.ts is node:child_process + node:path and pure command
53
82
  // building, with nothing to defer.
83
+ import { loadEnvFiles } from "./env";
84
+ // The identity endpoint's one spelling, shared with the CLI-side probe in
85
+ // edit-port.ts (which imports only the TYPE of this module's server, so there
86
+ // is no cycle and nothing interactive rides in here).
87
+ import { EDIT_HEALTH_PATH, editHealthBody } from "./edit-health";
54
88
  import { revealInFileManager } from "./open";
89
+ import { REVIEWED_SCENES_BASENAME, renderReplayArgs } from "./render-replay-args";
55
90
  // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
56
91
  // cover` needs the same out-resolution rule this server's thumbnail dest,
57
92
  // youtube markdown and reveal endpoint derive from, and two spellings of it
@@ -81,7 +116,16 @@ import {
81
116
  type ResolvedPortrait,
82
117
  } from "./portrait-override";
83
118
  import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
84
- import { buildPublishPosts, publishConfigured, publishReceiptPath, readPublishReceipt } from "./publish";
119
+ import { captionRegenProvider } from "./caption-regen-panel";
120
+ import { binOnPath } from "./llm-detect";
121
+ import {
122
+ attachDeliveryMedia,
123
+ buildPublishPosts,
124
+ publishConfigured,
125
+ publishReceiptPath,
126
+ readPublishReceipt,
127
+ sizeCapGroups,
128
+ } from "./publish";
85
129
 
86
130
  /**
87
131
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -108,6 +152,29 @@ export function resolveEditorPageDir(): string | null {
108
152
  return candidates[0] ?? null;
109
153
  }
110
154
 
155
+ /**
156
+ * This package's version for `/api/health`, read from the manifest exactly as
157
+ * `--version` does (R22 §113: never a literal, or it reports the number a
158
+ * developer typed). Memoized because it sits on a request path, and
159
+ * `undefined` when unreadable — the health body's `version` is optional
160
+ * precisely so an odd install still identifies itself as ossclip.
161
+ */
162
+ let cachedVersion: string | undefined | null = null;
163
+ function packageVersion(): string | undefined {
164
+ if (cachedVersion === null) {
165
+ try {
166
+ cachedVersion = (
167
+ JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
168
+ version: string;
169
+ }
170
+ ).version;
171
+ } catch {
172
+ cachedVersion = undefined;
173
+ }
174
+ }
175
+ return cachedVersion;
176
+ }
177
+
111
178
  /**
112
179
  * The config keys `/api/retranscribe-range` needs. Spelled structurally
113
180
  * rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
@@ -244,6 +311,12 @@ const MIME: Record<string, string> = {
244
311
  // the type is stated while the change is one line rather than a debugging
245
312
  // session.
246
313
  ".wav": "audio/wav",
314
+ // The sound-effect preview (`/api/sfx/audio`) and the staged `sfx/` copies
315
+ // the workdir serves over `/media/`: the starter pack is mono mp3, and an
316
+ // `<audio>` element handed an octet-stream is exactly the sniffing-dependent
317
+ // preview the `.jpg` note below refuses to rely on. Anything else a user
318
+ // pack ships still falls back to octet-stream rather than being refused.
319
+ ".mp3": "audio/mpeg",
247
320
  // The `--cover-in-video` overlay: produce stages the cover into the workdir
248
321
  // as well as the render's public dir, and the Player fetches it through
249
322
  // `/media/`. Browsers do sniff an image served as octet-stream, but a
@@ -353,6 +426,16 @@ export async function startEditServer(
353
426
  portrait?: unknown;
354
427
  thumbnailModel?: unknown;
355
428
  postizUrl?: string;
429
+ /** produce's config fallback for `--audience` — the on-demand pack
430
+ * generation reads it the same way (typeof, never truthiness). */
431
+ audience?: unknown;
432
+ /** The caption-regenerate spend report prices through the same table
433
+ * produce does — absent in a stub, the defaults apply. */
434
+ pricing?: Record<string, ModelPrice>;
435
+ /** Whether the bundled starter pack feeds the SFX routes' library —
436
+ * `unknown` because it is file-only and typed at the consumer
437
+ * (`resolveSfxBundledPack`), the `audience` rule. */
438
+ sfxBundledPack?: unknown;
356
439
  } & RetranscribeConfig;
357
440
  /** Env seam for the publish endpoints — tests inject their own so the
358
441
  * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
@@ -361,10 +444,18 @@ export async function startEditServer(
361
444
  /** Fetch seam for the publish endpoints — tests stub Postiz instead of
362
445
  * needing an instance on the runner (createPostizProvider's fetchImpl). */
363
446
  publishFetch?: typeof fetch;
447
+ /** The publish endpoints' two ffmpeg-family shell-outs (the sliceAudio/
448
+ * runWhisper seam pattern) — tests stub a probe and a delivery encode
449
+ * instead of needing ffmpeg/ffprobe and a real render on the runner. */
450
+ probeVideo?: typeof probe;
451
+ ensureDelivery?: typeof ensureDeliveryFile;
364
452
  /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
365
453
  * observe the revealed path instead of popping a real Finder/Explorer
366
454
  * window on the runner. */
367
455
  reveal?: (path: string) => void;
456
+ /** The caption-regenerate LLM seam (the `generateThumbnail` pattern for
457
+ * text): tests inject a fake provider factory and never touch a model. */
458
+ makeLlmProvider?: typeof createProvider;
368
459
  /**
369
460
  * The cover render seam, exactly like `generateThumbnail` above. Without
370
461
  * it `regenerateCover` lazily imports @ossclip/renderer and boots a
@@ -380,6 +471,16 @@ export async function startEditServer(
380
471
  */
381
472
  sliceAudio?: typeof extractAudioSpan;
382
473
  runWhisper?: typeof runWhisper;
474
+ /**
475
+ * The sound library the SFX routes serve (`loadCfg`'s rule applied to the
476
+ * pack loader): tests inject a hand-written library over a tmp dir, so the
477
+ * suite never depends on the bundled pack riding a given runner's checkout
478
+ * — nor on whatever the developer happens to have in ~/.ossclip/sfx, which
479
+ * the real loader merges in. It is called with the resolved `includeBundled`
480
+ * (see `sfxLibrary` below), so a test can assert the config gate reached the
481
+ * loader rather than only that the routes serve what they were handed.
482
+ */
483
+ loadSfx?: typeof loadSfxLibrary;
383
484
  } = {},
384
485
  ): Promise<EditServer> {
385
486
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -477,6 +578,38 @@ export async function startEditServer(
477
578
  * `coverBusy`'s rule with a second reason on top.
478
579
  */
479
580
  let retranscribeBusy = false;
581
+ /**
582
+ * One caption regeneration at a time (`/api/publish/regenerate`) — an LLM
583
+ * call costs money, and a double-click must not buy two. `thumbnailBusy`'s
584
+ * rule, its own flag: a caption rewrite must not block a thumbnail.
585
+ */
586
+ let captionRegenBusy = false;
587
+ /**
588
+ * One pack generation at a time (`/api/youtube/generate`) — an LLM call
589
+ * costs money, and a double-click must not buy two. `captionRegenBusy`'s
590
+ * rule, its own flag: generating the pack must not block a caption rewrite
591
+ * already in flight (they are different buttons in different panels).
592
+ */
593
+ let packGenBusy = false;
594
+ /**
595
+ * Where the in-flight publish is right now, for the panel's poll
596
+ * (2026-08-29): the POST runs the delivery encode synchronously — minutes
597
+ * of x264 behind one fetch — so `GET /api/publish/progress` reads this
598
+ * instead of the panel staring at a static button. Null whenever no publish
599
+ * is in flight; a POST's `finally` owns the reset so an error can't leave a
600
+ * stale "encoding" behind. Per server instance like the busy flags above.
601
+ */
602
+ let publishProgress: {
603
+ phase: "encoding" | "uploading";
604
+ pct: number | null;
605
+ etaSec: number | null;
606
+ speed: number | null;
607
+ /** The delivery file being encoded (from onStart) — a size-capped
608
+ * publish runs two sequential encodes, and a bare pct that resets to 0
609
+ * mid-publish looks like a hang unless the label names which file it
610
+ * restarted for. Null before the first onStart and while uploading. */
611
+ file: string | null;
612
+ } | null = null;
480
613
  /** Where the JPEG lives right now: the destination the last cover used,
481
614
  * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
482
615
  * recorded destination that was never rendered is a real state (the panel
@@ -531,6 +664,54 @@ export async function startEditServer(
531
664
  // panel round-trips through youtube-pack-approved.json, which produce's Y2
532
665
  // block honors VERBATIM on every replay — an edit persisted there survives
533
666
  // into future renders with zero new plumbing.
667
+ /**
668
+ * The publish config, resolved FRESH per request — both halves of it.
669
+ *
670
+ * `loadConfig()` already re-read `config.json` every time, but the API key
671
+ * came from the process env, which is populated once at CLI startup: a user
672
+ * who set up Postiz while the editor was open got "not configured" until a
673
+ * restart, with `postizUrl` live and the key stale (2026-08-27). Re-running
674
+ * `loadEnvFiles` costs two small file reads on a button press and keeps the
675
+ * documented precedence exactly — it never clobbers a key the real
676
+ * environment already set, so a shell-provided key still wins.
677
+ *
678
+ * Skipped entirely when the caller injected `publishEnv` (tests own their
679
+ * environment, and reading the developer's real `~/.ossclip/.env` into a
680
+ * test would make the suite depend on the machine it runs on).
681
+ */
682
+ const resolvePublishConfig = (): ReturnType<typeof publishConfigured> => {
683
+ if (opts.publishEnv === undefined) loadEnvFiles();
684
+ return publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
685
+ };
686
+ /** ffmpeg/ffprobe for the publish endpoints' probe + delivery encode —
687
+ * from the same config read the rest of the panel resolves through. The
688
+ * `?? "ffmpeg"` legs exist only for the narrowed `loadCfg` seam; the real
689
+ * `loadConfig()` always fills both. */
690
+ const publishTools = (): { ffmpegPath: string; ffprobePath: string } => {
691
+ const cfg = (opts.loadCfg ?? loadConfig)();
692
+ return { ffmpegPath: cfg.ffmpegPath ?? "ffmpeg", ffprobePath: cfg.ffprobePath ?? "ffprobe" };
693
+ };
694
+ /**
695
+ * The sound library the SFX routes serve, loaded through the SAME config gate
696
+ * produce's sfx step uses (`sfxBundledPack` → `resolveSfxBundledPack`). One
697
+ * helper rather than the resolution inline at each route, because /library
698
+ * and /audio must agree: a menu offering `pop` while /audio 404s it — or
699
+ * worse, while the next render drops it — is the exact mismatch the gate
700
+ * exists to avoid.
701
+ *
702
+ * Read per request, like every other `loadCfg` consumer here: the server
703
+ * outlives config edits (R17 §83's mutable-workdir posture), and a user who
704
+ * flips the key gets it on the next refresh instead of on a restart.
705
+ *
706
+ * The malformed-value warning is dropped on purpose — this is a request path
707
+ * with no console the user is watching, and produce prints it on the run that
708
+ * actually places effects. The loader's own issues DO reach the panel, which
709
+ * is where a `~/.ossclip/sfx` author sees them.
710
+ */
711
+ const sfxLibrary = (): ReturnType<typeof loadSfxLibrary> =>
712
+ (opts.loadSfx ?? loadSfxLibrary)({
713
+ includeBundled: resolveSfxBundledPack((opts.loadCfg ?? loadConfig)().sfxBundledPack).include,
714
+ });
534
715
  const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
535
716
  /** The pack the panel shows: the approved file first (the user's
536
717
  * decision), else the newest valid `youtube-<key>.json` cache (what the
@@ -589,6 +770,18 @@ export async function startEditServer(
589
770
  try {
590
771
  const url = new URL(req.url ?? "/", "http://localhost");
591
772
 
773
+ if (url.pathname === EDIT_HEALTH_PATH && req.method === "GET") {
774
+ // Who is on this port (edit-health.ts owns the contract). This is
775
+ // what makes a second `ossclip edit` on the same project an ATTACH
776
+ // instead of an EADDRINUSE stack, and it answers with the CURRENT
777
+ // workdir — mutable since R17 §83, so a server whose project was
778
+ // switched from the page identifies as the project it is serving now.
779
+ return send(
780
+ 200,
781
+ editHealthBody({ version: packageVersion(), workdir, pid: process.pid }),
782
+ );
783
+ }
784
+
592
785
  if (url.pathname === "/api/production" && req.method === "GET") {
593
786
  if (!workdir) {
594
787
  // Not an error — the picker state (R17 §83). Recents ride along
@@ -970,13 +1163,16 @@ export async function startEditServer(
970
1163
  const chunks: Buffer[] = [];
971
1164
  for await (const c of req) chunks.push(c as Buffer);
972
1165
  let customOut: string | undefined;
1166
+ let replan = false;
973
1167
  try {
974
1168
  const raw = Buffer.concat(chunks).toString();
975
1169
  if (raw.trim()) {
976
- const body = JSON.parse(raw) as { out?: string };
1170
+ const body = JSON.parse(raw) as { out?: string; replan?: boolean };
977
1171
  if (typeof body.out === "string" && body.out.trim()) {
978
1172
  customOut = body.out.trim();
979
1173
  }
1174
+ // Opt back into a fresh LLM plan (renderReplayArgs' why).
1175
+ replan = body.replan === true;
980
1176
  }
981
1177
  } catch {
982
1178
  // ignore
@@ -1031,6 +1227,30 @@ export async function startEditServer(
1031
1227
  filteredArgs.push("--out", customOut);
1032
1228
  args = filteredArgs;
1033
1229
  }
1230
+ // THE EDITOR IS THE AUTHORITY for a render started here: pin the
1231
+ // plan the user just reviewed (`production.json`'s scenes) instead
1232
+ // of letting `--produce` plan a fresh one that renumbers scenes and
1233
+ // orphans their edits (renderReplayArgs owns the full why). Written
1234
+ // to its own file rather than reusing `scenes-<key>.json`, which is
1235
+ // keyed to a beat sheet this render may no longer match.
1236
+ let scenesPath: string | undefined;
1237
+ if (!replan) {
1238
+ try {
1239
+ const production = JSON.parse(
1240
+ await readFile(join(workdir!, "production.json"), "utf8"),
1241
+ ) as { scenes?: unknown };
1242
+ if (Array.isArray(production.scenes) && production.scenes.length > 0) {
1243
+ scenesPath = join(workdir!, REVIEWED_SCENES_BASENAME);
1244
+ await writeFile(scenesPath, `${JSON.stringify(production.scenes, null, 2)}\n`);
1245
+ }
1246
+ } catch {
1247
+ // No production.json, an old one without `scenes`, or an
1248
+ // unwritable workdir: replay the recorded command rather than
1249
+ // refuse to render (renderReplayArgs' no-plan case).
1250
+ scenesPath = undefined;
1251
+ }
1252
+ }
1253
+ args = renderReplayArgs(args, { scenesPath, replan });
1034
1254
  renderLines = [];
1035
1255
  renderExit = null;
1036
1256
  renderStartedAt = Date.now();
@@ -1458,6 +1678,205 @@ export async function startEditServer(
1458
1678
  return send(200, { ok: true, mdPath });
1459
1679
  }
1460
1680
 
1681
+ if (url.pathname === "/api/youtube/generate" && req.method === "POST") {
1682
+ // Generate the pack ON DEMAND (2026-08-29): a render produced
1683
+ // without --youtube has no caption pack, and the publish modal
1684
+ // dead-ended on "run produce with --youtube" — a full re-produce
1685
+ // just to buy one LLM call. This writes the same CACHE file
1686
+ // produce's Y2 block would have, never the approved file: approval
1687
+ // stays the user's explicit act (PUT /api/youtube), and the user
1688
+ // still reviews the captions before anything sends.
1689
+ if (!workdir) return send(409, { error: "no workdir open" });
1690
+ // An LLM call costs money — one at a time, a second is a 409 like
1691
+ // a second caption rewrite.
1692
+ if (packGenBusy) {
1693
+ return send(409, { error: "a caption-pack generation is already running" });
1694
+ }
1695
+ // Lenient transcript read but a 412 when it yields nothing — the
1696
+ // /api/publish/regenerate posture, same reason: a pack with no
1697
+ // transcript would be invented metadata. The words joined raw is
1698
+ // the honest input the server has (regenerate made the same call);
1699
+ // produce's stamped transcript needs the cut map, which only a
1700
+ // produce run holds — the prompt tolerates plain text, the model
1701
+ // just gets no measured chapters worth trusting.
1702
+ let words: Array<{ text: string }> = [];
1703
+ let lastWordEnd: number | null = null;
1704
+ try {
1705
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
1706
+ words?: unknown;
1707
+ };
1708
+ if (Array.isArray(raw.words)) {
1709
+ words = raw.words.filter(
1710
+ (w): w is { text: string } =>
1711
+ typeof (w as { text?: unknown } | null)?.text === "string",
1712
+ );
1713
+ for (const w of raw.words) {
1714
+ const end = (w as { end?: unknown } | null)?.end;
1715
+ if (typeof end === "number" && Number.isFinite(end)) {
1716
+ lastWordEnd = Math.max(lastWordEnd ?? 0, end);
1717
+ }
1718
+ }
1719
+ }
1720
+ } catch {
1721
+ // absent/corrupt → the 412 below
1722
+ }
1723
+ if (words.length === 0) {
1724
+ return send(412, {
1725
+ error:
1726
+ "no transcript in this workdir — the pack would have nothing to ground " +
1727
+ "against; re-run `ossclip produce`.",
1728
+ });
1729
+ }
1730
+ // The prompt's runtime ceiling: the output duration produce
1731
+ // measured (render-props.json), else the transcript's last word
1732
+ // end — source-clock, but the honest number available, and a
1733
+ // raw-text transcript gets no measured chapters anyway.
1734
+ let durationSec: number | null = null;
1735
+ try {
1736
+ const props = JSON.parse(await readFile(propsPath(), "utf8")) as {
1737
+ outputDurationSec?: unknown;
1738
+ };
1739
+ if (
1740
+ typeof props.outputDurationSec === "number" &&
1741
+ Number.isFinite(props.outputDurationSec) &&
1742
+ props.outputDurationSec > 0
1743
+ ) {
1744
+ durationSec = props.outputDurationSec;
1745
+ }
1746
+ } catch {
1747
+ // corrupt props — the transcript fallback below
1748
+ }
1749
+ if (durationSec === null && lastWordEnd !== null && lastWordEnd > 0) {
1750
+ durationSec = lastWordEnd;
1751
+ }
1752
+ if (durationSec === null) {
1753
+ return send(412, {
1754
+ error: "cannot determine the video's duration — re-run `ossclip produce`.",
1755
+ });
1756
+ }
1757
+ const cmd = await readCommandRecord();
1758
+ const cfg = (opts.loadCfg ?? loadConfig)();
1759
+ // The editorial steer produce would have used: the recorded flags
1760
+ // first, then produce's own defaults when a flag is absent —
1761
+ // intent has none, audience falls back to the config's `audience`
1762
+ // (produce.ts's typed read: typeof, never truthiness).
1763
+ const intent = lastFlagValue(cmd?.args ?? [], ["--intent"]);
1764
+ const audience =
1765
+ lastFlagValue(cmd?.args ?? [], ["--audience"]) ??
1766
+ (typeof cfg.audience === "string" ? cfg.audience : undefined);
1767
+ // hook/coverText come from the beat sheet in produce; a produce
1768
+ // run that planned left its sheet cached in the workdir. The
1769
+ // shipped cover headline (cover.json, possibly the user's own
1770
+ // words) backstops coverText. All optional — buildYoutubePrompt
1771
+ // omits absent lines, exactly as a no-beat-sheet produce does.
1772
+ let hook: string | undefined;
1773
+ let coverText: string | undefined;
1774
+ const beatCache = await newestWorkdirFile(
1775
+ (n) => n.startsWith("beatsheet-") && n.endsWith(".json"),
1776
+ );
1777
+ if (beatCache !== null) {
1778
+ try {
1779
+ const sheet = JSON.parse(await readFile(beatCache, "utf8")) as {
1780
+ hook?: unknown;
1781
+ coverText?: unknown;
1782
+ };
1783
+ if (typeof sheet.hook === "string" && sheet.hook.trim().length > 0) {
1784
+ hook = sheet.hook;
1785
+ }
1786
+ if (typeof sheet.coverText === "string" && sheet.coverText.trim().length > 0) {
1787
+ coverText = sheet.coverText;
1788
+ }
1789
+ } catch {
1790
+ // a corrupt cache loses a steer line, never the generation
1791
+ }
1792
+ }
1793
+ if (coverText === undefined) {
1794
+ const provenance = await readCoverProvenance(workdir);
1795
+ if (provenance !== null && provenance.text.trim().length > 0) {
1796
+ coverText = provenance.text;
1797
+ }
1798
+ }
1799
+ // Env fresh per press, then the caption regenerate's provider
1800
+ // resolution verbatim — the same "which LLM does this project
1801
+ // use" question with the same three-rung answer.
1802
+ if (opts.publishEnv === undefined) loadEnvFiles();
1803
+ const env = opts.publishEnv ?? process.env;
1804
+ const usagePath = join(workdir, "usage.json");
1805
+ let usageLog: unknown = null;
1806
+ try {
1807
+ usageLog = JSON.parse(await readFile(usagePath, "utf8"));
1808
+ } catch {
1809
+ // no log yet — the resolution falls through to the pin/detection
1810
+ }
1811
+ const resolved = captionRegenProvider({
1812
+ usageLog,
1813
+ commandArgs: cmd?.args ?? null,
1814
+ env,
1815
+ hasBin: (bin) => binOnPath(bin, env),
1816
+ });
1817
+ if (resolved.status === "unavailable") {
1818
+ return send(412, { error: resolved.reason });
1819
+ }
1820
+ const provider = (opts.makeLlmProvider ?? createProvider)(resolved.provider);
1821
+ packGenBusy = true;
1822
+ try {
1823
+ let pack: YoutubePack;
1824
+ try {
1825
+ pack = await generateYoutubePack(provider, {
1826
+ transcriptText: words.map((w) => w.text).join(" "),
1827
+ ...(intent !== undefined ? { intent } : {}),
1828
+ ...(hook !== undefined ? { hook } : {}),
1829
+ ...(coverText !== undefined ? { coverText } : {}),
1830
+ ...(audience !== undefined ? { audience } : {}),
1831
+ durationSec,
1832
+ });
1833
+ } catch (err) {
1834
+ // 200 with ok:false, the caption regenerate's posture: a
1835
+ // provider failure is a sentence the panel shows VERBATIM,
1836
+ // never a dead 500. Nothing is cached (§106).
1837
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1838
+ }
1839
+ const pricing = cfg.pricing ?? {};
1840
+ // The spend is real, so it survives the session — the caption
1841
+ // regenerate's append, atomically.
1842
+ const nextLog = appendUsageRun(
1843
+ usageLog,
1844
+ { at: new Date().toISOString(), records: provider.usage },
1845
+ pricing,
1846
+ );
1847
+ const usageTmp = `${usagePath}.tmp`;
1848
+ await writeFile(usageTmp, JSON.stringify(nextLog, null, 2));
1849
+ await rename(usageTmp, usagePath);
1850
+ // A `youtube-<key>.json` cache, keyed on the provider asked like
1851
+ // produce's Y2 write — deliberately NOT produce's exact key: that
1852
+ // key hashes the cut map's spans and the stamped transcript,
1853
+ // which only a produce run holds, and matching it would falsely
1854
+ // claim cache identity with an answer built from different
1855
+ // inputs. mtime is what makes this pack current: both
1856
+ // currentYoutubePack and loadPublishPack sort caches newest
1857
+ // first. Atomic like the approved-pack write.
1858
+ const key = createHash("sha1")
1859
+ .update(
1860
+ JSON.stringify([
1861
+ YOUTUBE_PROMPT_VERSION,
1862
+ resolved.provider,
1863
+ intent ?? "",
1864
+ audience ?? "",
1865
+ words.map((w) => w.text),
1866
+ ]),
1867
+ )
1868
+ .digest("hex")
1869
+ .slice(0, 8);
1870
+ const packPath = join(workdir, `youtube-${key}.json`);
1871
+ const packTmp = `${packPath}.tmp`;
1872
+ await writeFile(packTmp, JSON.stringify(pack, null, 2));
1873
+ await rename(packTmp, packPath);
1874
+ return send(200, { ok: true, usage: formatUsageLine(provider.usage, pricing) });
1875
+ } finally {
1876
+ packGenBusy = false;
1877
+ }
1878
+ }
1879
+
1461
1880
  // ---- Publish (2026-08-26) -----------------------------------------
1462
1881
  // The server's FIRST outbound-network endpoints, against the
1463
1882
  // roadmap's "replay-only by deliberate design" posture — allowed
@@ -1468,14 +1887,33 @@ export async function startEditServer(
1468
1887
  // in the user's shell env) does the upload.
1469
1888
  if (url.pathname === "/api/publish" && req.method === "GET") {
1470
1889
  if (!workdir) return send(409, { error: "no workdir open" });
1471
- const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1890
+ const configured = resolvePublishConfig();
1472
1891
  if (!configured.ok) {
1473
1892
  return send(200, { configured: false, reason: configured.message });
1474
1893
  }
1475
1894
  const cmd = await readCommandRecord();
1476
1895
  const out = cmd ? recordedOutPath(cmd) : null;
1477
1896
  const pack = await currentYoutubePack();
1478
- let integrations: Array<{ id: string; provider: string; name: string; caption: string }> = [];
1897
+ // Pre-flight duration for the panel, read leniently (the
1898
+ // /api/cleanup posture): a missing ffprobe or an unreadable render
1899
+ // degrades to null — the panel loses the gray-out, never the modal —
1900
+ // and POST re-checks the caps authoritatively anyway.
1901
+ let durationSec: number | null = null;
1902
+ if (out !== null && existsSync(out)) {
1903
+ try {
1904
+ durationSec = (await (opts.probeVideo ?? probe)(publishTools(), out)).duration;
1905
+ } catch {
1906
+ // degrade — the caps still apply server-side on POST
1907
+ }
1908
+ }
1909
+ let integrations: Array<{
1910
+ id: string;
1911
+ provider: string;
1912
+ name: string;
1913
+ caption: string;
1914
+ durationCapSec: number | null;
1915
+ sizeCapBytes: number | null;
1916
+ }> = [];
1479
1917
  try {
1480
1918
  const provider = createPostizProvider({
1481
1919
  baseUrl: configured.baseUrl,
@@ -1488,6 +1926,13 @@ export async function startEditServer(
1488
1926
  // The caption the publish WOULD use — authored-else-derived —
1489
1927
  // so the panel previews truth, not a guess of it.
1490
1928
  caption: pack !== null ? captionForProvider(pack, t.provider) : "",
1929
+ // Null = no cap (limits.ts: absence means unlimited) — the
1930
+ // panel grays out a channel only against a cap that exists.
1931
+ durationCapSec: PLATFORM_DURATION_CAPS_SEC[t.provider] ?? null,
1932
+ // The platform's upload byte ceiling, same posture: null means
1933
+ // uncapped, a number means this channel gets its own smaller
1934
+ // delivery encode (limits.ts: Instagram's ~100MB URL-fetch cap).
1935
+ sizeCapBytes: PLATFORM_SIZE_CAP_BYTES[t.provider] ?? null,
1491
1936
  }));
1492
1937
  } catch (err) {
1493
1938
  return send(200, {
@@ -1502,13 +1947,14 @@ export async function startEditServer(
1502
1947
  integrations,
1503
1948
  packAvailable: pack !== null,
1504
1949
  outPathExists: out !== null && existsSync(out),
1950
+ durationSec,
1505
1951
  receipt: await readPublishReceipt(workdir),
1506
1952
  });
1507
1953
  }
1508
1954
 
1509
1955
  if (url.pathname === "/api/publish" && req.method === "POST") {
1510
1956
  if (!workdir) return send(409, { error: "no workdir open" });
1511
- const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1957
+ const configured = resolvePublishConfig();
1512
1958
  if (!configured.ok) return send(412, { error: configured.message });
1513
1959
  const chunks: Buffer[] = [];
1514
1960
  for await (const c of req) chunks.push(c as Buffer);
@@ -1522,6 +1968,9 @@ export async function startEditServer(
1522
1968
  // ids fall back to the pack's authored-else-derived caption.
1523
1969
  captions: z.record(z.string(), z.string()).optional(),
1524
1970
  force: z.boolean().optional(),
1971
+ // What uploads (the CLI's --delivery): auto (default) builds
1972
+ // the cached delivery encode, master sends the untouched render.
1973
+ delivery: z.enum(["auto", "master"]).optional(),
1525
1974
  })
1526
1975
  .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1527
1976
  if (!parsed.success) return send(400, { error: parsed.error.message });
@@ -1555,23 +2004,290 @@ export async function startEditServer(
1555
2004
  fetchImpl: opts.publishFetch,
1556
2005
  });
1557
2006
  const targets = await provider.listTargets();
1558
- const picked = parsed.data.integrationIds.map((id) => {
2007
+ let picked = parsed.data.integrationIds.map((id) => {
1559
2008
  const hit = targets.find((t) => t.id === id);
1560
2009
  if (!hit) throw new Error(`no integration with id "${id}" in Postiz`);
1561
2010
  return hit;
1562
2011
  });
2012
+ // Same core helpers, same semantics as the CLI (checkDurationCaps
2013
+ // + ensureDeliveryFile — one spelling of the rules): drop the
2014
+ // channels this video can never land on, publish the rest, and
2015
+ // only when EVERY pick is over its cap refuse the whole request.
2016
+ const tools = publishTools();
2017
+ const masterProbe = await (opts.probeVideo ?? probe)(tools, out);
2018
+ const violations = checkDurationCaps(picked, masterProbe.duration);
2019
+ // Duration entries keep their original shape; size entries carry
2020
+ // a `reason` and the cap that doomed them — additive fields only,
2021
+ // so an older panel reading `dropped` keeps working.
2022
+ const dropped: Array<Record<string, unknown>> = violations.map((v) => ({
2023
+ id: v.target.id,
2024
+ provider: v.target.provider,
2025
+ name: v.target.name,
2026
+ capSec: v.capSec,
2027
+ }));
2028
+ if (violations.length > 0) {
2029
+ const over = new Set(violations.map((v) => v.target.id));
2030
+ picked = picked.filter((t) => !over.has(t.id));
2031
+ if (picked.length === 0) {
2032
+ return send(412, {
2033
+ error:
2034
+ `every picked channel refuses a ${Math.round(masterProbe.duration)}s video — ` +
2035
+ "nothing to publish",
2036
+ dropped,
2037
+ });
2038
+ }
2039
+ }
2040
+ // Size-cap pre-check with the pure plan (the CLI's semantics,
2041
+ // one spelling): an unattainable cap drops the channel BEFORE
2042
+ // any encode — ensureDeliveryFile THROWS on unattainable, and a
2043
+ // 502 with ffmpeg arithmetic in it is not a drop-and-continue.
2044
+ const masterSizeBytes = (await stat(out)).size;
2045
+ const src = {
2046
+ width: masterProbe.width,
2047
+ height: masterProbe.height,
2048
+ fps: masterProbe.fps,
2049
+ duration: masterProbe.duration,
2050
+ sizeBytes: masterSizeBytes,
2051
+ };
2052
+ let capGroups = sizeCapGroups(picked);
2053
+ if (parsed.data.delivery !== "master") {
2054
+ for (const [capBytes, group] of capGroups) {
2055
+ const capped = deliveryEncodePlan(src, { sizeCapBytes: capBytes });
2056
+ if (capped !== null && "unattainable" in capped) {
2057
+ for (const t of group) {
2058
+ dropped.push({
2059
+ id: t.id,
2060
+ provider: t.provider,
2061
+ name: t.name,
2062
+ sizeCapBytes: capBytes,
2063
+ reason: "size",
2064
+ });
2065
+ }
2066
+ const over = new Set(group.map((t) => t.id));
2067
+ picked = picked.filter((t) => !over.has(t.id));
2068
+ }
2069
+ }
2070
+ if (picked.length === 0) {
2071
+ return send(412, {
2072
+ error:
2073
+ `every picked channel's size cap is unattainable for a ` +
2074
+ `${Math.round(masterProbe.duration)}s video — nothing to publish`,
2075
+ dropped,
2076
+ });
2077
+ }
2078
+ capGroups = sizeCapGroups(picked);
2079
+ }
1563
2080
  const posts = buildPublishPosts(pack, picked).map((p) => ({
1564
2081
  ...p,
1565
2082
  caption: parsed.data.captions?.[p.target.id] ?? p.caption,
1566
2083
  }));
1567
- const result = await provider.publish({ videoPath: out, posts, when });
2084
+ // Synchronous encodes (~1–3 min each, fetch won't time out) — a
2085
+ // job/poll model for the WORK is a noted follow-up, but the
2086
+ // PROGRESS is polled: /api/publish/progress reads the state the
2087
+ // onProgress callback below keeps current. Sequential per cap
2088
+ // group (two ffmpegs racing for cores would slow both), so pct
2089
+ // simply runs 0→100 once per file and `file` names which one.
2090
+ let uploadPath = out;
2091
+ const cappedPaths = new Map<number, string>();
2092
+ if (parsed.data.delivery !== "master") {
2093
+ // `workdir` is a mutable binding, so its non-null narrowing
2094
+ // from the top of the handler doesn't survive into a closure —
2095
+ // capture it while narrowed.
2096
+ const wd = workdir;
2097
+ const runEnsure = async (
2098
+ sizeCapBytes?: number,
2099
+ ): ReturnType<typeof ensureDeliveryFile> => {
2100
+ let currentFile: string | null = null;
2101
+ publishProgress = { phase: "encoding", pct: 0, etaSec: null, speed: null, file: null };
2102
+ return (opts.ensureDelivery ?? ensureDeliveryFile)(tools, wd, out, {
2103
+ ...(sizeCapBytes !== undefined ? { sizeCapBytes } : {}),
2104
+ onStart: (name) => {
2105
+ currentFile = name;
2106
+ publishProgress = { phase: "encoding", pct: 0, etaSec: null, speed: null, file: name };
2107
+ },
2108
+ onProgress: (p) => {
2109
+ publishProgress = {
2110
+ phase: "encoding",
2111
+ // Percent against the MASTER's duration — the delivery
2112
+ // encode preserves it, so out_time maps 1:1.
2113
+ pct:
2114
+ p.outTimeSec !== undefined && masterProbe.duration > 0
2115
+ ? Math.min(
2116
+ 100,
2117
+ Math.round((p.outTimeSec / masterProbe.duration) * 100),
2118
+ )
2119
+ : null,
2120
+ etaSec:
2121
+ p.outTimeSec !== undefined && p.speed !== undefined
2122
+ ? encodeEta(masterProbe.duration, p.outTimeSec, p.speed)
2123
+ : null,
2124
+ speed: p.speed ?? null,
2125
+ file: currentFile,
2126
+ };
2127
+ },
2128
+ });
2129
+ };
2130
+ uploadPath = (await runEnsure()).path;
2131
+ // One encode per DISTINCT cap — the bitrate-bearing filename is
2132
+ // the cache key, so a capped plan that lands on the default
2133
+ // file's name cache-hits instead of re-encoding.
2134
+ for (const capBytes of capGroups.keys()) {
2135
+ cappedPaths.set(capBytes, (await runEnsure(capBytes)).path);
2136
+ }
2137
+ }
2138
+ // No upload ETA — Postiz's multipart upload gives us nothing to
2139
+ // measure, so the phase alone is the signal.
2140
+ publishProgress = { phase: "uploading", pct: null, etaSec: null, speed: null, file: null };
2141
+ const result = await provider.publish({
2142
+ videoPath: uploadPath,
2143
+ posts: attachDeliveryMedia(posts, uploadPath, cappedPaths),
2144
+ when,
2145
+ });
1568
2146
  await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
1569
- return send(200, { ok: true, receipt: result });
2147
+ return send(200, { ok: true, receipt: result, dropped });
1570
2148
  } catch (err) {
1571
2149
  // Fail loud, verbatim — Postiz's own validation message is the
1572
2150
  // most specific thing anyone has (per-provider settings are its
1573
2151
  // domain, not ours).
1574
2152
  return send(502, { error: err instanceof Error ? err.message : String(err) });
2153
+ } finally {
2154
+ publishProgress = null;
2155
+ }
2156
+ }
2157
+
2158
+ if (url.pathname === "/api/publish/progress" && req.method === "GET") {
2159
+ // Where the in-flight publish is — 200 always, `progress: null`
2160
+ // when idle (a cache hit or a skip-plan publish never enters the
2161
+ // encoding phase, and the panel keeps its static line for that).
2162
+ return send(200, { progress: publishProgress });
2163
+ }
2164
+
2165
+ if (url.pathname === "/api/publish/regenerate" && req.method === "POST") {
2166
+ // Rewrite ONE network's caption with the LLM the produce run used
2167
+ // (handoff 2026-08-29 item 4): the prompt carries the transcript,
2168
+ // the caption AS THE PANEL HOLDS IT and the user's correction, and
2169
+ // the replacement text rides back into the panel's box. Nothing
2170
+ // auto-sends and nothing writes to the pack — the user still
2171
+ // reviews, edits and presses Publish.
2172
+ if (!workdir) return send(409, { error: "no workdir open" });
2173
+ // An LLM call costs money — one at a time, a second is a 409 like
2174
+ // a second thumbnail.
2175
+ if (captionRegenBusy) {
2176
+ return send(409, { error: "a caption regeneration is already running" });
2177
+ }
2178
+ const chunks: Buffer[] = [];
2179
+ for await (const c of req) chunks.push(c as Buffer);
2180
+ // `currentCaption` is deliberately the client's text — the ONE
2181
+ // body-steered value beyond the ask itself, because the box may
2182
+ // hold the user's manual edits and the model must see what the
2183
+ // user sees. It steers only prompt text, never a path or a spawn.
2184
+ const parsed = z
2185
+ .object({
2186
+ network: z.string().min(1),
2187
+ instruction: z.string().min(1),
2188
+ currentCaption: z.string(),
2189
+ })
2190
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
2191
+ if (!parsed.success) return send(400, { error: parsed.error.message });
2192
+ // Lenient transcript read (the GET /api/transcript posture), but a
2193
+ // 412 when it yields nothing: a rewrite with no transcript would be
2194
+ // a rewrite with no factual evidence, which is the exact failure
2195
+ // this endpoint exists to repair.
2196
+ let transcript: { language: string; words: Array<{ text: string }> } | null = null;
2197
+ try {
2198
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
2199
+ language?: unknown;
2200
+ words?: unknown;
2201
+ };
2202
+ if (Array.isArray(raw.words)) {
2203
+ transcript = {
2204
+ language: typeof raw.language === "string" ? raw.language : "en",
2205
+ words: raw.words.filter(
2206
+ (w): w is { text: string } =>
2207
+ typeof (w as { text?: unknown } | null)?.text === "string",
2208
+ ),
2209
+ };
2210
+ }
2211
+ } catch {
2212
+ // absent/corrupt → the 412 below
2213
+ }
2214
+ if (transcript === null || transcript.words.length === 0) {
2215
+ return send(412, {
2216
+ error:
2217
+ "no transcript in this workdir — the rewrite would have nothing to " +
2218
+ "ground against; re-run `ossclip produce`.",
2219
+ });
2220
+ }
2221
+ // Env fresh per press, resolvePublishConfig's rule: a key set after
2222
+ // startup must count. Skipped when tests own the environment.
2223
+ if (opts.publishEnv === undefined) loadEnvFiles();
2224
+ const env = opts.publishEnv ?? process.env;
2225
+ const usagePath = join(workdir, "usage.json");
2226
+ let usageLog: unknown = null;
2227
+ try {
2228
+ usageLog = JSON.parse(await readFile(usagePath, "utf8"));
2229
+ } catch {
2230
+ // no log yet — the resolution falls through to the pin/detection
2231
+ }
2232
+ const cmd = await readCommandRecord();
2233
+ const resolved = captionRegenProvider({
2234
+ usageLog,
2235
+ commandArgs: cmd?.args ?? null,
2236
+ env,
2237
+ hasBin: (bin) => binOnPath(bin, env),
2238
+ });
2239
+ if (resolved.status === "unavailable") {
2240
+ // Precondition, not a generation failure — 412 like a thumbnail
2241
+ // regenerate on an unavailable project.
2242
+ return send(412, { error: resolved.reason });
2243
+ }
2244
+ const provider = (opts.makeLlmProvider ?? createProvider)(resolved.provider);
2245
+ captionRegenBusy = true;
2246
+ try {
2247
+ let caption: string;
2248
+ try {
2249
+ caption = await generateCaptionRegen(provider, {
2250
+ network: parsed.data.network,
2251
+ currentCaption: parsed.data.currentCaption,
2252
+ instruction: parsed.data.instruction,
2253
+ transcriptText: transcript.words.map((w) => w.text).join(" "),
2254
+ charCap: captionCap(parsed.data.network),
2255
+ });
2256
+ } catch (err) {
2257
+ // 200 with ok:false, the thumbnail regenerate's posture: a
2258
+ // provider failure is a sentence the panel shows VERBATIM,
2259
+ // never a dead 500.
2260
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
2261
+ }
2262
+ const pricing = (opts.loadCfg ?? loadConfig)().pricing ?? {};
2263
+ // The spend is real, so it survives the session: appended into
2264
+ // the workdir's usage log the way produce appends its runs.
2265
+ // Atomic like the overrides write — /api/usage may read it at
2266
+ // any moment.
2267
+ const nextLog = appendUsageRun(
2268
+ usageLog,
2269
+ { at: new Date().toISOString(), records: provider.usage },
2270
+ pricing,
2271
+ );
2272
+ const tmp = `${usagePath}.tmp`;
2273
+ await writeFile(tmp, JSON.stringify(nextLog, null, 2));
2274
+ await rename(tmp, usagePath);
2275
+ // Advisory ONLY, never a block: captions legitimately carry
2276
+ // brand and platform words the take never speaks. The `--speaker`
2277
+ // pin counts as spoken vocabulary for checkGrounding's §39
2278
+ // reason — the channel name is in nearly every caption.
2279
+ const speaker = lastFlagValue(cmd?.args ?? [], ["--speaker"]);
2280
+ const notes = [...new Set(ungroundedTokens(caption, transcript, speaker))].map(
2281
+ (token) => `⚠ grounding: "${token}" — not in the take`,
2282
+ );
2283
+ return send(200, {
2284
+ ok: true,
2285
+ caption,
2286
+ usage: formatUsageLine(provider.usage, pricing),
2287
+ notes,
2288
+ });
2289
+ } finally {
2290
+ captionRegenBusy = false;
1575
2291
  }
1576
2292
  }
1577
2293
 
@@ -1772,6 +2488,170 @@ export async function startEditServer(
1772
2488
  }
1773
2489
  }
1774
2490
 
2491
+ if (url.pathname === "/api/sfx/plan" && req.method === "GET") {
2492
+ // The MODEL's placement plan plus the word space it is written in
2493
+ // (Phase 4). Two values, one route, because they are useless apart:
2494
+ // a placement is `{soundId, word}` and `word` is an INDEX, so the
2495
+ // editor can only draw a marker — or write one — against the exact
2496
+ // array produce counted.
2497
+ //
2498
+ // And that array is NOT `transcript.json`, which is what
2499
+ // /api/transcript serves: produce writes that file straight off
2500
+ // whisper (produce.ts, `writeFile(transcriptCache, …)`) and only
2501
+ // THEN applies repairs and the `--clip` slice, planning sound
2502
+ // effects against the result. `applyRepairs` splices, so the two
2503
+ // index spaces need not line up at all — clip.ts's own note on
2504
+ // slicing says it outright ("repairs may change word counts, so raw
2505
+ // and repaired index spaces need not line up"). An editor drawing
2506
+ // this lane off transcript.json would place every marker on the
2507
+ // wrong word the moment one repair changed a word count, and its
2508
+ // drags would WRITE those wrong indices into overrides.json, where
2509
+ // produce reads them in the other space. So the derivation happens
2510
+ // HERE, once, on the server that has the pieces.
2511
+ //
2512
+ // The derivation is `ProductionSchema.repairs`' own stated contract:
2513
+ // `applyRepairs(transcript, repairs.filter(r => r.applied))`
2514
+ // reconstructs exactly what was rendered. No clip windowing on top:
2515
+ // production.json's `transcript` is ALREADY the sliced raw one and
2516
+ // its `repairs` were re-indexed with it (produce.ts's `--clip`
2517
+ // block: `rawTranscript = rawSlice.transcript` / `repairs =
2518
+ // sliceRepairs(…)`), so a second slice here would shift every index
2519
+ // by the clip's offset — the very bug this route exists to avoid.
2520
+ //
2521
+ // Its own route rather than a field on /api/production: that endpoint
2522
+ // is the page's hot load path and reads render-props.json, and this
2523
+ // needs production.json plus a repair replay. Lenient throughout —
2524
+ // any missing or corrupt piece answers `null` and the editor hides
2525
+ // the lane, exactly the /api/cleanup posture. A sound-effect lane is
2526
+ // never worth a 500.
2527
+ if (!workdir) return send(409, { error: "no workdir open" });
2528
+ let production: Record<string, unknown> | null = null;
2529
+ try {
2530
+ production = JSON.parse(
2531
+ await readFile(join(workdir, "production.json"), "utf8"),
2532
+ ) as Record<string, unknown>;
2533
+ } catch {
2534
+ return send(200, { sfx: null, words: null });
2535
+ }
2536
+ // Parsed, never cast (CLAUDE.md) — production.json is a file a user
2537
+ // can hand-edit, and the editor has no zod of its own, so the parse
2538
+ // that guards this payload has to be this one. A `sfx` field that
2539
+ // fails it is the same as none: no lane.
2540
+ const parsedSfx = ProductionSfxSchema.safeParse(production.sfx);
2541
+ const parsedTranscript = TranscriptSchema.safeParse(production.transcript);
2542
+ let words: Array<{ text: string; start: number; end: number }> | null = null;
2543
+ if (parsedTranscript.success) {
2544
+ const stored = Array.isArray(production.repairs)
2545
+ ? (production.repairs as Array<Record<string, unknown>>)
2546
+ : [];
2547
+ const applied = stored.filter((r) => r.applied === true);
2548
+ if (applied.length === 0) {
2549
+ words = parsedTranscript.data.words;
2550
+ } else {
2551
+ // The dictionary rides along for the reason produce's own repair
2552
+ // REPLAY passes it (its cached-repairs block): a
2553
+ // dictionary-vouched correction clears the phonetic gate only
2554
+ // when the vouched set is present, and `applyRepairs` re-decides
2555
+ // every proposal it is handed rather than trusting the stored
2556
+ // verdict.
2557
+ // `dictionary` is `unknown` on the config (file-only, validated
2558
+ // at the consumer — config.ts's posture), narrowed here the same
2559
+ // way `retranscribeSettings` narrows it a few hundred lines up.
2560
+ const rawDict = (opts.loadCfg ?? loadConfig)().dictionary;
2561
+ const dictionary =
2562
+ Array.isArray(rawDict) && rawDict.every((t) => typeof t === "string")
2563
+ ? (rawDict as string[])
2564
+ : undefined;
2565
+ const decided = applyRepairs(
2566
+ parsedTranscript.data,
2567
+ applied.map((r) => ({
2568
+ startWord: Number(r.startWord),
2569
+ endWord: Number(r.endWord),
2570
+ heard: String(r.heard),
2571
+ correction: String(r.correction),
2572
+ })),
2573
+ { dictionary },
2574
+ );
2575
+ // A repair that produce APPLIED but this replay refuses means the
2576
+ // reconstruction is not the array the placements were counted
2577
+ // against — and a refused splice can change the word count, so
2578
+ // every index after it would be off by one with nothing to say
2579
+ // so. Refuse the whole word list rather than serve a plausible
2580
+ // wrong one (§137's never-misapply rule): the lane hides, and no
2581
+ // gesture can write an index into the wrong space.
2582
+ words = decided.applied.every((r) => r.applied)
2583
+ ? decided.transcript.words
2584
+ : null;
2585
+ }
2586
+ }
2587
+ return send(200, {
2588
+ sfx: parsedSfx.success ? parsedSfx.data : null,
2589
+ // METADATA only, like the library route: text and SOURCE seconds,
2590
+ // which is all the client needs to map an index through its own
2591
+ // live TimeMap.
2592
+ words:
2593
+ words === null
2594
+ ? null
2595
+ : words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
2596
+ });
2597
+ }
2598
+
2599
+ if (url.pathname === "/api/sfx/library" && req.method === "GET") {
2600
+ // The sound palette: what the swap dropdown offers and what a
2601
+ // click-to-preview can play. NO workdir guard, unlike its siblings —
2602
+ // the library is machine-global (every pack in ~/.ossclip/sfx, plus
2603
+ // the bundled one unless `sfxBundledPack` excludes it), so it has
2604
+ // nothing to do with which project is open, and the panel can render
2605
+ // its menu before one is.
2606
+ const library = sfxLibrary();
2607
+ return send(200, {
2608
+ // METADATA only: `absPath` stays server-side. The client addresses
2609
+ // a sound by id through /api/sfx/audio, which is what keeps the
2610
+ // filesystem out of a value the page could ever hand back (the
2611
+ // audio route's path rule is the other half of the same decision).
2612
+ sounds: library.sounds.map((s) => ({
2613
+ id: s.id,
2614
+ whenToUse: s.whenToUse,
2615
+ tags: s.tags,
2616
+ gain: s.gain,
2617
+ ...(s.durationSec !== undefined ? { durationSec: s.durationSec } : {}),
2618
+ packName: s.packName,
2619
+ })),
2620
+ // A user pack with a typo is a thing to SHOW, not to swallow: the
2621
+ // loader already degraded rather than throwing, and the panel is
2622
+ // the only surface a `~/.ossclip/sfx` author ever sees.
2623
+ issues: library.issues,
2624
+ });
2625
+ }
2626
+
2627
+ if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
2628
+ // Click-to-preview. The path comes from the LOADED LIBRARY, never
2629
+ // from the client: the query carries an id, the id is looked up, and
2630
+ // the file that answers is whatever `loadSfxLibrary` resolved for it.
2631
+ // A traversal attempt is therefore not a path to reject but an id
2632
+ // nothing answers to — a plain 404, the same as any other unknown id
2633
+ // (and `SfxSoundSchema.id` is a slug, so no id can ever spell a path
2634
+ // in the first place).
2635
+ const id = url.searchParams.get("id") ?? "";
2636
+ const sound = sfxLibrary().sounds.find((s) => s.id === id);
2637
+ // Existence is re-checked here for `resolveSfxCues`' reason: a pack
2638
+ // deleted since the library was read must be a 404, not a 500 out of
2639
+ // `statSync` inside `sendFile`.
2640
+ if (sound === undefined || !existsSync(sound.absPath)) {
2641
+ return send(404, { error: `no sound "${id}" in the library` });
2642
+ }
2643
+ // No range support: these are sub-second files an `<audio>` element
2644
+ // plays whole, and a preview has nothing to seek through.
2645
+ sendFile(
2646
+ req,
2647
+ res,
2648
+ sound.absPath,
2649
+ MIME[extname(sound.absPath).toLowerCase()] ?? "application/octet-stream",
2650
+ false,
2651
+ );
2652
+ return;
2653
+ }
2654
+
1775
2655
  if (url.pathname.startsWith("/media/")) {
1776
2656
  if (!workdir) return send(409, { error: "no workdir open" });
1777
2657
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
@@ -1797,7 +2677,22 @@ export async function startEditServer(
1797
2677
  })();
1798
2678
  });
1799
2679
 
1800
- await new Promise<void>((r) => server.listen(opts.port ?? 5174, "127.0.0.1", r));
2680
+ // REJECT on a failed bind, don't crash. Without the error listener an
2681
+ // EADDRINUSE is an unhandled 'error' event on the server object, which is
2682
+ // fatal to the whole process — that raw Node stack (plus the package
2683
+ // manager's ELIFECYCLE after it) is exactly what a busy 5174 printed at
2684
+ // users, and `openEditServer` (edit-port.ts) cannot offer to attach to the
2685
+ // editor already running there unless the failure comes back as a rejection
2686
+ // it can catch. The listener is removed on success so a LATER runtime error
2687
+ // (a client resetting a connection) keeps whatever handling http gives it.
2688
+ await new Promise<void>((res, rej) => {
2689
+ const onError = (err: Error): void => rej(err);
2690
+ server.once("error", onError);
2691
+ server.listen(opts.port ?? 5174, "127.0.0.1", () => {
2692
+ server.off("error", onError);
2693
+ res();
2694
+ });
2695
+ });
1801
2696
  const addr = server.address();
1802
2697
  const port = typeof addr === "object" && addr ? addr.port : (opts.port ?? 5174);
1803
2698
  return {