ossclip 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-C537KpNT.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DA7vDWn_.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-Bx2VQLP8.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.31",
40
- "@ossclip/renderer": "0.1.31",
41
- "@ossclip/scenes": "0.1.31"
39
+ "@ossclip/core": "0.1.33",
40
+ "@ossclip/renderer": "0.1.33",
41
+ "@ossclip/scenes": "0.1.33"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
@@ -3,6 +3,7 @@ import {
3
3
  applyCaptionRangeEdits,
4
4
  applyCaptionWordHides,
5
5
  applyCaptionLineTiming,
6
+ applyCaptionLineWindows,
6
7
  captionEditsToKeep,
7
8
  isLegacyCaptionKey,
8
9
  migrateCaptionKeys,
@@ -14,6 +15,7 @@ import type {
14
15
  CaptionKeyMigration,
15
16
  CaptionLine,
16
17
  OverrideDoc,
18
+ TimeMap,
17
19
  } from "@ossclip/core";
18
20
 
19
21
  /**
@@ -288,10 +290,17 @@ export interface CaptionReconciliation {
288
290
  * (`produce.ts` parses it at the top of the run). On raw `JSON.parse` output a
289
291
  * literal `"__proto__"` key would be assigned THROUGH rather than kept, and
290
292
  * the migration would report nothing lost while losing it.
293
+ *
294
+ * `map` is the run's own cutlist map — the one `baseLines` were built through
295
+ * — and exists for the WINDOW layer alone (`applyCaptionLineWindows`): windows
296
+ * are stored in source seconds and these lines speak output seconds. Taken as
297
+ * a parameter rather than rebuilt here for the usual reason: two maps is how
298
+ * this pass and the render would come to place a caption differently.
291
299
  */
292
300
  export function reconcileCaptionEdits(
293
301
  doc: OverrideDoc,
294
302
  baseLines: readonly CaptionLine[],
303
+ map: TimeMap,
295
304
  ): CaptionReconciliation {
296
305
  const log: string[] = [];
297
306
  const migration = migrateCaptionKeys(doc.captions, baseLines);
@@ -351,5 +360,21 @@ export function reconcileCaptionEdits(
351
360
  const nudged = appliedCaptionEditCount(doc.captionLineTiming, timed.dropped);
352
361
  if (nudged > 0) log.push(`▸ ${nudged} caption timing nudge(s) applied`);
353
362
  for (const d of timed.dropped) log.push(captionTimingDropLine(d));
354
- return { doc: migrated, lines: timed.lines, reanchored: reanchored > 0, log };
363
+ // WINDOWS last of all — `applyCaptionLayers`' one authoritative order: an
364
+ // absolute window is the user's final answer about when a caption is on
365
+ // screen, so it runs after (and overrides) the nudge layer. Counted with
366
+ // `appliedCaptionEditCount` for the same reason the nudges are: this layer
367
+ // also reports a `duplicate-anchor` drop per EXTRA claimant, so a plain
368
+ // subtraction would erase the line for a window that did apply. No key
369
+ // migration: `captionLineWindows` postdates §137, and the write-back above
370
+ // spreads it through untouched. Drops reuse `captionTimingDropLine` rather
371
+ // than earning a fifth sentence: both records answer "when is this caption
372
+ // on screen", both drop for the SAME two reasons (no line starts there any
373
+ // more / a second claimant), and the fix — re-make it in the timing tool —
374
+ // is one gesture, which is the test that function's docstring sets.
375
+ const windowed = applyCaptionLineWindows(timed.lines, doc.captionLineWindows, map);
376
+ const placed = appliedCaptionEditCount(doc.captionLineWindows, windowed.dropped);
377
+ if (placed > 0) log.push(`▸ ${placed} caption window(s) placed by the editor`);
378
+ for (const d of windowed.dropped) log.push(captionTimingDropLine(d));
379
+ return { doc: migrated, lines: windowed.lines, reanchored: reanchored > 0, log };
355
380
  }
package/src/edit.ts CHANGED
@@ -19,7 +19,11 @@ import {
19
19
  ThumbnailConceptSchema,
20
20
  approvedOverlayText,
21
21
  buildThumbnailPrompt,
22
+ captionForProvider,
23
+ createPostizProvider,
24
+ alignRestamp,
22
25
  emptyOverrideDoc,
26
+ extractAudioSpan,
23
27
  // Static import is fine here: the @google/genai SDK load is LAZY inside
24
28
  // this function (core's near-zero-dep rule), so the server pays for it
25
29
  // only when a regenerate actually runs.
@@ -30,7 +34,12 @@ import {
30
34
  PORTRAIT_MIME_TYPES,
31
35
  portraitMimeType,
32
36
  readCoverProvenance,
37
+ runWhisper,
33
38
  SegmentSchema,
39
+ spliceTranscript,
40
+ TranscriptSchema,
41
+ whisperPromptFor,
42
+ wordsInSpan,
34
43
  type Segment,
35
44
  thumbnailImageCacheName,
36
45
  type CoverProvenance,
@@ -60,6 +69,10 @@ import {
60
69
  type RecordedCommand,
61
70
  } from "./cover";
62
71
  import { expandHome } from "./paths";
72
+ // The model-path and implied-language rules, from THE source doctor, setup and
73
+ // produce all resolve through — a second copy here would send a user to a
74
+ // model file the rest of the tool never looks for.
75
+ import { modelImpliedLanguage, whisperModelPath } from "./setup/manifest";
63
76
  import {
64
77
  PORTRAIT_OVERRIDE_BASENAME,
65
78
  portraitExtensionForMime,
@@ -68,6 +81,7 @@ import {
68
81
  type ResolvedPortrait,
69
82
  } from "./portrait-override";
70
83
  import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
84
+ import { buildPublishPosts, publishConfigured, publishReceiptPath, readPublishReceipt } from "./publish";
71
85
 
72
86
  /**
73
87
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -94,6 +108,78 @@ export function resolveEditorPageDir(): string | null {
94
108
  return candidates[0] ?? null;
95
109
  }
96
110
 
111
+ /**
112
+ * The config keys `/api/retranscribe-range` needs. Spelled structurally
113
+ * rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
114
+ * stub that supplies only what a case is about — the same shape the youtube/
115
+ * portrait keys above it are declared with.
116
+ */
117
+ export interface RetranscribeConfig {
118
+ ffmpegPath?: string;
119
+ ffprobePath?: string;
120
+ whisperPath?: string;
121
+ model?: string;
122
+ modelDir?: string;
123
+ language?: unknown;
124
+ dictionary?: unknown;
125
+ }
126
+
127
+ /**
128
+ * How to spawn whisper for a range re-decode, or why we cannot.
129
+ *
130
+ * Pure — the `openCommand`/`openInBrowser` split — so the whole
131
+ * config × missing-binary × malformed-dictionary matrix is testable without
132
+ * whisper.cpp or a model on the runner.
133
+ *
134
+ * FROM THE CONFIG, not from the workdir's `transcript-key.json`, and that is
135
+ * a knowing limitation: reading the key would mean importing produce.ts,
136
+ * which statically pulls in @ossclip/renderer AND imports this module back
137
+ * (a cycle), for a server whose whole point is to start instantly. The
138
+ * failure when the two disagree is safe and REPORTED rather than silent: a
139
+ * range re-decoded with a different model produces words that match nothing,
140
+ * and `alignRestamp` answers "matched none — stamps left as they were".
141
+ *
142
+ * The dictionary is validated all-or-nothing, `validDictionary`'s rule
143
+ * (produce.ts): a hand-edited list with a number in it biases whisper with a
144
+ * vocabulary the user never reviewed, so the whole key is dropped instead.
145
+ * Same for `language`: typeof+trim, never truthiness, never coerced.
146
+ */
147
+ export function retranscribeSettings(
148
+ cfg: RetranscribeConfig,
149
+ ):
150
+ | {
151
+ tools: { ffmpegPath: string; ffprobePath: string };
152
+ whisperPath: string;
153
+ modelPath: string;
154
+ language?: string;
155
+ prompt?: string;
156
+ }
157
+ | { error: string } {
158
+ if (!cfg.ffmpegPath || !cfg.ffprobePath || !cfg.whisperPath || !cfg.model || !cfg.modelDir) {
159
+ return {
160
+ error:
161
+ "ffmpeg or whisper is not configured — run `ossclip doctor` to see what is missing, " +
162
+ "then `ossclip setup` to install it.",
163
+ };
164
+ }
165
+ const dict = Array.isArray(cfg.dictionary)
166
+ && cfg.dictionary.length > 0
167
+ && cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
168
+ ? (cfg.dictionary as string[]).map((t) => t.trim())
169
+ : [];
170
+ const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
171
+ ? cfg.language.trim()
172
+ : modelImpliedLanguage(cfg.model);
173
+ const prompt = whisperPromptFor(dict);
174
+ return {
175
+ tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
176
+ whisperPath: cfg.whisperPath,
177
+ modelPath: whisperModelPath(cfg.model, cfg.modelDir),
178
+ ...(language !== undefined ? { language } : {}),
179
+ ...(prompt !== undefined ? { prompt } : {}),
180
+ };
181
+ }
182
+
97
183
  /**
98
184
  * The editor's backend: a handful of endpoints and a static file server,
99
185
  * deliberately dependency-free. It reads the workdir a `produce` run left
@@ -158,6 +244,14 @@ const MIME: Record<string, string> = {
158
244
  // the type is stated while the change is one line rather than a debugging
159
245
  // session.
160
246
  ".wav": "audio/wav",
247
+ // The `--cover-in-video` overlay: produce stages the cover into the workdir
248
+ // as well as the render's public dir, and the Player fetches it through
249
+ // `/media/`. Browsers do sniff an image served as octet-stream, but a
250
+ // preview that depends on sniffing is a preview that breaks the first time
251
+ // something in front of it (a proxy, a stricter engine) declines to.
252
+ ".jpg": "image/jpeg",
253
+ ".jpeg": "image/jpeg",
254
+ ".png": "image/png",
161
255
  };
162
256
 
163
257
  /**
@@ -254,7 +348,19 @@ export async function startEditServer(
254
348
  /** Config seam for the thumbnail panel — tests inject `() => ({})` so a
255
349
  * run never reads the runner's real ~/.ossclip/config.json (the
256
350
  * `recentDir` rule applied to reads). */
257
- loadCfg?: () => { youtube?: unknown; portrait?: unknown; thumbnailModel?: unknown };
351
+ loadCfg?: () => {
352
+ youtube?: unknown;
353
+ portrait?: unknown;
354
+ thumbnailModel?: unknown;
355
+ postizUrl?: string;
356
+ } & RetranscribeConfig;
357
+ /** Env seam for the publish endpoints — tests inject their own so the
358
+ * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
359
+ * test (the loadCfg rule applied to the environment). */
360
+ publishEnv?: NodeJS.ProcessEnv;
361
+ /** Fetch seam for the publish endpoints — tests stub Postiz instead of
362
+ * needing an instance on the runner (createPostizProvider's fetchImpl). */
363
+ publishFetch?: typeof fetch;
258
364
  /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
259
365
  * observe the revealed path instead of popping a real Finder/Explorer
260
366
  * window on the runner. */
@@ -266,6 +372,14 @@ export async function startEditServer(
266
372
  * is also why cover.ts keeps that import lazy in the first place.
267
373
  */
268
374
  renderCover?: CoverSeams["renderCover"];
375
+ /**
376
+ * The two shell-outs `/api/retranscribe-range` makes — the
377
+ * `generateThumbnail` seam pattern, twice: tests stub a decode instead of
378
+ * needing ffmpeg, whisper.cpp and a multi-gigabyte model on the runner
379
+ * (`edit-server.test.ts` must stay a unit test).
380
+ */
381
+ sliceAudio?: typeof extractAudioSpan;
382
+ runWhisper?: typeof runWhisper;
269
383
  } = {},
270
384
  ): Promise<EditServer> {
271
385
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -355,6 +469,14 @@ export async function startEditServer(
355
469
  // headless browser, and a double-click must not run two renders at the same
356
470
  // destination. `thumbnailBusy`'s rule, for the same reason.
357
471
  let coverBusy = false;
472
+ /**
473
+ * One range re-decode at a time (`/api/retranscribe-range`). whisper is a
474
+ * CPU-bound spawn AND the endpoint read-modify-writes transcript.json, so
475
+ * two in flight is both a stalled box and a lost splice — the second write
476
+ * would be built on a transcript read before the first one landed.
477
+ * `coverBusy`'s rule with a second reason on top.
478
+ */
479
+ let retranscribeBusy = false;
358
480
  /** Where the JPEG lives right now: the destination the last cover used,
359
481
  * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
360
482
  * recorded destination that was never rendered is a real state (the panel
@@ -555,6 +677,157 @@ export async function startEditServer(
555
677
  });
556
678
  }
557
679
 
680
+ if (url.pathname === "/api/transcript" && req.method === "GET") {
681
+ // The FULL transcript, source-timed words included (cut-review
682
+ // rework follow-up): the editor rebuilds the caption track over
683
+ // REVIVED material with produce's own `buildCaptionLines`, and
684
+ // render-props' captionLines only cover what the last render kept
685
+ // — the revived words exist nowhere else client-side. Lenient like
686
+ // /api/cleanup: a missing or corrupt transcript.json degrades to
687
+ // null (captions over revived material stay absent, never a 500).
688
+ if (!workdir) return send(409, { error: "no workdir open" });
689
+ try {
690
+ const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
691
+ language?: unknown;
692
+ words?: unknown;
693
+ };
694
+ if (!Array.isArray(raw.words)) return send(200, { transcript: null });
695
+ return send(200, { transcript: raw });
696
+ } catch {
697
+ return send(200, { transcript: null });
698
+ }
699
+ }
700
+
701
+ if (url.pathname === "/api/retranscribe-range" && req.method === "POST") {
702
+ // Re-decode ONE source span and re-stamp the words already there
703
+ // (Phase A, 2026-08-26): inside a kept retake whisper mis-POSITIONS
704
+ // words — the caption says "has its" while the audio says "could
705
+ // read 50 files" — because the first decode ran over material the
706
+ // cut had removed. See restamp.ts for why the splice is
707
+ // stamps-only.
708
+ if (!workdir) return send(409, { error: "no workdir open" });
709
+ if (retranscribeBusy) {
710
+ return send(409, { error: "a re-transcription is already running" });
711
+ }
712
+ const chunks: Buffer[] = [];
713
+ for await (const c of req) chunks.push(c as Buffer);
714
+ // TWO steerable values, both times, and nothing else — the cover
715
+ // regenerate rule: unknown keys are stripped by the parse, and an
716
+ // ordered non-negative pair is checked HERE rather than trusted,
717
+ // since an inverted range would slice a negative duration out of
718
+ // ffmpeg (CLAUDE.md's parse-never-coerce).
719
+ const parsed = z
720
+ .object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() })
721
+ .refine((v) => v.srcOut > v.srcIn, { message: "srcOut must be after srcIn" })
722
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
723
+ if (!parsed.success) return send(400, { error: parsed.error.message });
724
+ const { srcIn, srcOut } = parsed.data;
725
+ // Every path server-derived, never from the body — the regenerate
726
+ // endpoint's stance. `whisper-range` is a distinct outBase from
727
+ // produce's `whisper`, so a range decode can never overwrite the
728
+ // artefact of the full one.
729
+ const dir = workdir;
730
+ const tmpWav = join(dir, "whisper-range.wav");
731
+ const outBase = join(dir, "whisper-range");
732
+ retranscribeBusy = true;
733
+ try {
734
+ const audio = join(dir, "audio.wav");
735
+ if (!existsSync(audio)) {
736
+ return send(200, {
737
+ ok: false,
738
+ error: "this workdir has no audio.wav to re-decode — re-run `ossclip produce`.",
739
+ });
740
+ }
741
+ const transcriptPath = join(dir, "transcript.json");
742
+ if (!existsSync(transcriptPath)) {
743
+ return send(200, {
744
+ ok: false,
745
+ error: "this workdir has no transcript.json to re-stamp — re-run `ossclip produce`.",
746
+ });
747
+ }
748
+ const settings = retranscribeSettings((opts.loadCfg ?? loadConfig)());
749
+ if ("error" in settings) return send(200, { ok: false, error: settings.error });
750
+ if (!existsSync(settings.modelPath)) {
751
+ // The `--transcript`-only install: whisper was never needed to
752
+ // make this project, so say what to run rather than 500ing.
753
+ return send(200, {
754
+ ok: false,
755
+ error:
756
+ `whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
757
+ `to download it.`,
758
+ });
759
+ }
760
+ // Parsed, not cast: this file is about to be rewritten, and a
761
+ // truncated one must fail loudly here rather than become the new
762
+ // transcript.
763
+ const transcript = TranscriptSchema.parse(
764
+ JSON.parse(await readFile(transcriptPath, "utf8")),
765
+ );
766
+ const range = wordsInSpan(transcript.words, srcIn, srcOut);
767
+ if (range.to === range.from) {
768
+ // Silence, or a range whose words all straddle its edges.
769
+ // Nothing to re-stamp is a SUCCESS with an empty mapping — the
770
+ // editor's no-op — not a failure the user has to read.
771
+ return send(200, {
772
+ ok: true,
773
+ mapping: [],
774
+ reports: ["no transcript words lie wholly inside that range"],
775
+ });
776
+ }
777
+ await (opts.sliceAudio ?? extractAudioSpan)(
778
+ settings.tools,
779
+ audio,
780
+ tmpWav,
781
+ srcIn,
782
+ srcOut - srcIn,
783
+ );
784
+ const fresh = await (opts.runWhisper ?? runWhisper)(
785
+ {
786
+ whisperPath: settings.whisperPath,
787
+ modelPath: settings.modelPath,
788
+ outBase,
789
+ ...(settings.language !== undefined ? { language: settings.language } : {}),
790
+ ...(settings.prompt !== undefined ? { prompt: settings.prompt } : {}),
791
+ },
792
+ tmpWav,
793
+ );
794
+ const restamped = alignRestamp(
795
+ transcript.words.slice(range.from, range.to),
796
+ fresh.words,
797
+ srcIn,
798
+ );
799
+ const next = spliceTranscript(transcript, range, restamped.words);
800
+ // Atomic, like the overrides write: produce may read this file at
801
+ // any moment (its cache-reuse branch reads it verbatim and writes
802
+ // back what it read), and a half-written transcript is worse than
803
+ // a stale one.
804
+ const tmp = `${transcriptPath}.tmp`;
805
+ await writeFile(tmp, JSON.stringify(next, null, 2));
806
+ await rename(tmp, transcriptPath);
807
+ // `overrides.json` IS NOT TOUCHED HERE, on purpose: the doc is
808
+ // client-owned — the editor holds unsaved edits and an undo stack
809
+ // over it — so the caption RE-KEY rides back as `mapping` and is
810
+ // applied by the `useEdits` reducer, one commit, one undo step.
811
+ return send(200, { ok: true, mapping: restamped.mapping, reports: restamped.reports });
812
+ } catch (err) {
813
+ // 200 {ok:false}, the cover regenerate posture: a missing whisper
814
+ // binary or a failed decode is a sentence the panel shows, not a
815
+ // dead 500 — and the editor keeps working on the old stamps.
816
+ const message = err instanceof Error ? err.message : String(err);
817
+ return send(200, {
818
+ ok: false,
819
+ error: `${message} — run \`ossclip doctor\` if ffmpeg or whisper is the problem.`,
820
+ });
821
+ } finally {
822
+ retranscribeBusy = false;
823
+ // Both artefacts are per-request scratch. `.catch()` because a
824
+ // decode that never got as far as writing one must not turn a
825
+ // reported failure into an unhandled rejection.
826
+ await unlink(tmpWav).catch(() => {});
827
+ await unlink(`${outBase}.json`).catch(() => {});
828
+ }
829
+ }
830
+
558
831
  if (url.pathname === "/api/cleanup" && req.method === "GET") {
559
832
  // The labeled removals: since cut review step 3 this serves the
560
833
  // PROPOSAL (`cutlistProposed` — the automatic cutlist before the
@@ -1185,6 +1458,123 @@ export async function startEditServer(
1185
1458
  return send(200, { ok: true, mdPath });
1186
1459
  }
1187
1460
 
1461
+ // ---- Publish (2026-08-26) -----------------------------------------
1462
+ // The server's FIRST outbound-network endpoints, against the
1463
+ // roadmap's "replay-only by deliberate design" posture — allowed
1464
+ // because all three gates hold: they exist behaviorally only when the
1465
+ // user configured their own Postiz instance (unconfigured → a hint,
1466
+ // never a control), they fire only on an explicit button press, and
1467
+ // the API key never reaches the browser — the server (already running
1468
+ // in the user's shell env) does the upload.
1469
+ if (url.pathname === "/api/publish" && req.method === "GET") {
1470
+ if (!workdir) return send(409, { error: "no workdir open" });
1471
+ const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1472
+ if (!configured.ok) {
1473
+ return send(200, { configured: false, reason: configured.message });
1474
+ }
1475
+ const cmd = await readCommandRecord();
1476
+ const out = cmd ? recordedOutPath(cmd) : null;
1477
+ const pack = await currentYoutubePack();
1478
+ let integrations: Array<{ id: string; provider: string; name: string; caption: string }> = [];
1479
+ try {
1480
+ const provider = createPostizProvider({
1481
+ baseUrl: configured.baseUrl,
1482
+ apiKey: configured.apiKey,
1483
+ fetchImpl: opts.publishFetch,
1484
+ });
1485
+ const targets = await provider.listTargets();
1486
+ integrations = targets.map((t) => ({
1487
+ ...t,
1488
+ // The caption the publish WOULD use — authored-else-derived —
1489
+ // so the panel previews truth, not a guess of it.
1490
+ caption: pack !== null ? captionForProvider(pack, t.provider) : "",
1491
+ }));
1492
+ } catch (err) {
1493
+ return send(200, {
1494
+ configured: true,
1495
+ reachable: false,
1496
+ reason: err instanceof Error ? err.message : String(err),
1497
+ });
1498
+ }
1499
+ return send(200, {
1500
+ configured: true,
1501
+ reachable: true,
1502
+ integrations,
1503
+ packAvailable: pack !== null,
1504
+ outPathExists: out !== null && existsSync(out),
1505
+ receipt: await readPublishReceipt(workdir),
1506
+ });
1507
+ }
1508
+
1509
+ if (url.pathname === "/api/publish" && req.method === "POST") {
1510
+ if (!workdir) return send(409, { error: "no workdir open" });
1511
+ const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1512
+ if (!configured.ok) return send(412, { error: configured.message });
1513
+ const chunks: Buffer[] = [];
1514
+ for await (const c of req) chunks.push(c as Buffer);
1515
+ const parsed = z
1516
+ .object({
1517
+ integrationIds: z.array(z.string()).min(1),
1518
+ // ISO-8601, validated as a real FUTURE instant below — zod can
1519
+ // say "string", only a clock can say "future".
1520
+ at: z.string().optional(),
1521
+ // Per-integration caption overrides typed in the panel; absent
1522
+ // ids fall back to the pack's authored-else-derived caption.
1523
+ captions: z.record(z.string(), z.string()).optional(),
1524
+ force: z.boolean().optional(),
1525
+ })
1526
+ .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1527
+ if (!parsed.success) return send(400, { error: parsed.error.message });
1528
+ let when: { kind: "now" } | { kind: "at"; iso: string } = { kind: "now" };
1529
+ if (parsed.data.at !== undefined) {
1530
+ const ms = Date.parse(parsed.data.at);
1531
+ if (Number.isNaN(ms)) return send(400, { error: `not an ISO-8601 time: "${parsed.data.at}"` });
1532
+ if (ms <= Date.now()) return send(400, { error: `schedule time already passed: "${parsed.data.at}"` });
1533
+ when = { kind: "at", iso: new Date(ms).toISOString() };
1534
+ }
1535
+ const cmd = await readCommandRecord();
1536
+ const out = cmd ? recordedOutPath(cmd) : null;
1537
+ if (out === null || !existsSync(out)) {
1538
+ return send(412, { error: "no finished render to publish — render first" });
1539
+ }
1540
+ const pack = await currentYoutubePack();
1541
+ if (pack === null) {
1542
+ return send(412, { error: "no YouTube pack — approve one in the SEO panel first" });
1543
+ }
1544
+ const receipt = await readPublishReceipt(workdir);
1545
+ if (receipt !== null && parsed.data.force !== true) {
1546
+ return send(412, {
1547
+ error: `already published on ${receipt.publishedAt} — pass force to publish again`,
1548
+ receipt,
1549
+ });
1550
+ }
1551
+ try {
1552
+ const provider = createPostizProvider({
1553
+ baseUrl: configured.baseUrl,
1554
+ apiKey: configured.apiKey,
1555
+ fetchImpl: opts.publishFetch,
1556
+ });
1557
+ const targets = await provider.listTargets();
1558
+ const picked = parsed.data.integrationIds.map((id) => {
1559
+ const hit = targets.find((t) => t.id === id);
1560
+ if (!hit) throw new Error(`no integration with id "${id}" in Postiz`);
1561
+ return hit;
1562
+ });
1563
+ const posts = buildPublishPosts(pack, picked).map((p) => ({
1564
+ ...p,
1565
+ caption: parsed.data.captions?.[p.target.id] ?? p.caption,
1566
+ }));
1567
+ const result = await provider.publish({ videoPath: out, posts, when });
1568
+ await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
1569
+ return send(200, { ok: true, receipt: result });
1570
+ } catch (err) {
1571
+ // Fail loud, verbatim — Postiz's own validation message is the
1572
+ // most specific thing anyone has (per-provider settings are its
1573
+ // domain, not ours).
1574
+ return send(502, { error: err instanceof Error ? err.message : String(err) });
1575
+ }
1576
+ }
1577
+
1188
1578
  if (url.pathname === "/api/cover" && req.method === "GET") {
1189
1579
  // The cover panel's one status call (2026-08-19): the provenance to
1190
1580
  // prefill and where the current image is. All reads — the panel