ossclip 0.1.35 → 0.1.37
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 +11 -1
- package/editor-dist/assets/index-pWbFr8vc.js +168 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/analyze.ts +4 -0
- package/src/cover.ts +18 -0
- package/src/doctor.ts +38 -6
- package/src/edit.ts +204 -34
- package/src/interactive/produce-wizard.ts +13 -2
- package/src/produce.ts +330 -45
- package/src/program.ts +68 -0
- package/src/setup/plan.ts +18 -0
- package/src/whisper-backend.ts +103 -0
- package/editor-dist/assets/index-DSB_SCmp.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-pWbFr8vc.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.37",
|
|
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/
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
39
|
+
"@ossclip/renderer": "0.1.37",
|
|
40
|
+
"@ossclip/core": "0.1.37",
|
|
41
|
+
"@ossclip/scenes": "0.1.37"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/analyze.ts
CHANGED
|
@@ -117,6 +117,9 @@ export interface AnalyzeOptions {
|
|
|
117
117
|
noiseDb?: number;
|
|
118
118
|
whisperModel?: string;
|
|
119
119
|
whisperLanguage?: string;
|
|
120
|
+
/** `--whisper-backend`, already zod-parsed by program.ts; undefined lets a
|
|
121
|
+
* configured `whisperUrl` decide (resolveWhisperBackend in produce). */
|
|
122
|
+
whisperBackend?: "local" | "remote";
|
|
120
123
|
blooperMarker?: string;
|
|
121
124
|
collapseRetakes?: boolean;
|
|
122
125
|
sort?: "name" | "mtime";
|
|
@@ -157,6 +160,7 @@ export async function runAnalyze(
|
|
|
157
160
|
noiseDb: opts.noiseDb,
|
|
158
161
|
whisperModel: opts.whisperModel,
|
|
159
162
|
whisperLanguage: opts.whisperLanguage,
|
|
163
|
+
whisperBackend: opts.whisperBackend,
|
|
160
164
|
blooperMarker: opts.blooperMarker,
|
|
161
165
|
collapseRetakes: opts.collapseRetakes,
|
|
162
166
|
sort: opts.sort,
|
package/src/cover.ts
CHANGED
|
@@ -468,6 +468,24 @@ export function coverTextHold(args: {
|
|
|
468
468
|
`(--cover-text-reset, or deleting ${COVER_PROVENANCE_BASENAME}, goes back to the generated one)`,
|
|
469
469
|
};
|
|
470
470
|
}
|
|
471
|
+
// The replay-render gap (field report 2026-08-31): a run without --produce
|
|
472
|
+
// generates NO headline, and letting that empty string win silently
|
|
473
|
+
// downgraded a banner cover to a bare frame on every render-from-the-
|
|
474
|
+
// editor. A persisted headline of ANY source outranks an empty generation
|
|
475
|
+
// — there is no fresher text to prefer. `reset` still goes bare: that is
|
|
476
|
+
// the user explicitly asking for the generated (here: no) headline.
|
|
477
|
+
if (
|
|
478
|
+
!args.reset &&
|
|
479
|
+
args.generated.trim() === "" &&
|
|
480
|
+
args.persisted !== null &&
|
|
481
|
+
args.persisted.text.trim() !== ""
|
|
482
|
+
) {
|
|
483
|
+
return {
|
|
484
|
+
text: args.persisted.text,
|
|
485
|
+
textSource: args.persisted.textSource,
|
|
486
|
+
message: `▸ cover: keeping the previous headline "${args.persisted.text}" (this run generated none)`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
471
489
|
return { text: args.generated, textSource: "beatsheet" };
|
|
472
490
|
}
|
|
473
491
|
|
package/src/doctor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import type { OssclipConfig } from "@ossclip/core";
|
|
4
4
|
import { modelUrl, validModelSources, whisperModelPath } from "./setup/manifest";
|
|
5
|
+
import { WHISPER_API_KEY_ENV, resolveWhisperBackend } from "./whisper-backend";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `ossclip doctor` (R18 §90a): check every prerequisite and print the exact
|
|
@@ -106,12 +107,23 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
106
107
|
}),
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
// Remote transcription (2026-09-01 weak-CPU field report) makes the next
|
|
111
|
+
// two checks OPTIONAL rather than blocking: a machine that transcribes on
|
|
112
|
+
// Groq has no reason to own whisper.cpp or a 1.5 GB model, and doctor
|
|
113
|
+
// reporting two red lines on a working install is how a user concludes the
|
|
114
|
+
// tool is broken. The LLM-provider posture, one level up: pass with a
|
|
115
|
+
// detail that says why nothing is needed.
|
|
116
|
+
const remote = resolveWhisperBackend(undefined, cfg, p.env);
|
|
117
|
+
const remoteBackend = remote.ok && remote.backend.kind === "remote" ? remote.backend : null;
|
|
118
|
+
const notNeeded = (found: string): string =>
|
|
119
|
+
`${found} not found — not needed: remote transcription configured`;
|
|
120
|
+
|
|
109
121
|
const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
|
|
110
122
|
checks.push({
|
|
111
123
|
name: "whisper-cli",
|
|
112
|
-
ok: whisperOk,
|
|
113
|
-
detail: cfg.whisperPath,
|
|
114
|
-
...(whisperOk
|
|
124
|
+
ok: whisperOk || remoteBackend !== null,
|
|
125
|
+
detail: whisperOk ? cfg.whisperPath : remoteBackend !== null ? notNeeded(cfg.whisperPath) : cfg.whisperPath,
|
|
126
|
+
...(whisperOk || remoteBackend !== null
|
|
115
127
|
? {}
|
|
116
128
|
: {
|
|
117
129
|
fix: viaSetup(
|
|
@@ -134,9 +146,9 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
134
146
|
const modelOk = p.exists(modelPath);
|
|
135
147
|
checks.push({
|
|
136
148
|
name: `whisper model (${cfg.model})`,
|
|
137
|
-
ok: modelOk,
|
|
138
|
-
detail: modelPath,
|
|
139
|
-
...(modelOk
|
|
149
|
+
ok: modelOk || remoteBackend !== null,
|
|
150
|
+
detail: modelOk ? modelPath : remoteBackend !== null ? notNeeded(modelPath) : modelPath,
|
|
151
|
+
...(modelOk || remoteBackend !== null
|
|
140
152
|
? {}
|
|
141
153
|
: {
|
|
142
154
|
fix: viaSetup(
|
|
@@ -146,6 +158,26 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
146
158
|
}),
|
|
147
159
|
});
|
|
148
160
|
|
|
161
|
+
// NO network call, unlike every other backend doctor could probe: a
|
|
162
|
+
// transcription request costs the user's metered free tier, and `doctor` is
|
|
163
|
+
// run repeatedly while fixing something else. This line reports the
|
|
164
|
+
// CONFIGURATION — the three things a 401 or a 404 would be about — and the
|
|
165
|
+
// provider's own status hints name the rest when a real run happens.
|
|
166
|
+
// Omitted entirely when remote is not configured: the local install is the
|
|
167
|
+
// default, and an extra "not configured" line for an opt-in feature is
|
|
168
|
+
// noise on every other machine.
|
|
169
|
+
if (remoteBackend !== null) {
|
|
170
|
+
checks.push({
|
|
171
|
+
name: "remote transcription",
|
|
172
|
+
ok: true,
|
|
173
|
+
detail:
|
|
174
|
+
`${remoteBackend.baseUrl} · model ${remoteBackend.model} · ` +
|
|
175
|
+
(remoteBackend.apiKey !== undefined
|
|
176
|
+
? `${WHISPER_API_KEY_ENV} set`
|
|
177
|
+
: `no API key (fine for self-hosted; Groq needs ${WHISPER_API_KEY_ENV})`),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
// Provider, in the same order auto-detection uses (agy → claude CLI →
|
|
150
182
|
// gemini key → anthropic key): subscription CLIs beat ambient env keys
|
|
151
183
|
// since 2026-08 — a logged-in CLI is an explicit, already-paid choice
|
package/src/edit.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
|
|
4
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
|
-
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { z } from "zod/v4";
|
|
10
10
|
import {
|
|
@@ -56,12 +56,25 @@ import {
|
|
|
56
56
|
// Resolved here too, not just in produce: an editor that offered stock sounds
|
|
57
57
|
// the config excludes would let a user pick one the next render drops.
|
|
58
58
|
resolveSfxBundledPack,
|
|
59
|
+
// The Color panel's .cube menu (2026-08-30): the same loader produce's LUT
|
|
60
|
+
// bake resolves against, the loadSfxLibrary rule for grades.
|
|
61
|
+
loadLutLibrary,
|
|
62
|
+
// …and the validator every grade layer goes through — the /api/luts payload
|
|
63
|
+
// carries the CONFIG grade so the panel can label its "Default" entry, and
|
|
64
|
+
// a malformed config value must read as "no default" there exactly as it
|
|
65
|
+
// reads in produce (`resolveProductionColorGrade` falls through to off).
|
|
66
|
+
resolveColorGrade,
|
|
59
67
|
outInsideInputFolderMessage,
|
|
60
68
|
outPathInsideInput,
|
|
61
69
|
PORTRAIT_MIME_TYPES,
|
|
62
70
|
portraitMimeType,
|
|
63
71
|
readCoverProvenance,
|
|
64
72
|
runWhisper,
|
|
73
|
+
// The remote transcription backend (2026-09-01) — the span re-decode goes
|
|
74
|
+
// through it whenever `whisperUrl` is configured.
|
|
75
|
+
createOpenAiCompatibleProvider,
|
|
76
|
+
type TranscribeRequest,
|
|
77
|
+
type Transcript,
|
|
65
78
|
SegmentSchema,
|
|
66
79
|
spliceTranscript,
|
|
67
80
|
TranscriptSchema,
|
|
@@ -108,6 +121,7 @@ import { expandHome } from "./paths";
|
|
|
108
121
|
// produce all resolve through — a second copy here would send a user to a
|
|
109
122
|
// model file the rest of the tool never looks for.
|
|
110
123
|
import { modelImpliedLanguage, whisperModelPath } from "./setup/manifest";
|
|
124
|
+
import { resolveWhisperBackend } from "./whisper-backend";
|
|
111
125
|
import {
|
|
112
126
|
PORTRAIT_OVERRIDE_BASENAME,
|
|
113
127
|
portraitExtensionForMime,
|
|
@@ -119,6 +133,7 @@ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
|
|
|
119
133
|
import { captionRegenProvider } from "./caption-regen-panel";
|
|
120
134
|
import { binOnPath } from "./llm-detect";
|
|
121
135
|
import {
|
|
136
|
+
YOUTUBE_PRIVACIES,
|
|
122
137
|
attachDeliveryMedia,
|
|
123
138
|
buildPublishPosts,
|
|
124
139
|
publishConfigured,
|
|
@@ -189,6 +204,37 @@ export interface RetranscribeConfig {
|
|
|
189
204
|
modelDir?: string;
|
|
190
205
|
language?: unknown;
|
|
191
206
|
dictionary?: unknown;
|
|
207
|
+
/** The remote-backend pair (2026-09-01): a configured URL means this span
|
|
208
|
+
* is decoded by an OpenAI-compatible server instead of whisper.cpp, so the
|
|
209
|
+
* server must see the same two keys `resolveWhisperBackend` reads. */
|
|
210
|
+
whisperUrl?: string;
|
|
211
|
+
whisperRemoteModel?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The half of a range re-decode that is the same on BOTH backends: the
|
|
216
|
+
* decoder bias. Extracted (2026-09-01) so the remote path shares these rules
|
|
217
|
+
* verbatim instead of growing a second copy that drifts — the validation is
|
|
218
|
+
* the load-bearing part, and it is unchanged from the local-only version.
|
|
219
|
+
*/
|
|
220
|
+
function retranscribeBias(cfg: RetranscribeConfig): { language?: string; prompt?: string } {
|
|
221
|
+
const dict = Array.isArray(cfg.dictionary)
|
|
222
|
+
&& cfg.dictionary.length > 0
|
|
223
|
+
&& cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
|
|
224
|
+
? (cfg.dictionary as string[]).map((t) => t.trim())
|
|
225
|
+
: [];
|
|
226
|
+
// `cfg.model` can be absent on the remote backend (a machine that never
|
|
227
|
+
// installed a local model), and no model name implies no language.
|
|
228
|
+
const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
|
|
229
|
+
? cfg.language.trim()
|
|
230
|
+
: cfg.model !== undefined
|
|
231
|
+
? modelImpliedLanguage(cfg.model)
|
|
232
|
+
: undefined;
|
|
233
|
+
const prompt = whisperPromptFor(dict);
|
|
234
|
+
return {
|
|
235
|
+
...(language !== undefined ? { language } : {}),
|
|
236
|
+
...(prompt !== undefined ? { prompt } : {}),
|
|
237
|
+
};
|
|
192
238
|
}
|
|
193
239
|
|
|
194
240
|
/**
|
|
@@ -229,21 +275,38 @@ export function retranscribeSettings(
|
|
|
229
275
|
"then `ossclip setup` to install it.",
|
|
230
276
|
};
|
|
231
277
|
}
|
|
232
|
-
const dict = Array.isArray(cfg.dictionary)
|
|
233
|
-
&& cfg.dictionary.length > 0
|
|
234
|
-
&& cfg.dictionary.every((t) => typeof t === "string" && t.trim().length > 0)
|
|
235
|
-
? (cfg.dictionary as string[]).map((t) => t.trim())
|
|
236
|
-
: [];
|
|
237
|
-
const language = typeof cfg.language === "string" && cfg.language.trim().length > 0
|
|
238
|
-
? cfg.language.trim()
|
|
239
|
-
: modelImpliedLanguage(cfg.model);
|
|
240
|
-
const prompt = whisperPromptFor(dict);
|
|
241
278
|
return {
|
|
242
279
|
tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
|
|
243
280
|
whisperPath: cfg.whisperPath,
|
|
244
281
|
modelPath: whisperModelPath(cfg.model, cfg.modelDir),
|
|
245
|
-
...(
|
|
246
|
-
|
|
282
|
+
...retranscribeBias(cfg),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The same thing for the REMOTE backend (2026-09-01): no whisper binary and
|
|
288
|
+
* no model file, because the point of remote is that neither is installed —
|
|
289
|
+
* but ffmpeg still is, since the span is sliced out of `audio.wav` locally
|
|
290
|
+
* before it is uploaded.
|
|
291
|
+
*
|
|
292
|
+
* Pure, like its local twin: the whole "remote configured but ffmpeg isn't"
|
|
293
|
+
* corner is testable without a network.
|
|
294
|
+
*/
|
|
295
|
+
export function retranscribeRemoteSettings(
|
|
296
|
+
cfg: RetranscribeConfig,
|
|
297
|
+
):
|
|
298
|
+
| { tools: { ffmpegPath: string; ffprobePath: string }; language?: string; prompt?: string }
|
|
299
|
+
| { error: string } {
|
|
300
|
+
if (!cfg.ffmpegPath || !cfg.ffprobePath) {
|
|
301
|
+
return {
|
|
302
|
+
error:
|
|
303
|
+
"ffmpeg is not configured — run `ossclip doctor` to see what is missing, " +
|
|
304
|
+
"then `ossclip setup` to install it. (Remote transcription still slices the span locally.)",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
tools: { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath },
|
|
309
|
+
...retranscribeBias(cfg),
|
|
247
310
|
};
|
|
248
311
|
}
|
|
249
312
|
|
|
@@ -436,6 +499,10 @@ export async function startEditServer(
|
|
|
436
499
|
* `unknown` because it is file-only and typed at the consumer
|
|
437
500
|
* (`resolveSfxBundledPack`), the `audience` rule. */
|
|
438
501
|
sfxBundledPack?: unknown;
|
|
502
|
+
/** The config-level default grade the Color panel's "Default" entry
|
|
503
|
+
* names — `unknown` because it is file-only and typed at the consumer
|
|
504
|
+
* (`resolveColorGrade`), the `sfxBundledPack` rule. */
|
|
505
|
+
colorGrade?: unknown;
|
|
439
506
|
} & RetranscribeConfig;
|
|
440
507
|
/** Env seam for the publish endpoints — tests inject their own so the
|
|
441
508
|
* runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
|
|
@@ -471,6 +538,13 @@ export async function startEditServer(
|
|
|
471
538
|
*/
|
|
472
539
|
sliceAudio?: typeof extractAudioSpan;
|
|
473
540
|
runWhisper?: typeof runWhisper;
|
|
541
|
+
/**
|
|
542
|
+
* The remote backend's half of the `runWhisper` seam (2026-09-01): with a
|
|
543
|
+
* `whisperUrl` configured the span goes to an OpenAI-compatible server
|
|
544
|
+
* instead of whisper.cpp, and a test must be able to observe that —
|
|
545
|
+
* including the failure sentence — without a network or an API key.
|
|
546
|
+
*/
|
|
547
|
+
transcribeRemote?: (wavPath: string, req: TranscribeRequest) => Promise<Transcript>;
|
|
474
548
|
/**
|
|
475
549
|
* The sound library the SFX routes serve (`loadCfg`'s rule applied to the
|
|
476
550
|
* pack loader): tests inject a hand-written library over a tmp dir, so the
|
|
@@ -481,6 +555,10 @@ export async function startEditServer(
|
|
|
481
555
|
* loader rather than only that the routes serve what they were handed.
|
|
482
556
|
*/
|
|
483
557
|
loadSfx?: typeof loadSfxLibrary;
|
|
558
|
+
/** The LUT library `/api/luts` serves — the `loadSfx` seam for grades:
|
|
559
|
+
* tests inject a hand-written library instead of depending on whatever
|
|
560
|
+
* the developer keeps in ~/.ossclip/luts. */
|
|
561
|
+
loadLuts?: typeof loadLutLibrary;
|
|
484
562
|
} = {},
|
|
485
563
|
): Promise<EditServer> {
|
|
486
564
|
// MUTABLE since R17 §83: the server can start with no project (the page
|
|
@@ -938,17 +1016,67 @@ export async function startEditServer(
|
|
|
938
1016
|
error: "this workdir has no transcript.json to re-stamp — re-run `ossclip produce`.",
|
|
939
1017
|
});
|
|
940
1018
|
}
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1019
|
+
const cfg = (opts.loadCfg ?? loadConfig)();
|
|
1020
|
+
// Which engine re-decodes the span, resolved HERE rather than in
|
|
1021
|
+
// retranscribeSettings (2026-09-01): the flag is a CLI thing and
|
|
1022
|
+
// there is no CLI in this loop, so a configured `whisperUrl` is
|
|
1023
|
+
// the whole switch. `undefined` as the flag can only answer ok —
|
|
1024
|
+
// only an explicit `--whisper-backend remote` with nothing
|
|
1025
|
+
// configured fails — so this is a narrowing, not a live branch.
|
|
1026
|
+
const backendPick = resolveWhisperBackend(undefined, cfg, process.env);
|
|
1027
|
+
if (!backendPick.ok) return send(200, { ok: false, error: backendPick.message });
|
|
1028
|
+
const backend = backendPick.backend;
|
|
1029
|
+
// One plan, two shapes: the local one needs a binary and a model
|
|
1030
|
+
// file on disk, the remote one needs neither. Both need ffmpeg —
|
|
1031
|
+
// the span is always sliced here.
|
|
1032
|
+
let plan: {
|
|
1033
|
+
tools: { ffmpegPath: string; ffprobePath: string };
|
|
1034
|
+
language?: string;
|
|
1035
|
+
prompt?: string;
|
|
1036
|
+
decode: (wavPath: string, req: TranscribeRequest) => Promise<Transcript>;
|
|
1037
|
+
};
|
|
1038
|
+
if (backend.kind === "remote") {
|
|
1039
|
+
const settings = retranscribeRemoteSettings(cfg);
|
|
1040
|
+
if ("error" in settings) return send(200, { ok: false, error: settings.error });
|
|
1041
|
+
const provider = createOpenAiCompatibleProvider({
|
|
1042
|
+
baseUrl: backend.baseUrl,
|
|
1043
|
+
model: backend.model,
|
|
1044
|
+
...(backend.apiKey !== undefined ? { apiKey: backend.apiKey } : {}),
|
|
951
1045
|
});
|
|
1046
|
+
plan = {
|
|
1047
|
+
...settings,
|
|
1048
|
+
// Uploaded AS-IS, no opus sidecar (produce's rule does not
|
|
1049
|
+
// apply): a span is seconds long, so its wav is far under any
|
|
1050
|
+
// upload cap and the encode would cost more than it saves.
|
|
1051
|
+
decode: opts.transcribeRemote ?? ((wavPath, r) => provider.transcribe(wavPath, r)),
|
|
1052
|
+
};
|
|
1053
|
+
} else {
|
|
1054
|
+
const settings = retranscribeSettings(cfg);
|
|
1055
|
+
if ("error" in settings) return send(200, { ok: false, error: settings.error });
|
|
1056
|
+
if (!existsSync(settings.modelPath)) {
|
|
1057
|
+
// The `--transcript`-only install: whisper was never needed to
|
|
1058
|
+
// make this project, so say what to run rather than 500ing.
|
|
1059
|
+
return send(200, {
|
|
1060
|
+
ok: false,
|
|
1061
|
+
error:
|
|
1062
|
+
`whisper model not found at ${settings.modelPath} — run \`ossclip setup\` ` +
|
|
1063
|
+
`to download it.`,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
plan = {
|
|
1067
|
+
...settings,
|
|
1068
|
+
decode: (wavPath, r) =>
|
|
1069
|
+
(opts.runWhisper ?? runWhisper)(
|
|
1070
|
+
{
|
|
1071
|
+
whisperPath: settings.whisperPath,
|
|
1072
|
+
modelPath: settings.modelPath,
|
|
1073
|
+
outBase,
|
|
1074
|
+
...(r.language !== undefined ? { language: r.language } : {}),
|
|
1075
|
+
...(r.prompt !== undefined ? { prompt: r.prompt } : {}),
|
|
1076
|
+
},
|
|
1077
|
+
wavPath,
|
|
1078
|
+
),
|
|
1079
|
+
};
|
|
952
1080
|
}
|
|
953
1081
|
// Parsed, not cast: this file is about to be rewritten, and a
|
|
954
1082
|
// truncated one must fail loudly here rather than become the new
|
|
@@ -968,22 +1096,16 @@ export async function startEditServer(
|
|
|
968
1096
|
});
|
|
969
1097
|
}
|
|
970
1098
|
await (opts.sliceAudio ?? extractAudioSpan)(
|
|
971
|
-
|
|
1099
|
+
plan.tools,
|
|
972
1100
|
audio,
|
|
973
1101
|
tmpWav,
|
|
974
1102
|
srcIn,
|
|
975
1103
|
srcOut - srcIn,
|
|
976
1104
|
);
|
|
977
|
-
const fresh = await (
|
|
978
|
-
{
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
outBase,
|
|
982
|
-
...(settings.language !== undefined ? { language: settings.language } : {}),
|
|
983
|
-
...(settings.prompt !== undefined ? { prompt: settings.prompt } : {}),
|
|
984
|
-
},
|
|
985
|
-
tmpWav,
|
|
986
|
-
);
|
|
1105
|
+
const fresh = await plan.decode(tmpWav, {
|
|
1106
|
+
...(plan.language !== undefined ? { language: plan.language } : {}),
|
|
1107
|
+
...(plan.prompt !== undefined ? { prompt: plan.prompt } : {}),
|
|
1108
|
+
});
|
|
987
1109
|
const restamped = alignRestamp(
|
|
988
1110
|
transcript.words.slice(range.from, range.to),
|
|
989
1111
|
fresh.words,
|
|
@@ -1971,6 +2093,14 @@ export async function startEditServer(
|
|
|
1971
2093
|
// What uploads (the CLI's --delivery): auto (default) builds
|
|
1972
2094
|
// the cached delivery encode, master sends the untouched render.
|
|
1973
2095
|
delivery: z.enum(["auto", "master"]).optional(),
|
|
2096
|
+
// YouTube's privacy status (the CLI's --youtube-privacy),
|
|
2097
|
+
// spelled ONCE — the flag's own value list, so the panel and
|
|
2098
|
+
// the CLI can never accept different words. Absent leaves
|
|
2099
|
+
// buildPostsPayload's safe private default alone: until this
|
|
2100
|
+
// rode along, every panel publish landed private with no way
|
|
2101
|
+
// to say otherwise, and two videos the user believed were
|
|
2102
|
+
// published sat private on the channel (2026-08-29).
|
|
2103
|
+
youtubePrivacy: z.enum(YOUTUBE_PRIVACIES).optional(),
|
|
1974
2104
|
})
|
|
1975
2105
|
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
1976
2106
|
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
@@ -2077,7 +2207,14 @@ export async function startEditServer(
|
|
|
2077
2207
|
}
|
|
2078
2208
|
capGroups = sizeCapGroups(picked);
|
|
2079
2209
|
}
|
|
2080
|
-
|
|
2210
|
+
// Same options object the CLI's publish path passes — undefined
|
|
2211
|
+
// stays undefined so buildPostsPayload's safe private default is
|
|
2212
|
+
// still the ONE place that decides an absent privacy.
|
|
2213
|
+
const posts = buildPublishPosts(pack, picked, {
|
|
2214
|
+
...(parsed.data.youtubePrivacy !== undefined
|
|
2215
|
+
? { youtubePrivacy: parsed.data.youtubePrivacy }
|
|
2216
|
+
: {}),
|
|
2217
|
+
}).map((p) => ({
|
|
2081
2218
|
...p,
|
|
2082
2219
|
caption: parsed.data.captions?.[p.target.id] ?? p.caption,
|
|
2083
2220
|
}));
|
|
@@ -2624,6 +2761,39 @@ export async function startEditServer(
|
|
|
2624
2761
|
});
|
|
2625
2762
|
}
|
|
2626
2763
|
|
|
2764
|
+
if (url.pathname === "/api/luts" && req.method === "GET") {
|
|
2765
|
+
// The Color panel's .cube menu plus the config-level default grade.
|
|
2766
|
+
// NO workdir guard, /api/sfx/library's rule: both halves are
|
|
2767
|
+
// machine-global (~/.ossclip/luts and config.json), so the panel can
|
|
2768
|
+
// build its dropdown before a project is open. Read per request like
|
|
2769
|
+
// every other loadCfg consumer — a LUT dropped while the editor is
|
|
2770
|
+
// up appears on the next refresh, not on a restart.
|
|
2771
|
+
const library = (opts.loadLuts ?? loadLutLibrary)();
|
|
2772
|
+
// The config grade rides along VALIDATED, not raw: a malformed
|
|
2773
|
+
// config value is what produce ignores (`resolveProductionColorGrade`
|
|
2774
|
+
// warns and proceeds without it), so a "Default (…)" entry built
|
|
2775
|
+
// from it would offer an inherit that renders as nothing. Null means
|
|
2776
|
+
// the panel shows no Default entry, and the warning is dropped for
|
|
2777
|
+
// the sfxLibrary helper's reason — no console here, produce prints
|
|
2778
|
+
// it on the run that grades.
|
|
2779
|
+
const configGrade = resolveColorGrade(
|
|
2780
|
+
(opts.loadCfg ?? loadConfig)().colorGrade,
|
|
2781
|
+
"config",
|
|
2782
|
+
).grade;
|
|
2783
|
+
return send(200, {
|
|
2784
|
+
// METADATA only, the sfx library rule: the absolute `path` stays
|
|
2785
|
+
// server-side. `file` (the basename, extension and all) is what an
|
|
2786
|
+
// editor-written override must carry — `ColorGrade.lut` documents
|
|
2787
|
+
// the basename, produce joins it against ~/.ossclip/luts verbatim,
|
|
2788
|
+
// and a stem-only id would drop the `.CUBE` an exporter spelled.
|
|
2789
|
+
items: library.items.map((l) => ({ id: l.id, title: l.title, file: basename(l.path) })),
|
|
2790
|
+
// A ~/.ossclip/luts author's only surface, like the sfx panel:
|
|
2791
|
+
// the loader degraded instead of throwing, so show the reason.
|
|
2792
|
+
issues: library.issues,
|
|
2793
|
+
configGrade: configGrade ?? null,
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2627
2797
|
if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
|
|
2628
2798
|
// Click-to-preview. The path comes from the LOADED LIBRARY, never
|
|
2629
2799
|
// from the client: the query carries an id, the id is looked up, and
|
|
@@ -10,7 +10,7 @@ import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-
|
|
|
10
10
|
import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* The produce wizard. Forty-
|
|
13
|
+
* The produce wizard. Forty-four flags (plus the positional input path)
|
|
14
14
|
* sorted into three tiers: six prompts asked directly — the input path, plus
|
|
15
15
|
* five flags (--out, --cleanup, --aspect, --produce, --intent) — twelve
|
|
16
16
|
* behind one "anything else?" multiselect (--sfx being the twelfth, with
|
|
@@ -18,11 +18,22 @@ import { assertInteractive, confirm, intro, multiselect, select, text, unwrap }
|
|
|
18
18
|
* debug/internal surfaces, replay-only fields, --no-watermark (the
|
|
19
19
|
* multiselect only turns the credit ON; off is already the default),
|
|
20
20
|
* --no-youtube (the same shape: the pack entry only turns it ON),
|
|
21
|
+
* --color-grade (2026-08-30, the --resolution shape: a channel's look is a
|
|
22
|
+
* durable machine preference, set once as `colorGrade` in
|
|
23
|
+
* ~/.ossclip/config.json rather than re-picked per wizard run — and an
|
|
24
|
+
* honest prompt would need to enumerate ~/.ossclip/luts and preview five
|
|
25
|
+
* presets, a design nobody has made; --no-color-grade then mirrors
|
|
26
|
+
* --no-watermark's tier for the same off-is-default reason),
|
|
21
27
|
* --captions (the mirror case: ON is already the default, so the
|
|
22
28
|
* multiselect entry is the OFF switch and the positive flag exists only for
|
|
23
29
|
* replay pinning), --add-jump-cuts (same mirror: auto already punches, the
|
|
24
30
|
* multiselect entry is the OFF switch, and the force flag exists to beat a
|
|
25
|
-
* future config-off),
|
|
31
|
+
* future config-off),
|
|
32
|
+
* --whisper-backend (2026-09-01, the --color-grade shape: it selects machine
|
|
33
|
+
* INFRASTRUCTURE — which transcription engine this box owns, alongside
|
|
34
|
+
* whisperPath and modelDir — not a per-run editorial choice, and the durable
|
|
35
|
+
* spelling is `whisperUrl` in ~/.ossclip/config.json; an honest prompt would
|
|
36
|
+
* also have to explain a base URL and an API key at a menu), or
|
|
26
37
|
* (final-review fix wave, Finding 1) --sort. A folder's clip order only means anything once the
|
|
27
38
|
* folder has been enumerated, and that enumeration happens inside
|
|
28
39
|
* `produce()` — after the wizard has already returned argv — so there is
|