ossclip 0.1.30 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/editor-dist/assets/index-DA7vDWn_.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-report.ts +26 -1
- package/src/edit.ts +490 -1
- package/src/produce.ts +287 -19
- package/src/program.ts +63 -0
- package/src/publish.ts +352 -0
- package/src/replay-argv.ts +16 -0
- package/src/ui/animation.ts +5 -1
- package/editor-dist/assets/index-DfTreNoE.js +0 -166
package/src/edit.ts
CHANGED
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
ThumbnailConceptSchema,
|
|
20
20
|
approvedOverlayText,
|
|
21
21
|
buildThumbnailPrompt,
|
|
22
|
+
captionForProvider,
|
|
23
|
+
createPostizProvider,
|
|
24
|
+
alignRestamp,
|
|
22
25
|
emptyOverrideDoc,
|
|
26
|
+
extractAudioSpan,
|
|
23
27
|
// Static import is fine here: the @google/genai SDK load is LAZY inside
|
|
24
28
|
// this function (core's near-zero-dep rule), so the server pays for it
|
|
25
29
|
// only when a regenerate actually runs.
|
|
@@ -30,7 +34,12 @@ import {
|
|
|
30
34
|
PORTRAIT_MIME_TYPES,
|
|
31
35
|
portraitMimeType,
|
|
32
36
|
readCoverProvenance,
|
|
37
|
+
runWhisper,
|
|
33
38
|
SegmentSchema,
|
|
39
|
+
spliceTranscript,
|
|
40
|
+
TranscriptSchema,
|
|
41
|
+
whisperPromptFor,
|
|
42
|
+
wordsInSpan,
|
|
34
43
|
type Segment,
|
|
35
44
|
thumbnailImageCacheName,
|
|
36
45
|
type CoverProvenance,
|
|
@@ -60,6 +69,10 @@ import {
|
|
|
60
69
|
type RecordedCommand,
|
|
61
70
|
} from "./cover";
|
|
62
71
|
import { expandHome } from "./paths";
|
|
72
|
+
// The model-path and implied-language rules, from THE source doctor, setup and
|
|
73
|
+
// produce all resolve through — a second copy here would send a user to a
|
|
74
|
+
// model file the rest of the tool never looks for.
|
|
75
|
+
import { modelImpliedLanguage, whisperModelPath } from "./setup/manifest";
|
|
63
76
|
import {
|
|
64
77
|
PORTRAIT_OVERRIDE_BASENAME,
|
|
65
78
|
portraitExtensionForMime,
|
|
@@ -68,6 +81,7 @@ import {
|
|
|
68
81
|
type ResolvedPortrait,
|
|
69
82
|
} from "./portrait-override";
|
|
70
83
|
import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
|
|
84
|
+
import { buildPublishPosts, publishConfigured, publishReceiptPath, readPublishReceipt } from "./publish";
|
|
71
85
|
|
|
72
86
|
/**
|
|
73
87
|
* Where the built editor page lives (R18 §90b): `editor-dist/` inside this
|
|
@@ -94,6 +108,78 @@ export function resolveEditorPageDir(): string | null {
|
|
|
94
108
|
return candidates[0] ?? null;
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The config keys `/api/retranscribe-range` needs. Spelled structurally
|
|
113
|
+
* rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
|
|
114
|
+
* stub that supplies only what a case is about — the same shape the youtube/
|
|
115
|
+
* portrait keys above it are declared with.
|
|
116
|
+
*/
|
|
117
|
+
export interface RetranscribeConfig {
|
|
118
|
+
ffmpegPath?: string;
|
|
119
|
+
ffprobePath?: string;
|
|
120
|
+
whisperPath?: string;
|
|
121
|
+
model?: string;
|
|
122
|
+
modelDir?: string;
|
|
123
|
+
language?: unknown;
|
|
124
|
+
dictionary?: unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* How to spawn whisper for a range re-decode, or why we cannot.
|
|
129
|
+
*
|
|
130
|
+
* Pure — the `openCommand`/`openInBrowser` split — so the whole
|
|
131
|
+
* config × missing-binary × malformed-dictionary matrix is testable without
|
|
132
|
+
* whisper.cpp or a model on the runner.
|
|
133
|
+
*
|
|
134
|
+
* FROM THE CONFIG, not from the workdir's `transcript-key.json`, and that is
|
|
135
|
+
* a knowing limitation: reading the key would mean importing produce.ts,
|
|
136
|
+
* which statically pulls in @ossclip/renderer AND imports this module back
|
|
137
|
+
* (a cycle), for a server whose whole point is to start instantly. The
|
|
138
|
+
* failure when the two disagree is safe and REPORTED rather than silent: a
|
|
139
|
+
* range re-decoded with a different model produces words that match nothing,
|
|
140
|
+
* and `alignRestamp` answers "matched none — stamps left as they were".
|
|
141
|
+
*
|
|
142
|
+
* The dictionary is validated all-or-nothing, `validDictionary`'s rule
|
|
143
|
+
* (produce.ts): a hand-edited list with a number in it biases whisper with a
|
|
144
|
+
* vocabulary the user never reviewed, so the whole key is dropped instead.
|
|
145
|
+
* Same for `language`: typeof+trim, never truthiness, never coerced.
|
|
146
|
+
*/
|
|
147
|
+
export function retranscribeSettings(
|
|
148
|
+
cfg: RetranscribeConfig,
|
|
149
|
+
):
|
|
150
|
+
| {
|
|
151
|
+
tools: { ffmpegPath: string; ffprobePath: string };
|
|
152
|
+
whisperPath: string;
|
|
153
|
+
modelPath: string;
|
|
154
|
+
language?: string;
|
|
155
|
+
prompt?: string;
|
|
156
|
+
}
|
|
157
|
+
| { error: string } {
|
|
158
|
+
if (!cfg.ffmpegPath || !cfg.ffprobePath || !cfg.whisperPath || !cfg.model || !cfg.modelDir) {
|
|
159
|
+
return {
|
|
160
|
+
error:
|
|
161
|
+
"ffmpeg or whisper is not configured — run `ossclip doctor` to see what is missing, " +
|
|
162
|
+
"then `ossclip setup` to install it.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const dict = Array.isArray(cfg.dictionary)
|
|
166
|
+
&& cfg.dictionary.length > 0
|
|
167
|
+
&& cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
|
|
168
|
+
? (cfg.dictionary as string[]).map((t) => t.trim())
|
|
169
|
+
: [];
|
|
170
|
+
const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
|
|
171
|
+
? cfg.language.trim()
|
|
172
|
+
: modelImpliedLanguage(cfg.model);
|
|
173
|
+
const prompt = whisperPromptFor(dict);
|
|
174
|
+
return {
|
|
175
|
+
tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
|
|
176
|
+
whisperPath: cfg.whisperPath,
|
|
177
|
+
modelPath: whisperModelPath(cfg.model, cfg.modelDir),
|
|
178
|
+
...(language !== undefined ? { language } : {}),
|
|
179
|
+
...(prompt !== undefined ? { prompt } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
97
183
|
/**
|
|
98
184
|
* The editor's backend: a handful of endpoints and a static file server,
|
|
99
185
|
* deliberately dependency-free. It reads the workdir a `produce` run left
|
|
@@ -158,6 +244,14 @@ const MIME: Record<string, string> = {
|
|
|
158
244
|
// the type is stated while the change is one line rather than a debugging
|
|
159
245
|
// session.
|
|
160
246
|
".wav": "audio/wav",
|
|
247
|
+
// The `--cover-in-video` overlay: produce stages the cover into the workdir
|
|
248
|
+
// as well as the render's public dir, and the Player fetches it through
|
|
249
|
+
// `/media/`. Browsers do sniff an image served as octet-stream, but a
|
|
250
|
+
// preview that depends on sniffing is a preview that breaks the first time
|
|
251
|
+
// something in front of it (a proxy, a stricter engine) declines to.
|
|
252
|
+
".jpg": "image/jpeg",
|
|
253
|
+
".jpeg": "image/jpeg",
|
|
254
|
+
".png": "image/png",
|
|
161
255
|
};
|
|
162
256
|
|
|
163
257
|
/**
|
|
@@ -254,7 +348,19 @@ export async function startEditServer(
|
|
|
254
348
|
/** Config seam for the thumbnail panel — tests inject `() => ({})` so a
|
|
255
349
|
* run never reads the runner's real ~/.ossclip/config.json (the
|
|
256
350
|
* `recentDir` rule applied to reads). */
|
|
257
|
-
loadCfg?: () => {
|
|
351
|
+
loadCfg?: () => {
|
|
352
|
+
youtube?: unknown;
|
|
353
|
+
portrait?: unknown;
|
|
354
|
+
thumbnailModel?: unknown;
|
|
355
|
+
postizUrl?: string;
|
|
356
|
+
} & RetranscribeConfig;
|
|
357
|
+
/** Env seam for the publish endpoints — tests inject their own so the
|
|
358
|
+
* runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
|
|
359
|
+
* test (the loadCfg rule applied to the environment). */
|
|
360
|
+
publishEnv?: NodeJS.ProcessEnv;
|
|
361
|
+
/** Fetch seam for the publish endpoints — tests stub Postiz instead of
|
|
362
|
+
* needing an instance on the runner (createPostizProvider's fetchImpl). */
|
|
363
|
+
publishFetch?: typeof fetch;
|
|
258
364
|
/** File-manager reveal seam (the `generateThumbnail` pattern) — tests
|
|
259
365
|
* observe the revealed path instead of popping a real Finder/Explorer
|
|
260
366
|
* window on the runner. */
|
|
@@ -266,6 +372,14 @@ export async function startEditServer(
|
|
|
266
372
|
* is also why cover.ts keeps that import lazy in the first place.
|
|
267
373
|
*/
|
|
268
374
|
renderCover?: CoverSeams["renderCover"];
|
|
375
|
+
/**
|
|
376
|
+
* The two shell-outs `/api/retranscribe-range` makes — the
|
|
377
|
+
* `generateThumbnail` seam pattern, twice: tests stub a decode instead of
|
|
378
|
+
* needing ffmpeg, whisper.cpp and a multi-gigabyte model on the runner
|
|
379
|
+
* (`edit-server.test.ts` must stay a unit test).
|
|
380
|
+
*/
|
|
381
|
+
sliceAudio?: typeof extractAudioSpan;
|
|
382
|
+
runWhisper?: typeof runWhisper;
|
|
269
383
|
} = {},
|
|
270
384
|
): Promise<EditServer> {
|
|
271
385
|
// MUTABLE since R17 §83: the server can start with no project (the page
|
|
@@ -355,6 +469,14 @@ export async function startEditServer(
|
|
|
355
469
|
// headless browser, and a double-click must not run two renders at the same
|
|
356
470
|
// destination. `thumbnailBusy`'s rule, for the same reason.
|
|
357
471
|
let coverBusy = false;
|
|
472
|
+
/**
|
|
473
|
+
* One range re-decode at a time (`/api/retranscribe-range`). whisper is a
|
|
474
|
+
* CPU-bound spawn AND the endpoint read-modify-writes transcript.json, so
|
|
475
|
+
* two in flight is both a stalled box and a lost splice — the second write
|
|
476
|
+
* would be built on a transcript read before the first one landed.
|
|
477
|
+
* `coverBusy`'s rule with a second reason on top.
|
|
478
|
+
*/
|
|
479
|
+
let retranscribeBusy = false;
|
|
358
480
|
/** Where the JPEG lives right now: the destination the last cover used,
|
|
359
481
|
* else `<recorded out>.cover.jpg`. Existence is the caller's check — a
|
|
360
482
|
* recorded destination that was never rendered is a real state (the panel
|
|
@@ -369,6 +491,11 @@ export async function startEditServer(
|
|
|
369
491
|
* file behind this URL). */
|
|
370
492
|
const coverImageUrl = (image: string | null): string | null =>
|
|
371
493
|
image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
|
|
494
|
+
/** Where the Preview button's one-off render lands (handoff-cover-panel
|
|
495
|
+
* §3): a fixed name IN the workdir — never a client-named path, and never
|
|
496
|
+
* the canonical `.cover.jpg` a real Apply owns. Each preview overwrites the
|
|
497
|
+
* last; the file is scratch, like the frame-sampler's cache. */
|
|
498
|
+
const coverPreviewPath = (): string => join(workdir!, "cover-preview.jpg");
|
|
372
499
|
|
|
373
500
|
// ---- Portrait override (editor face swap, 2026-08-17) -------------------
|
|
374
501
|
// A per-project `portrait-override.<ext>` in the workdir that outranks the
|
|
@@ -550,6 +677,157 @@ export async function startEditServer(
|
|
|
550
677
|
});
|
|
551
678
|
}
|
|
552
679
|
|
|
680
|
+
if (url.pathname === "/api/transcript" && req.method === "GET") {
|
|
681
|
+
// The FULL transcript, source-timed words included (cut-review
|
|
682
|
+
// rework follow-up): the editor rebuilds the caption track over
|
|
683
|
+
// REVIVED material with produce's own `buildCaptionLines`, and
|
|
684
|
+
// render-props' captionLines only cover what the last render kept
|
|
685
|
+
// — the revived words exist nowhere else client-side. Lenient like
|
|
686
|
+
// /api/cleanup: a missing or corrupt transcript.json degrades to
|
|
687
|
+
// null (captions over revived material stay absent, never a 500).
|
|
688
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
689
|
+
try {
|
|
690
|
+
const raw = JSON.parse(await readFile(join(workdir, "transcript.json"), "utf8")) as {
|
|
691
|
+
language?: unknown;
|
|
692
|
+
words?: unknown;
|
|
693
|
+
};
|
|
694
|
+
if (!Array.isArray(raw.words)) return send(200, { transcript: null });
|
|
695
|
+
return send(200, { transcript: raw });
|
|
696
|
+
} catch {
|
|
697
|
+
return send(200, { transcript: null });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (url.pathname === "/api/retranscribe-range" && req.method === "POST") {
|
|
702
|
+
// Re-decode ONE source span and re-stamp the words already there
|
|
703
|
+
// (Phase A, 2026-08-26): inside a kept retake whisper mis-POSITIONS
|
|
704
|
+
// words — the caption says "has its" while the audio says "could
|
|
705
|
+
// read 50 files" — because the first decode ran over material the
|
|
706
|
+
// cut had removed. See restamp.ts for why the splice is
|
|
707
|
+
// stamps-only.
|
|
708
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
709
|
+
if (retranscribeBusy) {
|
|
710
|
+
return send(409, { error: "a re-transcription is already running" });
|
|
711
|
+
}
|
|
712
|
+
const chunks: Buffer[] = [];
|
|
713
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
714
|
+
// TWO steerable values, both times, and nothing else — the cover
|
|
715
|
+
// regenerate rule: unknown keys are stripped by the parse, and an
|
|
716
|
+
// ordered non-negative pair is checked HERE rather than trusted,
|
|
717
|
+
// since an inverted range would slice a negative duration out of
|
|
718
|
+
// ffmpeg (CLAUDE.md's parse-never-coerce).
|
|
719
|
+
const parsed = z
|
|
720
|
+
.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() })
|
|
721
|
+
.refine((v) => v.srcOut > v.srcIn, { message: "srcOut must be after srcIn" })
|
|
722
|
+
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
723
|
+
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
724
|
+
const { srcIn, srcOut } = parsed.data;
|
|
725
|
+
// Every path server-derived, never from the body — the regenerate
|
|
726
|
+
// endpoint's stance. `whisper-range` is a distinct outBase from
|
|
727
|
+
// produce's `whisper`, so a range decode can never overwrite the
|
|
728
|
+
// artefact of the full one.
|
|
729
|
+
const dir = workdir;
|
|
730
|
+
const tmpWav = join(dir, "whisper-range.wav");
|
|
731
|
+
const outBase = join(dir, "whisper-range");
|
|
732
|
+
retranscribeBusy = true;
|
|
733
|
+
try {
|
|
734
|
+
const audio = join(dir, "audio.wav");
|
|
735
|
+
if (!existsSync(audio)) {
|
|
736
|
+
return send(200, {
|
|
737
|
+
ok: false,
|
|
738
|
+
error: "this workdir has no audio.wav to re-decode — re-run `ossclip produce`.",
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const transcriptPath = join(dir, "transcript.json");
|
|
742
|
+
if (!existsSync(transcriptPath)) {
|
|
743
|
+
return send(200, {
|
|
744
|
+
ok: false,
|
|
745
|
+
error: "this workdir has no transcript.json to re-stamp — re-run `ossclip produce`.",
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
const settings = retranscribeSettings((opts.loadCfg ?? loadConfig)());
|
|
749
|
+
if ("error" in settings) return send(200, { ok: false, error: settings.error });
|
|
750
|
+
if (!existsSync(settings.modelPath)) {
|
|
751
|
+
// The `--transcript`-only install: whisper was never needed to
|
|
752
|
+
// make this project, so say what to run rather than 500ing.
|
|
753
|
+
return send(200, {
|
|
754
|
+
ok: false,
|
|
755
|
+
error:
|
|
756
|
+
`whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
|
|
757
|
+
`to download it.`,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
// Parsed, not cast: this file is about to be rewritten, and a
|
|
761
|
+
// truncated one must fail loudly here rather than become the new
|
|
762
|
+
// transcript.
|
|
763
|
+
const transcript = TranscriptSchema.parse(
|
|
764
|
+
JSON.parse(await readFile(transcriptPath, "utf8")),
|
|
765
|
+
);
|
|
766
|
+
const range = wordsInSpan(transcript.words, srcIn, srcOut);
|
|
767
|
+
if (range.to === range.from) {
|
|
768
|
+
// Silence, or a range whose words all straddle its edges.
|
|
769
|
+
// Nothing to re-stamp is a SUCCESS with an empty mapping — the
|
|
770
|
+
// editor's no-op — not a failure the user has to read.
|
|
771
|
+
return send(200, {
|
|
772
|
+
ok: true,
|
|
773
|
+
mapping: [],
|
|
774
|
+
reports: ["no transcript words lie wholly inside that range"],
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
await (opts.sliceAudio ?? extractAudioSpan)(
|
|
778
|
+
settings.tools,
|
|
779
|
+
audio,
|
|
780
|
+
tmpWav,
|
|
781
|
+
srcIn,
|
|
782
|
+
srcOut - srcIn,
|
|
783
|
+
);
|
|
784
|
+
const fresh = await (opts.runWhisper ?? runWhisper)(
|
|
785
|
+
{
|
|
786
|
+
whisperPath: settings.whisperPath,
|
|
787
|
+
modelPath: settings.modelPath,
|
|
788
|
+
outBase,
|
|
789
|
+
...(settings.language !== undefined ? { language: settings.language } : {}),
|
|
790
|
+
...(settings.prompt !== undefined ? { prompt: settings.prompt } : {}),
|
|
791
|
+
},
|
|
792
|
+
tmpWav,
|
|
793
|
+
);
|
|
794
|
+
const restamped = alignRestamp(
|
|
795
|
+
transcript.words.slice(range.from, range.to),
|
|
796
|
+
fresh.words,
|
|
797
|
+
srcIn,
|
|
798
|
+
);
|
|
799
|
+
const next = spliceTranscript(transcript, range, restamped.words);
|
|
800
|
+
// Atomic, like the overrides write: produce may read this file at
|
|
801
|
+
// any moment (its cache-reuse branch reads it verbatim and writes
|
|
802
|
+
// back what it read), and a half-written transcript is worse than
|
|
803
|
+
// a stale one.
|
|
804
|
+
const tmp = `${transcriptPath}.tmp`;
|
|
805
|
+
await writeFile(tmp, JSON.stringify(next, null, 2));
|
|
806
|
+
await rename(tmp, transcriptPath);
|
|
807
|
+
// `overrides.json` IS NOT TOUCHED HERE, on purpose: the doc is
|
|
808
|
+
// client-owned — the editor holds unsaved edits and an undo stack
|
|
809
|
+
// over it — so the caption RE-KEY rides back as `mapping` and is
|
|
810
|
+
// applied by the `useEdits` reducer, one commit, one undo step.
|
|
811
|
+
return send(200, { ok: true, mapping: restamped.mapping, reports: restamped.reports });
|
|
812
|
+
} catch (err) {
|
|
813
|
+
// 200 {ok:false}, the cover regenerate posture: a missing whisper
|
|
814
|
+
// binary or a failed decode is a sentence the panel shows, not a
|
|
815
|
+
// dead 500 — and the editor keeps working on the old stamps.
|
|
816
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
817
|
+
return send(200, {
|
|
818
|
+
ok: false,
|
|
819
|
+
error: `${message} — run \`ossclip doctor\` if ffmpeg or whisper is the problem.`,
|
|
820
|
+
});
|
|
821
|
+
} finally {
|
|
822
|
+
retranscribeBusy = false;
|
|
823
|
+
// Both artefacts are per-request scratch. `.catch()` because a
|
|
824
|
+
// decode that never got as far as writing one must not turn a
|
|
825
|
+
// reported failure into an unhandled rejection.
|
|
826
|
+
await unlink(tmpWav).catch(() => {});
|
|
827
|
+
await unlink(`${outBase}.json`).catch(() => {});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
553
831
|
if (url.pathname === "/api/cleanup" && req.method === "GET") {
|
|
554
832
|
// The labeled removals: since cut review step 3 this serves the
|
|
555
833
|
// PROPOSAL (`cutlistProposed` — the automatic cutlist before the
|
|
@@ -1180,6 +1458,123 @@ export async function startEditServer(
|
|
|
1180
1458
|
return send(200, { ok: true, mdPath });
|
|
1181
1459
|
}
|
|
1182
1460
|
|
|
1461
|
+
// ---- Publish (2026-08-26) -----------------------------------------
|
|
1462
|
+
// The server's FIRST outbound-network endpoints, against the
|
|
1463
|
+
// roadmap's "replay-only by deliberate design" posture — allowed
|
|
1464
|
+
// because all three gates hold: they exist behaviorally only when the
|
|
1465
|
+
// user configured their own Postiz instance (unconfigured → a hint,
|
|
1466
|
+
// never a control), they fire only on an explicit button press, and
|
|
1467
|
+
// the API key never reaches the browser — the server (already running
|
|
1468
|
+
// in the user's shell env) does the upload.
|
|
1469
|
+
if (url.pathname === "/api/publish" && req.method === "GET") {
|
|
1470
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1471
|
+
const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
|
|
1472
|
+
if (!configured.ok) {
|
|
1473
|
+
return send(200, { configured: false, reason: configured.message });
|
|
1474
|
+
}
|
|
1475
|
+
const cmd = await readCommandRecord();
|
|
1476
|
+
const out = cmd ? recordedOutPath(cmd) : null;
|
|
1477
|
+
const pack = await currentYoutubePack();
|
|
1478
|
+
let integrations: Array<{ id: string; provider: string; name: string; caption: string }> = [];
|
|
1479
|
+
try {
|
|
1480
|
+
const provider = createPostizProvider({
|
|
1481
|
+
baseUrl: configured.baseUrl,
|
|
1482
|
+
apiKey: configured.apiKey,
|
|
1483
|
+
fetchImpl: opts.publishFetch,
|
|
1484
|
+
});
|
|
1485
|
+
const targets = await provider.listTargets();
|
|
1486
|
+
integrations = targets.map((t) => ({
|
|
1487
|
+
...t,
|
|
1488
|
+
// The caption the publish WOULD use — authored-else-derived —
|
|
1489
|
+
// so the panel previews truth, not a guess of it.
|
|
1490
|
+
caption: pack !== null ? captionForProvider(pack, t.provider) : "",
|
|
1491
|
+
}));
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
return send(200, {
|
|
1494
|
+
configured: true,
|
|
1495
|
+
reachable: false,
|
|
1496
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
return send(200, {
|
|
1500
|
+
configured: true,
|
|
1501
|
+
reachable: true,
|
|
1502
|
+
integrations,
|
|
1503
|
+
packAvailable: pack !== null,
|
|
1504
|
+
outPathExists: out !== null && existsSync(out),
|
|
1505
|
+
receipt: await readPublishReceipt(workdir),
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (url.pathname === "/api/publish" && req.method === "POST") {
|
|
1510
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1511
|
+
const configured = publishConfigured((opts.loadCfg ?? loadConfig)(), opts.publishEnv ?? process.env);
|
|
1512
|
+
if (!configured.ok) return send(412, { error: configured.message });
|
|
1513
|
+
const chunks: Buffer[] = [];
|
|
1514
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
1515
|
+
const parsed = z
|
|
1516
|
+
.object({
|
|
1517
|
+
integrationIds: z.array(z.string()).min(1),
|
|
1518
|
+
// ISO-8601, validated as a real FUTURE instant below — zod can
|
|
1519
|
+
// say "string", only a clock can say "future".
|
|
1520
|
+
at: z.string().optional(),
|
|
1521
|
+
// Per-integration caption overrides typed in the panel; absent
|
|
1522
|
+
// ids fall back to the pack's authored-else-derived caption.
|
|
1523
|
+
captions: z.record(z.string(), z.string()).optional(),
|
|
1524
|
+
force: z.boolean().optional(),
|
|
1525
|
+
})
|
|
1526
|
+
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
1527
|
+
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
1528
|
+
let when: { kind: "now" } | { kind: "at"; iso: string } = { kind: "now" };
|
|
1529
|
+
if (parsed.data.at !== undefined) {
|
|
1530
|
+
const ms = Date.parse(parsed.data.at);
|
|
1531
|
+
if (Number.isNaN(ms)) return send(400, { error: `not an ISO-8601 time: "${parsed.data.at}"` });
|
|
1532
|
+
if (ms <= Date.now()) return send(400, { error: `schedule time already passed: "${parsed.data.at}"` });
|
|
1533
|
+
when = { kind: "at", iso: new Date(ms).toISOString() };
|
|
1534
|
+
}
|
|
1535
|
+
const cmd = await readCommandRecord();
|
|
1536
|
+
const out = cmd ? recordedOutPath(cmd) : null;
|
|
1537
|
+
if (out === null || !existsSync(out)) {
|
|
1538
|
+
return send(412, { error: "no finished render to publish — render first" });
|
|
1539
|
+
}
|
|
1540
|
+
const pack = await currentYoutubePack();
|
|
1541
|
+
if (pack === null) {
|
|
1542
|
+
return send(412, { error: "no YouTube pack — approve one in the SEO panel first" });
|
|
1543
|
+
}
|
|
1544
|
+
const receipt = await readPublishReceipt(workdir);
|
|
1545
|
+
if (receipt !== null && parsed.data.force !== true) {
|
|
1546
|
+
return send(412, {
|
|
1547
|
+
error: `already published on ${receipt.publishedAt} — pass force to publish again`,
|
|
1548
|
+
receipt,
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
try {
|
|
1552
|
+
const provider = createPostizProvider({
|
|
1553
|
+
baseUrl: configured.baseUrl,
|
|
1554
|
+
apiKey: configured.apiKey,
|
|
1555
|
+
fetchImpl: opts.publishFetch,
|
|
1556
|
+
});
|
|
1557
|
+
const targets = await provider.listTargets();
|
|
1558
|
+
const picked = parsed.data.integrationIds.map((id) => {
|
|
1559
|
+
const hit = targets.find((t) => t.id === id);
|
|
1560
|
+
if (!hit) throw new Error(`no integration with id "${id}" in Postiz`);
|
|
1561
|
+
return hit;
|
|
1562
|
+
});
|
|
1563
|
+
const posts = buildPublishPosts(pack, picked).map((p) => ({
|
|
1564
|
+
...p,
|
|
1565
|
+
caption: parsed.data.captions?.[p.target.id] ?? p.caption,
|
|
1566
|
+
}));
|
|
1567
|
+
const result = await provider.publish({ videoPath: out, posts, when });
|
|
1568
|
+
await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
|
|
1569
|
+
return send(200, { ok: true, receipt: result });
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
// Fail loud, verbatim — Postiz's own validation message is the
|
|
1572
|
+
// most specific thing anyone has (per-provider settings are its
|
|
1573
|
+
// domain, not ours).
|
|
1574
|
+
return send(502, { error: err instanceof Error ? err.message : String(err) });
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1183
1578
|
if (url.pathname === "/api/cover" && req.method === "GET") {
|
|
1184
1579
|
// The cover panel's one status call (2026-08-19): the provenance to
|
|
1185
1580
|
// prefill and where the current image is. All reads — the panel
|
|
@@ -1193,6 +1588,27 @@ export async function startEditServer(
|
|
|
1193
1588
|
// throw for want of a destination, and the panel says so up front
|
|
1194
1589
|
// rather than after a click.
|
|
1195
1590
|
const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
|
|
1591
|
+
// The finished mp4's OWN span set — the RESOLVED cutlist, never the
|
|
1592
|
+
// proposal /api/cleanup serves: the panel converts the playhead
|
|
1593
|
+
// between the output clock and the source clock
|
|
1594
|
+
// (handoff-cover-panel §1), and a proposal the user's vetoes already
|
|
1595
|
+
// changed is the wrong ruler. Lenient per-span parse, [] on a
|
|
1596
|
+
// missing or corrupt production.json — a cover panel that cannot
|
|
1597
|
+
// convert must still open.
|
|
1598
|
+
let coverCutlist: Segment[] = [];
|
|
1599
|
+
try {
|
|
1600
|
+
const production = JSON.parse(
|
|
1601
|
+
await readFile(join(workdir, "production.json"), "utf8"),
|
|
1602
|
+
) as { cutlist?: unknown };
|
|
1603
|
+
if (Array.isArray(production.cutlist)) {
|
|
1604
|
+
coverCutlist = production.cutlist.flatMap((s) => {
|
|
1605
|
+
const p = SegmentSchema.safeParse(s);
|
|
1606
|
+
return p.success ? [p.data] : [];
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
} catch {
|
|
1610
|
+
// degrade — same as /api/cleanup
|
|
1611
|
+
}
|
|
1196
1612
|
return send(200, {
|
|
1197
1613
|
status: outPath === null ? "unavailable" : "ready",
|
|
1198
1614
|
...(outPath === null
|
|
@@ -1203,6 +1619,7 @@ export async function startEditServer(
|
|
|
1203
1619
|
provenance,
|
|
1204
1620
|
outPath,
|
|
1205
1621
|
imageUrl: coverImageUrl(image),
|
|
1622
|
+
cutlist: coverCutlist,
|
|
1206
1623
|
});
|
|
1207
1624
|
}
|
|
1208
1625
|
|
|
@@ -1283,6 +1700,78 @@ export async function startEditServer(
|
|
|
1283
1700
|
}
|
|
1284
1701
|
}
|
|
1285
1702
|
|
|
1703
|
+
if (url.pathname === "/api/cover/preview-image" && req.method === "GET") {
|
|
1704
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1705
|
+
const image = coverPreviewPath();
|
|
1706
|
+
if (!existsSync(image)) return send(404, { error: "no preview image" });
|
|
1707
|
+
// /api/cover/image's exact posture, for its exact reason: every
|
|
1708
|
+
// preview REPLACES the file behind a URL the panel busts with ?ts,
|
|
1709
|
+
// and a cached 200 would show the old preview against the new ts.
|
|
1710
|
+
const bytes = await readFile(image);
|
|
1711
|
+
res.writeHead(200, {
|
|
1712
|
+
"content-type": "image/jpeg",
|
|
1713
|
+
"cache-control": "no-store",
|
|
1714
|
+
"content-length": String(bytes.length),
|
|
1715
|
+
});
|
|
1716
|
+
res.end(bytes);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (url.pathname === "/api/cover/preview" && req.method === "POST") {
|
|
1721
|
+
// The regenerate handler above, verbatim, up to the outPath: a
|
|
1722
|
+
// preview is a full cover render — it boots the same headless
|
|
1723
|
+
// browser (hence the same coverBusy gate), takes the same three
|
|
1724
|
+
// steerable values and nothing else, and fails with the same
|
|
1725
|
+
// 200-with-ok:false verbatim-message posture.
|
|
1726
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1727
|
+
if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
|
|
1728
|
+
const chunks: Buffer[] = [];
|
|
1729
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
1730
|
+
const parsed = z
|
|
1731
|
+
.object({
|
|
1732
|
+
text: z.string().optional(),
|
|
1733
|
+
atSec: CoverAtSecondsSchema.optional(),
|
|
1734
|
+
from: CoverFromSchema.optional(),
|
|
1735
|
+
})
|
|
1736
|
+
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
1737
|
+
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
1738
|
+
coverBusy = true;
|
|
1739
|
+
try {
|
|
1740
|
+
const notes: string[] = [];
|
|
1741
|
+
await regenerateCover(
|
|
1742
|
+
workdir,
|
|
1743
|
+
{
|
|
1744
|
+
text: parsed.data.text,
|
|
1745
|
+
atSec: parsed.data.atSec,
|
|
1746
|
+
from: parsed.data.from,
|
|
1747
|
+
// The ONE-OFF path, derived HERE and never from the body (the
|
|
1748
|
+
// regenerate handler's comment owns why). Same machinery as
|
|
1749
|
+
// `ossclip cover --out` (handoff-cover-panel §3): the
|
|
1750
|
+
// canonical cover is untouched, provenance updates to
|
|
1751
|
+
// describe the previewed frame — which is what makes a
|
|
1752
|
+
// follow-up blank-field Apply adopt it via the cheap path,
|
|
1753
|
+
// and the one-off note riding back is the panel's disclosure
|
|
1754
|
+
// of exactly that.
|
|
1755
|
+
outPath: coverPreviewPath(),
|
|
1756
|
+
},
|
|
1757
|
+
{ renderCover: opts.renderCover, log: (line) => notes.push(line) },
|
|
1758
|
+
);
|
|
1759
|
+
return send(200, {
|
|
1760
|
+
ok: true,
|
|
1761
|
+
notes,
|
|
1762
|
+
// coverImageUrl's mtime rule, aimed at the preview file: the
|
|
1763
|
+
// URL changes exactly when the file does.
|
|
1764
|
+
previewImageUrl: `/api/cover/preview-image?ts=${Math.round(
|
|
1765
|
+
statSync(coverPreviewPath()).mtimeMs,
|
|
1766
|
+
)}`,
|
|
1767
|
+
});
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1770
|
+
} finally {
|
|
1771
|
+
coverBusy = false;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1286
1775
|
if (url.pathname.startsWith("/media/")) {
|
|
1287
1776
|
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1288
1777
|
const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
|