ossclip 0.1.33 → 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,10 +17,24 @@ import {
17
17
  type YoutubePack,
18
18
  ThumbnailConceptApprovedSchema,
19
19
  ThumbnailConceptSchema,
20
+ appendUsageRun,
20
21
  approvedOverlayText,
21
22
  buildThumbnailPrompt,
23
+ captionCap,
22
24
  captionForProvider,
25
+ checkDurationCaps,
23
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,
24
38
  alignRestamp,
25
39
  emptyOverrideDoc,
26
40
  extractAudioSpan,
@@ -38,8 +52,10 @@ import {
38
52
  SegmentSchema,
39
53
  spliceTranscript,
40
54
  TranscriptSchema,
55
+ ungroundedTokens,
41
56
  whisperPromptFor,
42
57
  wordsInSpan,
58
+ type ModelPrice,
43
59
  type Segment,
44
60
  thumbnailImageCacheName,
45
61
  type CoverProvenance,
@@ -51,7 +67,9 @@ import {
51
67
  // drags in @ossclip/core's process runner and llm-detect, worth deferring off
52
68
  // server startup — open.ts is node:child_process + node:path and pure command
53
69
  // building, with nothing to defer.
70
+ import { loadEnvFiles } from "./env";
54
71
  import { revealInFileManager } from "./open";
72
+ import { REVIEWED_SCENES_BASENAME, renderReplayArgs } from "./render-replay-args";
55
73
  // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
56
74
  // cover` needs the same out-resolution rule this server's thumbnail dest,
57
75
  // youtube markdown and reveal endpoint derive from, and two spellings of it
@@ -81,7 +99,16 @@ import {
81
99
  type ResolvedPortrait,
82
100
  } from "./portrait-override";
83
101
  import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
84
- import { buildPublishPosts, publishConfigured, publishReceiptPath, readPublishReceipt } from "./publish";
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";
85
112
 
86
113
  /**
87
114
  * Where the built editor page lives (R18 §90b): `editor-dist/` inside this
@@ -353,6 +380,12 @@ export async function startEditServer(
353
380
  portrait?: unknown;
354
381
  thumbnailModel?: unknown;
355
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>;
356
389
  } & RetranscribeConfig;
357
390
  /** Env seam for the publish endpoints — tests inject their own so the
358
391
  * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
@@ -361,10 +394,18 @@ export async function startEditServer(
361
394
  /** Fetch seam for the publish endpoints — tests stub Postiz instead of
362
395
  * needing an instance on the runner (createPostizProvider's fetchImpl). */
363
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;
364
402
  /** File-manager reveal seam (the `generateThumbnail` pattern) — tests
365
403
  * observe the revealed path instead of popping a real Finder/Explorer
366
404
  * window on the runner. */
367
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;
368
409
  /**
369
410
  * The cover render seam, exactly like `generateThumbnail` above. Without
370
411
  * it `regenerateCover` lazily imports @ossclip/renderer and boots a
@@ -477,6 +518,38 @@ export async function startEditServer(
477
518
  * `coverBusy`'s rule with a second reason on top.
478
519
  */
479
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;
480
553
  /** Where the JPEG lives right now: the destination the last cover used,
481
554
  * else `<recorded out>.cover.jpg`. Existence is the caller's check — a
482
555
  * recorded destination that was never rendered is a real state (the panel
@@ -531,6 +604,33 @@ export async function startEditServer(
531
604
  // panel round-trips through youtube-pack-approved.json, which produce's Y2
532
605
  // block honors VERBATIM on every replay — an edit persisted there survives
533
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
+ };
534
634
  const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
535
635
  /** The pack the panel shows: the approved file first (the user's
536
636
  * decision), else the newest valid `youtube-<key>.json` cache (what the
@@ -970,13 +1070,16 @@ export async function startEditServer(
970
1070
  const chunks: Buffer[] = [];
971
1071
  for await (const c of req) chunks.push(c as Buffer);
972
1072
  let customOut: string | undefined;
1073
+ let replan = false;
973
1074
  try {
974
1075
  const raw = Buffer.concat(chunks).toString();
975
1076
  if (raw.trim()) {
976
- const body = JSON.parse(raw) as { out?: string };
1077
+ const body = JSON.parse(raw) as { out?: string; replan?: boolean };
977
1078
  if (typeof body.out === "string" && body.out.trim()) {
978
1079
  customOut = body.out.trim();
979
1080
  }
1081
+ // Opt back into a fresh LLM plan (renderReplayArgs' why).
1082
+ replan = body.replan === true;
980
1083
  }
981
1084
  } catch {
982
1085
  // ignore
@@ -1031,6 +1134,30 @@ export async function startEditServer(
1031
1134
  filteredArgs.push("--out", customOut);
1032
1135
  args = filteredArgs;
1033
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 });
1034
1161
  renderLines = [];
1035
1162
  renderExit = null;
1036
1163
  renderStartedAt = Date.now();
@@ -1458,6 +1585,205 @@ export async function startEditServer(
1458
1585
  return send(200, { ok: true, mdPath });
1459
1586
  }
1460
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
+
1461
1787
  // ---- Publish (2026-08-26) -----------------------------------------
1462
1788
  // The server's FIRST outbound-network endpoints, against the
1463
1789
  // roadmap's "replay-only by deliberate design" posture — allowed
@@ -1468,14 +1794,33 @@ export async function startEditServer(
1468
1794
  // in the user's shell env) does the upload.
1469
1795
  if (url.pathname === "/api/publish" && req.method === "GET") {
1470
1796
  if (!workdir) return send(409, { error: "no workdir open" });
1471
- const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1797
+ const configured = resolvePublishConfig();
1472
1798
  if (!configured.ok) {
1473
1799
  return send(200, { configured: false, reason: configured.message });
1474
1800
  }
1475
1801
  const cmd = await readCommandRecord();
1476
1802
  const out = cmd ? recordedOutPath(cmd) : null;
1477
1803
  const pack = await currentYoutubePack();
1478
- let integrations: Array<{ id: string; provider: string; name: string; caption: string }> = [];
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
+ }> = [];
1479
1824
  try {
1480
1825
  const provider = createPostizProvider({
1481
1826
  baseUrl: configured.baseUrl,
@@ -1488,6 +1833,13 @@ export async function startEditServer(
1488
1833
  // The caption the publish WOULD use — authored-else-derived —
1489
1834
  // so the panel previews truth, not a guess of it.
1490
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,
1491
1843
  }));
1492
1844
  } catch (err) {
1493
1845
  return send(200, {
@@ -1502,13 +1854,14 @@ export async function startEditServer(
1502
1854
  integrations,
1503
1855
  packAvailable: pack !== null,
1504
1856
  outPathExists: out !== null && existsSync(out),
1857
+ durationSec,
1505
1858
  receipt: await readPublishReceipt(workdir),
1506
1859
  });
1507
1860
  }
1508
1861
 
1509
1862
  if (url.pathname === "/api/publish" && req.method === "POST") {
1510
1863
  if (!workdir) return send(409, { error: "no workdir open" });
1511
- const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
1864
+ const configured = resolvePublishConfig();
1512
1865
  if (!configured.ok) return send(412, { error: configured.message });
1513
1866
  const chunks: Buffer[] = [];
1514
1867
  for await (const c of req) chunks.push(c as Buffer);
@@ -1522,6 +1875,9 @@ export async function startEditServer(
1522
1875
  // ids fall back to the pack's authored-else-derived caption.
1523
1876
  captions: z.record(z.string(), z.string()).optional(),
1524
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(),
1525
1881
  })
1526
1882
  .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1527
1883
  if (!parsed.success) return send(400, { error: parsed.error.message });
@@ -1555,23 +1911,290 @@ export async function startEditServer(
1555
1911
  fetchImpl: opts.publishFetch,
1556
1912
  });
1557
1913
  const targets = await provider.listTargets();
1558
- const picked = parsed.data.integrationIds.map((id) => {
1914
+ let picked = parsed.data.integrationIds.map((id) => {
1559
1915
  const hit = targets.find((t) => t.id === id);
1560
1916
  if (!hit) throw new Error(`no integration with id "${id}" in Postiz`);
1561
1917
  return hit;
1562
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
+ }
1563
1987
  const posts = buildPublishPosts(pack, picked).map((p) => ({
1564
1988
  ...p,
1565
1989
  caption: parsed.data.captions?.[p.target.id] ?? p.caption,
1566
1990
  }));
1567
- const result = await provider.publish({ videoPath: out, posts, when });
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
+ });
1568
2053
  await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
1569
- return send(200, { ok: true, receipt: result });
2054
+ return send(200, { ok: true, receipt: result, dropped });
1570
2055
  } catch (err) {
1571
2056
  // Fail loud, verbatim — Postiz's own validation message is the
1572
2057
  // most specific thing anyone has (per-provider settings are its
1573
2058
  // domain, not ours).
1574
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;
1575
2198
  }
1576
2199
  }
1577
2200