ossclip 0.1.31 → 0.1.34

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,9 +17,27 @@ import {
17
17
  type YoutubePack,
18
18
  ThumbnailConceptApprovedSchema,
19
19
  ThumbnailConceptSchema,
20
+ appendUsageRun,
20
21
  approvedOverlayText,
21
22
  buildThumbnailPrompt,
23
+ captionCap,
24
+ captionForProvider,
25
+ checkDurationCaps,
26
+ createPostizProvider,
27
+ createProvider,
28
+ encodeEta,
29
+ formatUsageLine,
30
+ generateCaptionRegen,
31
+ generateYoutubePack,
32
+ YOUTUBE_PROMPT_VERSION,
33
+ deliveryEncodePlan,
34
+ ensureDeliveryFile,
35
+ PLATFORM_DURATION_CAPS_SEC,
36
+ PLATFORM_SIZE_CAP_BYTES,
37
+ probe,
38
+ alignRestamp,
22
39
  emptyOverrideDoc,
40
+ extractAudioSpan,
23
41
  // Static import is fine here: the @google/genai SDK load is LAZY inside
24
42
  // this function (core's near-zero-dep rule), so the server pays for it
25
43
  // only when a regenerate actually runs.
@@ -30,7 +48,14 @@ import {
30
48
  PORTRAIT_MIME_TYPES,
31
49
  portraitMimeType,
32
50
  readCoverProvenance,
51
+ runWhisper,
33
52
  SegmentSchema,
53
+ spliceTranscript,
54
+ TranscriptSchema,
55
+ ungroundedTokens,
56
+ whisperPromptFor,
57
+ wordsInSpan,
58
+ type ModelPrice,
34
59
  type Segment,
35
60
  thumbnailImageCacheName,
36
61
  type CoverProvenance,
@@ -42,7 +67,9 @@ import {
42
67
  // drags in @ossclip/core's process runner and llm-detect, worth deferring off
43
68
  // server startup — open.ts is node:child_process + node:path and pure command
44
69
  // building, with nothing to defer.
70
+ import { loadEnvFiles } from "./env";
45
71
  import { revealInFileManager } from "./open";
72
+ import { REVIEWED_SCENES_BASENAME, renderReplayArgs } from "./render-replay-args";
46
73
  // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
47
74
  // cover` needs the same out-resolution rule this server's thumbnail dest,
48
75
  // youtube markdown and reveal endpoint derive from, and two spellings of it
@@ -60,6 +87,10 @@ import {
60
87
  type RecordedCommand,
61
88
  } from "./cover";
62
89
  import { expandHome } from "./paths";
90
+ // The model-path and implied-language rules, from THE source doctor, setup and
91
+ // produce all resolve through — a second copy here would send a user to a
92
+ // model file the rest of the tool never looks for.
93
+ import { modelImpliedLanguage, whisperModelPath } from "./setup/manifest";
63
94
  import {
64
95
  PORTRAIT_OVERRIDE_BASENAME,
65
96
  portraitExtensionForMime,
@@ -68,6 +99,16 @@ import {
68
99
  type ResolvedPortrait,
69
100
  } from "./portrait-override";
70
101
  import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
102
+ import { captionRegenProvider } from "./caption-regen-panel";
103
+ import { binOnPath } from "./llm-detect";
104
+ import {
105
+ attachDeliveryMedia,
106
+ buildPublishPosts,
107
+ publishConfigured,
108
+ publishReceiptPath,
109
+ readPublishReceipt,
110
+ sizeCapGroups,
111
+ } from "./publish";
71
112
 
72
113
  /**
73
114
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -94,6 +135,78 @@ export function resolveEditorPageDir(): string | null {
94
135
  return candidates[0] ?? null;
95
136
  }
96
137
 
138
+ /**
139
+ * The config keys `/api/retranscribe-range` needs. Spelled structurally
140
+ * rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
141
+ * stub that supplies only what a case is about — the same shape the youtube/
142
+ * portrait keys above it are declared with.
143
+ */
144
+ export interface RetranscribeConfig {
145
+ ffmpegPath?: string;
146
+ ffprobePath?: string;
147
+ whisperPath?: string;
148
+ model?: string;
149
+ modelDir?: string;
150
+ language?: unknown;
151
+ dictionary?: unknown;
152
+ }
153
+
154
+ /**
155
+ * How to spawn whisper for a range re-decode, or why we cannot.
156
+ *
157
+ * Pure — the `openCommand`/`openInBrowser` split — so the whole
158
+ * config × missing-binary × malformed-dictionary matrix is testable without
159
+ * whisper.cpp or a model on the runner.
160
+ *
161
+ * FROM THE CONFIG, not from the workdir's `transcript-key.json`, and that is
162
+ * a knowing limitation: reading the key would mean importing produce.ts,
163
+ * which statically pulls in @ossclip/renderer AND imports this module back
164
+ * (a cycle), for a server whose whole point is to start instantly. The
165
+ * failure when the two disagree is safe and REPORTED rather than silent: a
166
+ * range re-decoded with a different model produces words that match nothing,
167
+ * and `alignRestamp` answers "matched none — stamps left as they were".
168
+ *
169
+ * The dictionary is validated all-or-nothing, `validDictionary`'s rule
170
+ * (produce.ts): a hand-edited list with a number in it biases whisper with a
171
+ * vocabulary the user never reviewed, so the whole key is dropped instead.
172
+ * Same for `language`: typeof+trim, never truthiness, never coerced.
173
+ */
174
+ export function retranscribeSettings(
175
+ cfg: RetranscribeConfig,
176
+ ):
177
+ | {
178
+ tools: { ffmpegPath: string; ffprobePath: string };
179
+ whisperPath: string;
180
+ modelPath: string;
181
+ language?: string;
182
+ prompt?: string;
183
+ }
184
+ | { error: string } {
185
+ if (!cfg.ffmpegPath || !cfg.ffprobePath || !cfg.whisperPath || !cfg.model || !cfg.modelDir) {
186
+ return {
187
+ error:
188
+ "ffmpeg or whisper is not configured — run `ossclip doctor` to see what is missing, " +
189
+ "then `ossclip setup` to install it.",
190
+ };
191
+ }
192
+ const dict = Array.isArray(cfg.dictionary)
193
+ && cfg.dictionary.length > 0
194
+ && cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
195
+ ? (cfg.dictionary as string[]).map((t) => t.trim())
196
+ : [];
197
+ const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
198
+ ? cfg.language.trim()
199
+ : modelImpliedLanguage(cfg.model);
200
+ const prompt = whisperPromptFor(dict);
201
+ return {
202
+ tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
203
+ whisperPath: cfg.whisperPath,
204
+ modelPath: whisperModelPath(cfg.model, cfg.modelDir),
205
+ ...(language !== undefined ? { language } : {}),
206
+ ...(prompt !== undefined ? { prompt } : {}),
207
+ };
208
+ }
209
+
97
210
  /**
98
211
  * The editor's backend: a handful of endpoints and a static file server,
99
212
  * deliberately dependency-free. It reads the workdir a `produce` run left
@@ -158,6 +271,14 @@ const MIME: Record<string, string> = {
158
271
  // the type is stated while the change is one line rather than a debugging
159
272
  // session.
160
273
  ".wav": "audio/wav",
274
+ // The `--cover-in-video` overlay: produce stages the cover into the workdir
275
+ // as well as the render's public dir, and the Player fetches it through
276
+ // `/media/`. Browsers do sniff an image served as octet-stream, but a
277
+ // preview that depends on sniffing is a preview that breaks the first time
278
+ // something in front of it (a proxy, a stricter engine) declines to.
279
+ ".jpg": "image/jpeg",
280
+ ".jpeg": "image/jpeg",
281
+ ".png": "image/png",
161
282
  };
162
283
 
163
284
  /**
@@ -254,11 +375,37 @@ export async function startEditServer(
254
375
  /** Config seam for the thumbnail panel — tests inject `() => ({})` so a
255
376
  * run never reads the runner's real ~/.ossclip/config.json (the
256
377
  * `recentDir` rule applied to reads). */
257
- loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
378
+ loadCfg?: () => {
379
+ youtube?: unknown;
380
+ portrait?: unknown;
381
+ thumbnailModel?: unknown;
382
+ postizUrl?: string;
383
+ /** produce's config fallback for `--audience` — the on-demand pack
384
+ * generation reads it the same way (typeof, never truthiness). */
385
+ audience?: unknown;
386
+ /** The caption-regenerate spend report prices through the same table
387
+ * produce does — absent in a stub, the defaults apply. */
388
+ pricing?: Record<string, ModelPrice>;
389
+ } & RetranscribeConfig;
390
+ /** Env seam for the publish endpoints — tests inject their own so the
391
+ * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
392
+ * test (the loadCfg rule applied to the environment). */
393
+ publishEnv?: NodeJS.ProcessEnv;
394
+ /** Fetch seam for the publish endpoints — tests stub Postiz instead of
395
+ * needing an instance on the runner (createPostizProvider's fetchImpl). */
396
+ publishFetch?: typeof fetch;
397
+ /** The publish endpoints' two ffmpeg-family shell-outs (the sliceAudio/
398
+ * runWhisper seam pattern) — tests stub a probe and a delivery encode
399
+ * instead of needing ffmpeg/ffprobe and a real render on the runner. */
400
+ probeVideo?: typeof probe;
401
+ ensureDelivery?: typeof ensureDeliveryFile;
258
402
  /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
259
403
  * observe the revealed path instead of popping a real Finder/Explorer
260
404
  * window on the runner. */
261
405
  reveal?: (path: string) => void;
406
+ /** The caption-regenerate LLM seam (the `generateThumbnail` pattern for
407
+ * text): tests inject a fake provider factory and never touch a model. */
408
+ makeLlmProvider?: typeof createProvider;
262
409
  /**
263
410
  * The cover render seam, exactly like `generateThumbnail` above. Without
264
411
  * it `regenerateCover` lazily imports @ossclip/renderer and boots a
@@ -266,6 +413,14 @@ export async function startEditServer(
266
413
  * is also why cover.ts keeps that import lazy in the first place.
267
414
  */
268
415
  renderCover?: CoverSeams["renderCover"];
416
+ /**
417
+ * The two shell-outs `/api/retranscribe-range` makes — the
418
+ * `generateThumbnail` seam pattern, twice: tests stub a decode instead of
419
+ * needing ffmpeg, whisper.cpp and a multi-gigabyte model on the runner
420
+ * (`edit-server.test.ts` must stay a unit test).
421
+ */
422
+ sliceAudio?: typeof extractAudioSpan;
423
+ runWhisper?: typeof runWhisper;
269
424
  } = {},
270
425
  ): Promise<EditServer> {
271
426
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -355,6 +510,46 @@ export async function startEditServer(
355
510
  // headless browser, and a double-click must not run two renders at the same
356
511
  // destination. `thumbnailBusy`'s rule, for the same reason.
357
512
  let coverBusy = false;
513
+ /**
514
+ * One range re-decode at a time (`/api/retranscribe-range`). whisper is a
515
+ * CPU-bound spawn AND the endpoint read-modify-writes transcript.json, so
516
+ * two in flight is both a stalled box and a lost splice — the second write
517
+ * would be built on a transcript read before the first one landed.
518
+ * `coverBusy`'s rule with a second reason on top.
519
+ */
520
+ let retranscribeBusy = false;
521
+ /**
522
+ * One caption regeneration at a time (`/api/publish/regenerate`) — an LLM
523
+ * call costs money, and a double-click must not buy two. `thumbnailBusy`'s
524
+ * rule, its own flag: a caption rewrite must not block a thumbnail.
525
+ */
526
+ let captionRegenBusy = false;
527
+ /**
528
+ * One pack generation at a time (`/api/youtube/generate`) — an LLM call
529
+ * costs money, and a double-click must not buy two. `captionRegenBusy`'s
530
+ * rule, its own flag: generating the pack must not block a caption rewrite
531
+ * already in flight (they are different buttons in different panels).
532
+ */
533
+ let packGenBusy = false;
534
+ /**
535
+ * Where the in-flight publish is right now, for the panel's poll
536
+ * (2026-08-29): the POST runs the delivery encode synchronously — minutes
537
+ * of x264 behind one fetch — so `GET /api/publish/progress` reads this
538
+ * instead of the panel staring at a static button. Null whenever no publish
539
+ * is in flight; a POST's `finally` owns the reset so an error can't leave a
540
+ * stale "encoding" behind. Per server instance like the busy flags above.
541
+ */
542
+ let publishProgress: {
543
+ phase: "encoding" | "uploading";
544
+ pct: number | null;
545
+ etaSec: number | null;
546
+ speed: number | null;
547
+ /** The delivery file being encoded (from onStart) — a size-capped
548
+ * publish runs two sequential encodes, and a bare pct that resets to 0
549
+ * mid-publish looks like a hang unless the label names which file it
550
+ * restarted for. Null before the first onStart and while uploading. */
551
+ file: string | null;
552
+ } | null = null;
358
553
  /** Where the JPEG lives right now: the destination the last cover used,
359
554
  * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
360
555
  * recorded destination that was never rendered is a real state (the panel
@@ -409,6 +604,33 @@ export async function startEditServer(
409
604
  // panel round-trips through youtube-pack-approved.json, which produce's Y2
410
605
  // block honors VERBATIM on every replay — an edit persisted there survives
411
606
  // into future renders with zero new plumbing.
607
+ /**
608
+ * The publish config, resolved FRESH per request — both halves of it.
609
+ *
610
+ * `loadConfig()` already re-read `config.json` every time, but the API key
611
+ * came from the process env, which is populated once at CLI startup: a user
612
+ * who set up Postiz while the editor was open got "not configured" until a
613
+ * restart, with `postizUrl` live and the key stale (2026-08-27). Re-running
614
+ * `loadEnvFiles` costs two small file reads on a button press and keeps the
615
+ * documented precedence exactly — it never clobbers a key the real
616
+ * environment already set, so a shell-provided key still wins.
617
+ *
618
+ * Skipped entirely when the caller injected `publishEnv` (tests own their
619
+ * environment, and reading the developer's real `~/.ossclip/.env` into a
620
+ * test would make the suite depend on the machine it runs on).
621
+ */
622
+ const resolvePublishConfig = (): ReturnType<typeof publishConfigured> => {
623
+ if (opts.publishEnv === undefined) loadEnvFiles();
624
+ return publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
625
+ };
626
+ /** ffmpeg/ffprobe for the publish endpoints' probe + delivery encode —
627
+ * from the same config read the rest of the panel resolves through. The
628
+ * `?? "ffmpeg"` legs exist only for the narrowed `loadCfg` seam; the real
629
+ * `loadConfig()` always fills both. */
630
+ const publishTools = (): { ffmpegPath: string; ffprobePath: string } => {
631
+ const cfg = (opts.loadCfg ?? loadConfig)();
632
+ return { ffmpegPath: cfg.ffmpegPath ?? "ffmpeg", ffprobePath: cfg.ffprobePath ?? "ffprobe" };
633
+ };
412
634
  const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
413
635
  /** The pack the panel shows: the approved file first (the user's
414
636
  * decision), else the newest valid `youtube-<key>.json` cache (what the
@@ -555,6 +777,157 @@ export async function startEditServer(
555
777
  });
556
778
  }
557
779
 
780
+ if (url.pathname === "/api/transcript" && req.method === "GET") {
781
+ // The FULL transcript, source-timed words included (cut-review
782
+ // rework follow-up): the editor rebuilds the caption track over
783
+ // REVIVED material with produce's own `buildCaptionLines`, and
784
+ // render-props' captionLines only cover what the last render kept
785
+ // — the revived words exist nowhere else client-side. Lenient like
786
+ // /api/cleanup: a missing or corrupt transcript.json degrades to
787
+ // null (captions over revived material stay absent, never a 500).
788
+ if (!workdir) return send(409, { error: "no workdir open" });
789
+ try {
790
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
791
+ language?: unknown;
792
+ words?: unknown;
793
+ };
794
+ if (!Array.isArray(raw.words)) return send(200, { transcript: null });
795
+ return send(200, { transcript: raw });
796
+ } catch {
797
+ return send(200, { transcript: null });
798
+ }
799
+ }
800
+
801
+ if (url.pathname === "/api/retranscribe-range" && req.method === "POST") {
802
+ // Re-decode ONE source span and re-stamp the words already there
803
+ // (Phase A, 2026-08-26): inside a kept retake whisper mis-POSITIONS
804
+ // words — the caption says "has its" while the audio says "could
805
+ // read 50 files" — because the first decode ran over material the
806
+ // cut had removed. See restamp.ts for why the splice is
807
+ // stamps-only.
808
+ if (!workdir) return send(409, { error: "no workdir open" });
809
+ if (retranscribeBusy) {
810
+ return send(409, { error: "a re-transcription is already running" });
811
+ }
812
+ const chunks: Buffer[] = [];
813
+ for await (const c of req) chunks.push(c as Buffer);
814
+ // TWO steerable values, both times, and nothing else — the cover
815
+ // regenerate rule: unknown keys are stripped by the parse, and an
816
+ // ordered non-negative pair is checked HERE rather than trusted,
817
+ // since an inverted range would slice a negative duration out of
818
+ // ffmpeg (CLAUDE.md's parse-never-coerce).
819
+ const parsed = z
820
+ .object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() })
821
+ .refine((v) => v.srcOut > v.srcIn, { message: "srcOut must be after srcIn" })
822
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
823
+ if (!parsed.success) return send(400, { error: parsed.error.message });
824
+ const { srcIn, srcOut } = parsed.data;
825
+ // Every path server-derived, never from the body — the regenerate
826
+ // endpoint's stance. `whisper-range` is a distinct outBase from
827
+ // produce's `whisper`, so a range decode can never overwrite the
828
+ // artefact of the full one.
829
+ const dir = workdir;
830
+ const tmpWav = join(dir, "whisper-range.wav");
831
+ const outBase = join(dir, "whisper-range");
832
+ retranscribeBusy = true;
833
+ try {
834
+ const audio = join(dir, "audio.wav");
835
+ if (!existsSync(audio)) {
836
+ return send(200, {
837
+ ok: false,
838
+ error: "this workdir has no audio.wav to re-decode — re-run `ossclip produce`.",
839
+ });
840
+ }
841
+ const transcriptPath = join(dir, "transcript.json");
842
+ if (!existsSync(transcriptPath)) {
843
+ return send(200, {
844
+ ok: false,
845
+ error: "this workdir has no transcript.json to re-stamp — re-run `ossclip produce`.",
846
+ });
847
+ }
848
+ const settings = retranscribeSettings((opts.loadCfg ?? loadConfig)());
849
+ if ("error" in settings) return send(200, { ok: false, error: settings.error });
850
+ if (!existsSync(settings.modelPath)) {
851
+ // The `--transcript`-only install: whisper was never needed to
852
+ // make this project, so say what to run rather than 500ing.
853
+ return send(200, {
854
+ ok: false,
855
+ error:
856
+ `whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
857
+ `to download it.`,
858
+ });
859
+ }
860
+ // Parsed, not cast: this file is about to be rewritten, and a
861
+ // truncated one must fail loudly here rather than become the new
862
+ // transcript.
863
+ const transcript = TranscriptSchema.parse(
864
+ JSON.parse(await readFile(transcriptPath, "utf8")),
865
+ );
866
+ const range = wordsInSpan(transcript.words, srcIn, srcOut);
867
+ if (range.to === range.from) {
868
+ // Silence, or a range whose words all straddle its edges.
869
+ // Nothing to re-stamp is a SUCCESS with an empty mapping — the
870
+ // editor's no-op — not a failure the user has to read.
871
+ return send(200, {
872
+ ok: true,
873
+ mapping: [],
874
+ reports: ["no transcript words lie wholly inside that range"],
875
+ });
876
+ }
877
+ await (opts.sliceAudio ?? extractAudioSpan)(
878
+ settings.tools,
879
+ audio,
880
+ tmpWav,
881
+ srcIn,
882
+ srcOut - srcIn,
883
+ );
884
+ const fresh = await (opts.runWhisper ?? runWhisper)(
885
+ {
886
+ whisperPath: settings.whisperPath,
887
+ modelPath: settings.modelPath,
888
+ outBase,
889
+ ...(settings.language !== undefined ? { language: settings.language } : {}),
890
+ ...(settings.prompt !== undefined ? { prompt: settings.prompt } : {}),
891
+ },
892
+ tmpWav,
893
+ );
894
+ const restamped = alignRestamp(
895
+ transcript.words.slice(range.from, range.to),
896
+ fresh.words,
897
+ srcIn,
898
+ );
899
+ const next = spliceTranscript(transcript, range, restamped.words);
900
+ // Atomic, like the overrides write: produce may read this file at
901
+ // any moment (its cache-reuse branch reads it verbatim and writes
902
+ // back what it read), and a half-written transcript is worse than
903
+ // a stale one.
904
+ const tmp = `${transcriptPath}.tmp`;
905
+ await writeFile(tmp, JSON.stringify(next, null, 2));
906
+ await rename(tmp, transcriptPath);
907
+ // `overrides.json` IS NOT TOUCHED HERE, on purpose: the doc is
908
+ // client-owned — the editor holds unsaved edits and an undo stack
909
+ // over it — so the caption RE-KEY rides back as `mapping` and is
910
+ // applied by the `useEdits` reducer, one commit, one undo step.
911
+ return send(200, { ok: true, mapping: restamped.mapping, reports: restamped.reports });
912
+ } catch (err) {
913
+ // 200 {ok:false}, the cover regenerate posture: a missing whisper
914
+ // binary or a failed decode is a sentence the panel shows, not a
915
+ // dead 500 — and the editor keeps working on the old stamps.
916
+ const message = err instanceof Error ? err.message : String(err);
917
+ return send(200, {
918
+ ok: false,
919
+ error: `${message} — run \`ossclip doctor\` if ffmpeg or whisper is the problem.`,
920
+ });
921
+ } finally {
922
+ retranscribeBusy = false;
923
+ // Both artefacts are per-request scratch. `.catch()` because a
924
+ // decode that never got as far as writing one must not turn a
925
+ // reported failure into an unhandled rejection.
926
+ await unlink(tmpWav).catch(() => {});
927
+ await unlink(`${outBase}.json`).catch(() => {});
928
+ }
929
+ }
930
+
558
931
  if (url.pathname === "/api/cleanup" && req.method === "GET") {
559
932
  // The labeled removals: since cut review step 3 this serves the
560
933
  // PROPOSAL (`cutlistProposed` — the automatic cutlist before the
@@ -697,13 +1070,16 @@ export async function startEditServer(
697
1070
  const chunks: Buffer[] = [];
698
1071
  for await (const c of req) chunks.push(c as Buffer);
699
1072
  let customOut: string | undefined;
1073
+ let replan = false;
700
1074
  try {
701
1075
  const raw = Buffer.concat(chunks).toString();
702
1076
  if (raw.trim()) {
703
- const body = JSON.parse(raw) as { out?: string };
1077
+ const body = JSON.parse(raw) as { out?: string; replan?: boolean };
704
1078
  if (typeof body.out === "string" && body.out.trim()) {
705
1079
  customOut = body.out.trim();
706
1080
  }
1081
+ // Opt back into a fresh LLM plan (renderReplayArgs' why).
1082
+ replan = body.replan === true;
707
1083
  }
708
1084
  } catch {
709
1085
  // ignore
@@ -758,6 +1134,30 @@ export async function startEditServer(
758
1134
  filteredArgs.push("--out", customOut);
759
1135
  args = filteredArgs;
760
1136
  }
1137
+ // THE EDITOR IS THE AUTHORITY for a render started here: pin the
1138
+ // plan the user just reviewed (`production.json`'s scenes) instead
1139
+ // of letting `--produce` plan a fresh one that renumbers scenes and
1140
+ // orphans their edits (renderReplayArgs owns the full why). Written
1141
+ // to its own file rather than reusing `scenes-<key>.json`, which is
1142
+ // keyed to a beat sheet this render may no longer match.
1143
+ let scenesPath: string | undefined;
1144
+ if (!replan) {
1145
+ try {
1146
+ const production = JSON.parse(
1147
+ await readFile(join(workdir!, "production.json"), "utf8"),
1148
+ ) as { scenes?: unknown };
1149
+ if (Array.isArray(production.scenes) && production.scenes.length > 0) {
1150
+ scenesPath = join(workdir!, REVIEWED_SCENES_BASENAME);
1151
+ await writeFile(scenesPath, `${JSON.stringify(production.scenes, null, 2)}\n`);
1152
+ }
1153
+ } catch {
1154
+ // No production.json, an old one without `scenes`, or an
1155
+ // unwritable workdir: replay the recorded command rather than
1156
+ // refuse to render (renderReplayArgs' no-plan case).
1157
+ scenesPath = undefined;
1158
+ }
1159
+ }
1160
+ args = renderReplayArgs(args, { scenesPath, replan });
761
1161
  renderLines = [];
762
1162
  renderExit = null;
763
1163
  renderStartedAt = Date.now();
@@ -1185,6 +1585,619 @@ export async function startEditServer(
1185
1585
  return send(200, { ok: true, mdPath });
1186
1586
  }
1187
1587
 
1588
+ if (url.pathname === "/api/youtube/generate" && req.method === "POST") {
1589
+ // Generate the pack ON DEMAND (2026-08-29): a render produced
1590
+ // without --youtube has no caption pack, and the publish modal
1591
+ // dead-ended on "run produce with --youtube" — a full re-produce
1592
+ // just to buy one LLM call. This writes the same CACHE file
1593
+ // produce's Y2 block would have, never the approved file: approval
1594
+ // stays the user's explicit act (PUT /api/youtube), and the user
1595
+ // still reviews the captions before anything sends.
1596
+ if (!workdir) return send(409, { error: "no workdir open" });
1597
+ // An LLM call costs money — one at a time, a second is a 409 like
1598
+ // a second caption rewrite.
1599
+ if (packGenBusy) {
1600
+ return send(409, { error: "a caption-pack generation is already running" });
1601
+ }
1602
+ // Lenient transcript read but a 412 when it yields nothing — the
1603
+ // /api/publish/regenerate posture, same reason: a pack with no
1604
+ // transcript would be invented metadata. The words joined raw is
1605
+ // the honest input the server has (regenerate made the same call);
1606
+ // produce's stamped transcript needs the cut map, which only a
1607
+ // produce run holds — the prompt tolerates plain text, the model
1608
+ // just gets no measured chapters worth trusting.
1609
+ let words: Array<{ text: string }> = [];
1610
+ let lastWordEnd: number | null = null;
1611
+ try {
1612
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
1613
+ words?: unknown;
1614
+ };
1615
+ if (Array.isArray(raw.words)) {
1616
+ words = raw.words.filter(
1617
+ (w): w is { text: string } =>
1618
+ typeof (w as { text?: unknown } | null)?.text === "string",
1619
+ );
1620
+ for (const w of raw.words) {
1621
+ const end = (w as { end?: unknown } | null)?.end;
1622
+ if (typeof end === "number" && Number.isFinite(end)) {
1623
+ lastWordEnd = Math.max(lastWordEnd ?? 0, end);
1624
+ }
1625
+ }
1626
+ }
1627
+ } catch {
1628
+ // absent/corrupt → the 412 below
1629
+ }
1630
+ if (words.length === 0) {
1631
+ return send(412, {
1632
+ error:
1633
+ "no transcript in this workdir — the pack would have nothing to ground " +
1634
+ "against; re-run `ossclip produce`.",
1635
+ });
1636
+ }
1637
+ // The prompt's runtime ceiling: the output duration produce
1638
+ // measured (render-props.json), else the transcript's last word
1639
+ // end — source-clock, but the honest number available, and a
1640
+ // raw-text transcript gets no measured chapters anyway.
1641
+ let durationSec: number | null = null;
1642
+ try {
1643
+ const props = JSON.parse(await readFile(propsPath(), "utf8")) as {
1644
+ outputDurationSec?: unknown;
1645
+ };
1646
+ if (
1647
+ typeof props.outputDurationSec === "number" &&
1648
+ Number.isFinite(props.outputDurationSec) &&
1649
+ props.outputDurationSec > 0
1650
+ ) {
1651
+ durationSec = props.outputDurationSec;
1652
+ }
1653
+ } catch {
1654
+ // corrupt props — the transcript fallback below
1655
+ }
1656
+ if (durationSec === null && lastWordEnd !== null && lastWordEnd > 0) {
1657
+ durationSec = lastWordEnd;
1658
+ }
1659
+ if (durationSec === null) {
1660
+ return send(412, {
1661
+ error: "cannot determine the video's duration — re-run `ossclip produce`.",
1662
+ });
1663
+ }
1664
+ const cmd = await readCommandRecord();
1665
+ const cfg = (opts.loadCfg ?? loadConfig)();
1666
+ // The editorial steer produce would have used: the recorded flags
1667
+ // first, then produce's own defaults when a flag is absent —
1668
+ // intent has none, audience falls back to the config's `audience`
1669
+ // (produce.ts's typed read: typeof, never truthiness).
1670
+ const intent = lastFlagValue(cmd?.args ?? [], ["--intent"]);
1671
+ const audience =
1672
+ lastFlagValue(cmd?.args ?? [], ["--audience"]) ??
1673
+ (typeof cfg.audience === "string" ? cfg.audience : undefined);
1674
+ // hook/coverText come from the beat sheet in produce; a produce
1675
+ // run that planned left its sheet cached in the workdir. The
1676
+ // shipped cover headline (cover.json, possibly the user's own
1677
+ // words) backstops coverText. All optional — buildYoutubePrompt
1678
+ // omits absent lines, exactly as a no-beat-sheet produce does.
1679
+ let hook: string | undefined;
1680
+ let coverText: string | undefined;
1681
+ const beatCache = await newestWorkdirFile(
1682
+ (n) => n.startsWith("beatsheet-") && n.endsWith(".json"),
1683
+ );
1684
+ if (beatCache !== null) {
1685
+ try {
1686
+ const sheet = JSON.parse(await readFile(beatCache, "utf8")) as {
1687
+ hook?: unknown;
1688
+ coverText?: unknown;
1689
+ };
1690
+ if (typeof sheet.hook === "string" && sheet.hook.trim().length > 0) {
1691
+ hook = sheet.hook;
1692
+ }
1693
+ if (typeof sheet.coverText === "string" && sheet.coverText.trim().length > 0) {
1694
+ coverText = sheet.coverText;
1695
+ }
1696
+ } catch {
1697
+ // a corrupt cache loses a steer line, never the generation
1698
+ }
1699
+ }
1700
+ if (coverText === undefined) {
1701
+ const provenance = await readCoverProvenance(workdir);
1702
+ if (provenance !== null && provenance.text.trim().length > 0) {
1703
+ coverText = provenance.text;
1704
+ }
1705
+ }
1706
+ // Env fresh per press, then the caption regenerate's provider
1707
+ // resolution verbatim — the same "which LLM does this project
1708
+ // use" question with the same three-rung answer.
1709
+ if (opts.publishEnv === undefined) loadEnvFiles();
1710
+ const env = opts.publishEnv ?? process.env;
1711
+ const usagePath = join(workdir, "usage.json");
1712
+ let usageLog: unknown = null;
1713
+ try {
1714
+ usageLog = JSON.parse(await readFile(usagePath, "utf8"));
1715
+ } catch {
1716
+ // no log yet — the resolution falls through to the pin/detection
1717
+ }
1718
+ const resolved = captionRegenProvider({
1719
+ usageLog,
1720
+ commandArgs: cmd?.args ?? null,
1721
+ env,
1722
+ hasBin: (bin) => binOnPath(bin, env),
1723
+ });
1724
+ if (resolved.status === "unavailable") {
1725
+ return send(412, { error: resolved.reason });
1726
+ }
1727
+ const provider = (opts.makeLlmProvider ?? createProvider)(resolved.provider);
1728
+ packGenBusy = true;
1729
+ try {
1730
+ let pack: YoutubePack;
1731
+ try {
1732
+ pack = await generateYoutubePack(provider, {
1733
+ transcriptText: words.map((w) => w.text).join(" "),
1734
+ ...(intent !== undefined ? { intent } : {}),
1735
+ ...(hook !== undefined ? { hook } : {}),
1736
+ ...(coverText !== undefined ? { coverText } : {}),
1737
+ ...(audience !== undefined ? { audience } : {}),
1738
+ durationSec,
1739
+ });
1740
+ } catch (err) {
1741
+ // 200 with ok:false, the caption regenerate's posture: a
1742
+ // provider failure is a sentence the panel shows VERBATIM,
1743
+ // never a dead 500. Nothing is cached (§106).
1744
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1745
+ }
1746
+ const pricing = cfg.pricing ?? {};
1747
+ // The spend is real, so it survives the session — the caption
1748
+ // regenerate's append, atomically.
1749
+ const nextLog = appendUsageRun(
1750
+ usageLog,
1751
+ { at: new Date().toISOString(), records: provider.usage },
1752
+ pricing,
1753
+ );
1754
+ const usageTmp = `${usagePath}.tmp`;
1755
+ await writeFile(usageTmp, JSON.stringify(nextLog, null, 2));
1756
+ await rename(usageTmp, usagePath);
1757
+ // A `youtube-<key>.json` cache, keyed on the provider asked like
1758
+ // produce's Y2 write — deliberately NOT produce's exact key: that
1759
+ // key hashes the cut map's spans and the stamped transcript,
1760
+ // which only a produce run holds, and matching it would falsely
1761
+ // claim cache identity with an answer built from different
1762
+ // inputs. mtime is what makes this pack current: both
1763
+ // currentYoutubePack and loadPublishPack sort caches newest
1764
+ // first. Atomic like the approved-pack write.
1765
+ const key = createHash("sha1")
1766
+ .update(
1767
+ JSON.stringify([
1768
+ YOUTUBE_PROMPT_VERSION,
1769
+ resolved.provider,
1770
+ intent ?? "",
1771
+ audience ?? "",
1772
+ words.map((w) => w.text),
1773
+ ]),
1774
+ )
1775
+ .digest("hex")
1776
+ .slice(0, 8);
1777
+ const packPath = join(workdir, `youtube-${key}.json`);
1778
+ const packTmp = `${packPath}.tmp`;
1779
+ await writeFile(packTmp, JSON.stringify(pack, null, 2));
1780
+ await rename(packTmp, packPath);
1781
+ return send(200, { ok: true, usage: formatUsageLine(provider.usage, pricing) });
1782
+ } finally {
1783
+ packGenBusy = false;
1784
+ }
1785
+ }
1786
+
1787
+ // ---- Publish (2026-08-26) -----------------------------------------
1788
+ // The server's FIRST outbound-network endpoints, against the
1789
+ // roadmap's "replay-only by deliberate design" posture — allowed
1790
+ // because all three gates hold: they exist behaviorally only when the
1791
+ // user configured their own Postiz instance (unconfigured → a hint,
1792
+ // never a control), they fire only on an explicit button press, and
1793
+ // the API key never reaches the browser — the server (already running
1794
+ // in the user's shell env) does the upload.
1795
+ if (url.pathname === "/api/publish" && req.method === "GET") {
1796
+ if (!workdir) return send(409, { error: "no workdir open" });
1797
+ const configured = resolvePublishConfig();
1798
+ if (!configured.ok) {
1799
+ return send(200, { configured: false, reason: configured.message });
1800
+ }
1801
+ const cmd = await readCommandRecord();
1802
+ const out = cmd ? recordedOutPath(cmd) : null;
1803
+ const pack = await currentYoutubePack();
1804
+ // Pre-flight duration for the panel, read leniently (the
1805
+ // /api/cleanup posture): a missing ffprobe or an unreadable render
1806
+ // degrades to null — the panel loses the gray-out, never the modal —
1807
+ // and POST re-checks the caps authoritatively anyway.
1808
+ let durationSec: number | null = null;
1809
+ if (out !== null && existsSync(out)) {
1810
+ try {
1811
+ durationSec = (await (opts.probeVideo ?? probe)(publishTools(), out)).duration;
1812
+ } catch {
1813
+ // degrade — the caps still apply server-side on POST
1814
+ }
1815
+ }
1816
+ let integrations: Array<{
1817
+ id: string;
1818
+ provider: string;
1819
+ name: string;
1820
+ caption: string;
1821
+ durationCapSec: number | null;
1822
+ sizeCapBytes: number | null;
1823
+ }> = [];
1824
+ try {
1825
+ const provider = createPostizProvider({
1826
+ baseUrl: configured.baseUrl,
1827
+ apiKey: configured.apiKey,
1828
+ fetchImpl: opts.publishFetch,
1829
+ });
1830
+ const targets = await provider.listTargets();
1831
+ integrations = targets.map((t) => ({
1832
+ ...t,
1833
+ // The caption the publish WOULD use — authored-else-derived —
1834
+ // so the panel previews truth, not a guess of it.
1835
+ caption: pack !== null ? captionForProvider(pack, t.provider) : "",
1836
+ // Null = no cap (limits.ts: absence means unlimited) — the
1837
+ // panel grays out a channel only against a cap that exists.
1838
+ durationCapSec: PLATFORM_DURATION_CAPS_SEC[t.provider] ?? null,
1839
+ // The platform's upload byte ceiling, same posture: null means
1840
+ // uncapped, a number means this channel gets its own smaller
1841
+ // delivery encode (limits.ts: Instagram's ~100MB URL-fetch cap).
1842
+ sizeCapBytes: PLATFORM_SIZE_CAP_BYTES[t.provider] ?? null,
1843
+ }));
1844
+ } catch (err) {
1845
+ return send(200, {
1846
+ configured: true,
1847
+ reachable: false,
1848
+ reason: err instanceof Error ? err.message : String(err),
1849
+ });
1850
+ }
1851
+ return send(200, {
1852
+ configured: true,
1853
+ reachable: true,
1854
+ integrations,
1855
+ packAvailable: pack !== null,
1856
+ outPathExists: out !== null && existsSync(out),
1857
+ durationSec,
1858
+ receipt: await readPublishReceipt(workdir),
1859
+ });
1860
+ }
1861
+
1862
+ if (url.pathname === "/api/publish" && req.method === "POST") {
1863
+ if (!workdir) return send(409, { error: "no workdir open" });
1864
+ const configured = resolvePublishConfig();
1865
+ if (!configured.ok) return send(412, { error: configured.message });
1866
+ const chunks: Buffer[] = [];
1867
+ for await (const c of req) chunks.push(c as Buffer);
1868
+ const parsed = z
1869
+ .object({
1870
+ integrationIds: z.array(z.string()).min(1),
1871
+ // ISO-8601, validated as a real FUTURE instant below — zod can
1872
+ // say "string", only a clock can say "future".
1873
+ at: z.string().optional(),
1874
+ // Per-integration caption overrides typed in the panel; absent
1875
+ // ids fall back to the pack's authored-else-derived caption.
1876
+ captions: z.record(z.string(), z.string()).optional(),
1877
+ force: z.boolean().optional(),
1878
+ // What uploads (the CLI's --delivery): auto (default) builds
1879
+ // the cached delivery encode, master sends the untouched render.
1880
+ delivery: z.enum(["auto", "master"]).optional(),
1881
+ })
1882
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1883
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1884
+ let when: { kind: "now" } | { kind: "at"; iso: string } = { kind: "now" };
1885
+ if (parsed.data.at !== undefined) {
1886
+ const ms = Date.parse(parsed.data.at);
1887
+ if (Number.isNaN(ms)) return send(400, { error: `not an ISO-8601 time: "${parsed.data.at}"` });
1888
+ if (ms <= Date.now()) return send(400, { error: `schedule time already passed: "${parsed.data.at}"` });
1889
+ when = { kind: "at", iso: new Date(ms).toISOString() };
1890
+ }
1891
+ const cmd = await readCommandRecord();
1892
+ const out = cmd ? recordedOutPath(cmd) : null;
1893
+ if (out === null || !existsSync(out)) {
1894
+ return send(412, { error: "no finished render to publish — render first" });
1895
+ }
1896
+ const pack = await currentYoutubePack();
1897
+ if (pack === null) {
1898
+ return send(412, { error: "no YouTube pack — approve one in the SEO panel first" });
1899
+ }
1900
+ const receipt = await readPublishReceipt(workdir);
1901
+ if (receipt !== null && parsed.data.force !== true) {
1902
+ return send(412, {
1903
+ error: `already published on ${receipt.publishedAt} — pass force to publish again`,
1904
+ receipt,
1905
+ });
1906
+ }
1907
+ try {
1908
+ const provider = createPostizProvider({
1909
+ baseUrl: configured.baseUrl,
1910
+ apiKey: configured.apiKey,
1911
+ fetchImpl: opts.publishFetch,
1912
+ });
1913
+ const targets = await provider.listTargets();
1914
+ let picked = parsed.data.integrationIds.map((id) => {
1915
+ const hit = targets.find((t) => t.id === id);
1916
+ if (!hit) throw new Error(`no integration with id "${id}" in Postiz`);
1917
+ return hit;
1918
+ });
1919
+ // Same core helpers, same semantics as the CLI (checkDurationCaps
1920
+ // + ensureDeliveryFile — one spelling of the rules): drop the
1921
+ // channels this video can never land on, publish the rest, and
1922
+ // only when EVERY pick is over its cap refuse the whole request.
1923
+ const tools = publishTools();
1924
+ const masterProbe = await (opts.probeVideo ?? probe)(tools, out);
1925
+ const violations = checkDurationCaps(picked, masterProbe.duration);
1926
+ // Duration entries keep their original shape; size entries carry
1927
+ // a `reason` and the cap that doomed them — additive fields only,
1928
+ // so an older panel reading `dropped` keeps working.
1929
+ const dropped: Array<Record<string, unknown>> = violations.map((v) => ({
1930
+ id: v.target.id,
1931
+ provider: v.target.provider,
1932
+ name: v.target.name,
1933
+ capSec: v.capSec,
1934
+ }));
1935
+ if (violations.length > 0) {
1936
+ const over = new Set(violations.map((v) => v.target.id));
1937
+ picked = picked.filter((t) => !over.has(t.id));
1938
+ if (picked.length === 0) {
1939
+ return send(412, {
1940
+ error:
1941
+ `every picked channel refuses a ${Math.round(masterProbe.duration)}s video — ` +
1942
+ "nothing to publish",
1943
+ dropped,
1944
+ });
1945
+ }
1946
+ }
1947
+ // Size-cap pre-check with the pure plan (the CLI's semantics,
1948
+ // one spelling): an unattainable cap drops the channel BEFORE
1949
+ // any encode — ensureDeliveryFile THROWS on unattainable, and a
1950
+ // 502 with ffmpeg arithmetic in it is not a drop-and-continue.
1951
+ const masterSizeBytes = (await stat(out)).size;
1952
+ const src = {
1953
+ width: masterProbe.width,
1954
+ height: masterProbe.height,
1955
+ fps: masterProbe.fps,
1956
+ duration: masterProbe.duration,
1957
+ sizeBytes: masterSizeBytes,
1958
+ };
1959
+ let capGroups = sizeCapGroups(picked);
1960
+ if (parsed.data.delivery !== "master") {
1961
+ for (const [capBytes, group] of capGroups) {
1962
+ const capped = deliveryEncodePlan(src, { sizeCapBytes: capBytes });
1963
+ if (capped !== null && "unattainable" in capped) {
1964
+ for (const t of group) {
1965
+ dropped.push({
1966
+ id: t.id,
1967
+ provider: t.provider,
1968
+ name: t.name,
1969
+ sizeCapBytes: capBytes,
1970
+ reason: "size",
1971
+ });
1972
+ }
1973
+ const over = new Set(group.map((t) => t.id));
1974
+ picked = picked.filter((t) => !over.has(t.id));
1975
+ }
1976
+ }
1977
+ if (picked.length === 0) {
1978
+ return send(412, {
1979
+ error:
1980
+ `every picked channel's size cap is unattainable for a ` +
1981
+ `${Math.round(masterProbe.duration)}s video — nothing to publish`,
1982
+ dropped,
1983
+ });
1984
+ }
1985
+ capGroups = sizeCapGroups(picked);
1986
+ }
1987
+ const posts = buildPublishPosts(pack, picked).map((p) => ({
1988
+ ...p,
1989
+ caption: parsed.data.captions?.[p.target.id] ?? p.caption,
1990
+ }));
1991
+ // Synchronous encodes (~1–3 min each, fetch won't time out) — a
1992
+ // job/poll model for the WORK is a noted follow-up, but the
1993
+ // PROGRESS is polled: /api/publish/progress reads the state the
1994
+ // onProgress callback below keeps current. Sequential per cap
1995
+ // group (two ffmpegs racing for cores would slow both), so pct
1996
+ // simply runs 0→100 once per file and `file` names which one.
1997
+ let uploadPath = out;
1998
+ const cappedPaths = new Map<number, string>();
1999
+ if (parsed.data.delivery !== "master") {
2000
+ // `workdir` is a mutable binding, so its non-null narrowing
2001
+ // from the top of the handler doesn't survive into a closure —
2002
+ // capture it while narrowed.
2003
+ const wd = workdir;
2004
+ const runEnsure = async (
2005
+ sizeCapBytes?: number,
2006
+ ): ReturnType<typeof ensureDeliveryFile> => {
2007
+ let currentFile: string | null = null;
2008
+ publishProgress = { phase: "encoding", pct: 0, etaSec: null, speed: null, file: null };
2009
+ return (opts.ensureDelivery ?? ensureDeliveryFile)(tools, wd, out, {
2010
+ ...(sizeCapBytes !== undefined ? { sizeCapBytes } : {}),
2011
+ onStart: (name) => {
2012
+ currentFile = name;
2013
+ publishProgress = { phase: "encoding", pct: 0, etaSec: null, speed: null, file: name };
2014
+ },
2015
+ onProgress: (p) => {
2016
+ publishProgress = {
2017
+ phase: "encoding",
2018
+ // Percent against the MASTER's duration — the delivery
2019
+ // encode preserves it, so out_time maps 1:1.
2020
+ pct:
2021
+ p.outTimeSec !== undefined && masterProbe.duration > 0
2022
+ ? Math.min(
2023
+ 100,
2024
+ Math.round((p.outTimeSec / masterProbe.duration) * 100),
2025
+ )
2026
+ : null,
2027
+ etaSec:
2028
+ p.outTimeSec !== undefined && p.speed !== undefined
2029
+ ? encodeEta(masterProbe.duration, p.outTimeSec, p.speed)
2030
+ : null,
2031
+ speed: p.speed ?? null,
2032
+ file: currentFile,
2033
+ };
2034
+ },
2035
+ });
2036
+ };
2037
+ uploadPath = (await runEnsure()).path;
2038
+ // One encode per DISTINCT cap — the bitrate-bearing filename is
2039
+ // the cache key, so a capped plan that lands on the default
2040
+ // file's name cache-hits instead of re-encoding.
2041
+ for (const capBytes of capGroups.keys()) {
2042
+ cappedPaths.set(capBytes, (await runEnsure(capBytes)).path);
2043
+ }
2044
+ }
2045
+ // No upload ETA — Postiz's multipart upload gives us nothing to
2046
+ // measure, so the phase alone is the signal.
2047
+ publishProgress = { phase: "uploading", pct: null, etaSec: null, speed: null, file: null };
2048
+ const result = await provider.publish({
2049
+ videoPath: uploadPath,
2050
+ posts: attachDeliveryMedia(posts, uploadPath, cappedPaths),
2051
+ when,
2052
+ });
2053
+ await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
2054
+ return send(200, { ok: true, receipt: result, dropped });
2055
+ } catch (err) {
2056
+ // Fail loud, verbatim — Postiz's own validation message is the
2057
+ // most specific thing anyone has (per-provider settings are its
2058
+ // domain, not ours).
2059
+ return send(502, { error: err instanceof Error ? err.message : String(err) });
2060
+ } finally {
2061
+ publishProgress = null;
2062
+ }
2063
+ }
2064
+
2065
+ if (url.pathname === "/api/publish/progress" && req.method === "GET") {
2066
+ // Where the in-flight publish is — 200 always, `progress: null`
2067
+ // when idle (a cache hit or a skip-plan publish never enters the
2068
+ // encoding phase, and the panel keeps its static line for that).
2069
+ return send(200, { progress: publishProgress });
2070
+ }
2071
+
2072
+ if (url.pathname === "/api/publish/regenerate" && req.method === "POST") {
2073
+ // Rewrite ONE network's caption with the LLM the produce run used
2074
+ // (handoff 2026-08-29 item 4): the prompt carries the transcript,
2075
+ // the caption AS THE PANEL HOLDS IT and the user's correction, and
2076
+ // the replacement text rides back into the panel's box. Nothing
2077
+ // auto-sends and nothing writes to the pack — the user still
2078
+ // reviews, edits and presses Publish.
2079
+ if (!workdir) return send(409, { error: "no workdir open" });
2080
+ // An LLM call costs money — one at a time, a second is a 409 like
2081
+ // a second thumbnail.
2082
+ if (captionRegenBusy) {
2083
+ return send(409, { error: "a caption regeneration is already running" });
2084
+ }
2085
+ const chunks: Buffer[] = [];
2086
+ for await (const c of req) chunks.push(c as Buffer);
2087
+ // `currentCaption` is deliberately the client's text — the ONE
2088
+ // body-steered value beyond the ask itself, because the box may
2089
+ // hold the user's manual edits and the model must see what the
2090
+ // user sees. It steers only prompt text, never a path or a spawn.
2091
+ const parsed = z
2092
+ .object({
2093
+ network: z.string().min(1),
2094
+ instruction: z.string().min(1),
2095
+ currentCaption: z.string(),
2096
+ })
2097
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
2098
+ if (!parsed.success) return send(400, { error: parsed.error.message });
2099
+ // Lenient transcript read (the GET /api/transcript posture), but a
2100
+ // 412 when it yields nothing: a rewrite with no transcript would be
2101
+ // a rewrite with no factual evidence, which is the exact failure
2102
+ // this endpoint exists to repair.
2103
+ let transcript: { language: string; words: Array<{ text: string }> } | null = null;
2104
+ try {
2105
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
2106
+ language?: unknown;
2107
+ words?: unknown;
2108
+ };
2109
+ if (Array.isArray(raw.words)) {
2110
+ transcript = {
2111
+ language: typeof raw.language === "string" ? raw.language : "en",
2112
+ words: raw.words.filter(
2113
+ (w): w is { text: string } =>
2114
+ typeof (w as { text?: unknown } | null)?.text === "string",
2115
+ ),
2116
+ };
2117
+ }
2118
+ } catch {
2119
+ // absent/corrupt → the 412 below
2120
+ }
2121
+ if (transcript === null || transcript.words.length === 0) {
2122
+ return send(412, {
2123
+ error:
2124
+ "no transcript in this workdir — the rewrite would have nothing to " +
2125
+ "ground against; re-run `ossclip produce`.",
2126
+ });
2127
+ }
2128
+ // Env fresh per press, resolvePublishConfig's rule: a key set after
2129
+ // startup must count. Skipped when tests own the environment.
2130
+ if (opts.publishEnv === undefined) loadEnvFiles();
2131
+ const env = opts.publishEnv ?? process.env;
2132
+ const usagePath = join(workdir, "usage.json");
2133
+ let usageLog: unknown = null;
2134
+ try {
2135
+ usageLog = JSON.parse(await readFile(usagePath, "utf8"));
2136
+ } catch {
2137
+ // no log yet — the resolution falls through to the pin/detection
2138
+ }
2139
+ const cmd = await readCommandRecord();
2140
+ const resolved = captionRegenProvider({
2141
+ usageLog,
2142
+ commandArgs: cmd?.args ?? null,
2143
+ env,
2144
+ hasBin: (bin) => binOnPath(bin, env),
2145
+ });
2146
+ if (resolved.status === "unavailable") {
2147
+ // Precondition, not a generation failure — 412 like a thumbnail
2148
+ // regenerate on an unavailable project.
2149
+ return send(412, { error: resolved.reason });
2150
+ }
2151
+ const provider = (opts.makeLlmProvider ?? createProvider)(resolved.provider);
2152
+ captionRegenBusy = true;
2153
+ try {
2154
+ let caption: string;
2155
+ try {
2156
+ caption = await generateCaptionRegen(provider, {
2157
+ network: parsed.data.network,
2158
+ currentCaption: parsed.data.currentCaption,
2159
+ instruction: parsed.data.instruction,
2160
+ transcriptText: transcript.words.map((w) => w.text).join(" "),
2161
+ charCap: captionCap(parsed.data.network),
2162
+ });
2163
+ } catch (err) {
2164
+ // 200 with ok:false, the thumbnail regenerate's posture: a
2165
+ // provider failure is a sentence the panel shows VERBATIM,
2166
+ // never a dead 500.
2167
+ return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
2168
+ }
2169
+ const pricing = (opts.loadCfg ?? loadConfig)().pricing ?? {};
2170
+ // The spend is real, so it survives the session: appended into
2171
+ // the workdir's usage log the way produce appends its runs.
2172
+ // Atomic like the overrides write — /api/usage may read it at
2173
+ // any moment.
2174
+ const nextLog = appendUsageRun(
2175
+ usageLog,
2176
+ { at: new Date().toISOString(), records: provider.usage },
2177
+ pricing,
2178
+ );
2179
+ const tmp = `${usagePath}.tmp`;
2180
+ await writeFile(tmp, JSON.stringify(nextLog, null, 2));
2181
+ await rename(tmp, usagePath);
2182
+ // Advisory ONLY, never a block: captions legitimately carry
2183
+ // brand and platform words the take never speaks. The `--speaker`
2184
+ // pin counts as spoken vocabulary for checkGrounding's §39
2185
+ // reason — the channel name is in nearly every caption.
2186
+ const speaker = lastFlagValue(cmd?.args ?? [], ["--speaker"]);
2187
+ const notes = [...new Set(ungroundedTokens(caption, transcript, speaker))].map(
2188
+ (token) => `⚠ grounding: "${token}" — not in the take`,
2189
+ );
2190
+ return send(200, {
2191
+ ok: true,
2192
+ caption,
2193
+ usage: formatUsageLine(provider.usage, pricing),
2194
+ notes,
2195
+ });
2196
+ } finally {
2197
+ captionRegenBusy = false;
2198
+ }
2199
+ }
2200
+
1188
2201
  if (url.pathname === "/api/cover" && req.method === "GET") {
1189
2202
  // The cover panel's one status call (2026-08-19): the provenance to
1190
2203
  // prefill and where the current image is. All reads — the panel