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/README.md +2 -0
- package/editor-dist/assets/index-Dn813PGp.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-regen-panel.ts +74 -0
- package/src/caption-report.ts +26 -1
- package/src/edit.ts +1016 -3
- package/src/produce.ts +374 -22
- package/src/program.ts +119 -1
- package/src/publish.ts +696 -0
- package/src/render-replay-args.ts +50 -0
- package/src/replay-argv.ts +16 -0
- package/src/ui/animation.ts +5 -1
- package/editor-dist/assets/index-C537KpNT.js +0 -166
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?: () => {
|
|
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
|