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