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