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/src/produce.ts
CHANGED
|
@@ -66,6 +66,10 @@ import {
|
|
|
66
66
|
splitThenDropHidden,
|
|
67
67
|
emptyOverrideDoc,
|
|
68
68
|
extractAudio,
|
|
69
|
+
encodeUploadAudio,
|
|
70
|
+
REMOTE_UPLOAD_MAX_BYTES,
|
|
71
|
+
createOpenAiCompatibleProvider,
|
|
72
|
+
openaiTranscriptionsUrl,
|
|
69
73
|
fillPlainCues,
|
|
70
74
|
splitCues,
|
|
71
75
|
landscapeLayout,
|
|
@@ -124,6 +128,18 @@ import {
|
|
|
124
128
|
makeMezzanine,
|
|
125
129
|
mezzanineFileName,
|
|
126
130
|
mezzanineScale,
|
|
131
|
+
// The color-grade pipeline (2026-08-30): validation, the preset/LUT split,
|
|
132
|
+
// the SVG filter spec preset grades ride render-props as, and the .cube
|
|
133
|
+
// bake+hash LUT grades ride the mezzanine as.
|
|
134
|
+
CONFIG_DIR,
|
|
135
|
+
resolveColorGrade,
|
|
136
|
+
resolveGradeToLook,
|
|
137
|
+
gradeToSvgFilterSpec,
|
|
138
|
+
parseCubeLut,
|
|
139
|
+
bakeCube,
|
|
140
|
+
lutHash,
|
|
141
|
+
type ColorGrade,
|
|
142
|
+
type SvgGradeFilterSpec,
|
|
127
143
|
scaleContentTimeline,
|
|
128
144
|
scaleFramingWindows,
|
|
129
145
|
measureFace,
|
|
@@ -237,6 +253,7 @@ import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from
|
|
|
237
253
|
import { reconcileCaptionEdits } from "./caption-report";
|
|
238
254
|
import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
|
|
239
255
|
import { recordedProduceArgs } from "./replay-argv";
|
|
256
|
+
import { remoteWhisperHost, resolveWhisperBackend } from "./whisper-backend";
|
|
240
257
|
import { makeCancelSignal, renderCover, renderProduction } from "@ossclip/renderer";
|
|
241
258
|
import type { RenderPhase } from "@ossclip/renderer";
|
|
242
259
|
import {
|
|
@@ -299,6 +316,17 @@ export const TranscriptKeySchema = z.object({
|
|
|
299
316
|
* files) means "no translation", the `dictionary` contract.
|
|
300
317
|
*/
|
|
301
318
|
translate: z.boolean().optional(),
|
|
319
|
+
/**
|
|
320
|
+
* Which BACKEND decoded it (2026-09-01): `remote:<normalized endpoint>`, or
|
|
321
|
+
* absent for local whisper.cpp — the `dictionary`/`translate` contract, so
|
|
322
|
+
* every key file written before remote existed still reads as local. Two
|
|
323
|
+
* engines on the same audio produce different words, so without this a warm
|
|
324
|
+
* workdir serves the local transcript to a remote run (and vice versa) —
|
|
325
|
+
* the exact staleness `language` and `translate` were added for. The
|
|
326
|
+
* remote MODEL name rides in `model` above, so switching Groq models
|
|
327
|
+
* re-keys through the existing field.
|
|
328
|
+
*/
|
|
329
|
+
backend: z.string().optional(),
|
|
302
330
|
});
|
|
303
331
|
export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
|
|
304
332
|
|
|
@@ -326,6 +354,10 @@ export function transcriptCacheReusable(
|
|
|
326
354
|
// Absent and false are the same "no translation", so pre-flag key
|
|
327
355
|
// files reuse under a non-translate request.
|
|
328
356
|
(effective.translate ?? false) === (requested.translate ?? false) &&
|
|
357
|
+
// Absent means LOCAL on both sides, so every pre-2026-09-01 key file
|
|
358
|
+
// still reuses under a local request — and a remote request against
|
|
359
|
+
// one of them re-transcribes, which is the point.
|
|
360
|
+
(effective.backend ?? "") === (requested.backend ?? "") &&
|
|
329
361
|
// ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
|
|
330
362
|
// text verbatim, so a reordered list genuinely is a different decoder
|
|
331
363
|
// input — treating it as equal would serve a transcript biased by a
|
|
@@ -622,6 +654,13 @@ export interface ProduceOptions {
|
|
|
622
654
|
* together (whisper decodes better knowing the source language).
|
|
623
655
|
*/
|
|
624
656
|
whisperTranslate?: boolean;
|
|
657
|
+
/**
|
|
658
|
+
* `--whisper-backend`, already zod-parsed to the union by program.ts
|
|
659
|
+
* (2026-09-01 weak-CPU field report). Undefined means "not typed", which
|
|
660
|
+
* is what lets a configured `whisperUrl` select remote — the flag is
|
|
661
|
+
* mainly `local`, the per-run opt-out.
|
|
662
|
+
*/
|
|
663
|
+
whisperBackend?: "local" | "remote";
|
|
625
664
|
/**
|
|
626
665
|
* Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
|
|
627
666
|
* split/trimmed by the action. Wholesale beats the config's `dictionary`
|
|
@@ -725,6 +764,16 @@ export interface ProduceOptions {
|
|
|
725
764
|
* ignore an uploaded cover.
|
|
726
765
|
*/
|
|
727
766
|
coverInVideo?: boolean;
|
|
767
|
+
/**
|
|
768
|
+
* `--color-grade <look>` / `--no-color-grade` — the watermark's tri-state
|
|
769
|
+
* carrying a VALUE: a string when typed (a preset id, or a `.cube`
|
|
770
|
+
* filename — `colorGradeFlagValue` classifies by extension), `false` for a
|
|
771
|
+
* typed --no-color-grade, undefined when neither so overrides.json and
|
|
772
|
+
* then the config's `colorGrade` decide (`resolveProductionColorGrade`).
|
|
773
|
+
* Deliberately unparsed in transit: validation warns-and-proceeds at the
|
|
774
|
+
* use site, because a grade typo must cost the look, never the run.
|
|
775
|
+
*/
|
|
776
|
+
colorGrade?: string | false;
|
|
728
777
|
/**
|
|
729
778
|
* `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
|
|
730
779
|
* true/false when TYPED, undefined when not — undefined lets the config's
|
|
@@ -861,6 +910,71 @@ export function resolveCoverInVideo(
|
|
|
861
910
|
return flag ?? configValue === true;
|
|
862
911
|
}
|
|
863
912
|
|
|
913
|
+
/**
|
|
914
|
+
* `--color-grade`'s value classified into the ColorGradeSchema shape: a value
|
|
915
|
+
* ending in `.cube` names a LUT file in `~/.ossclip/luts`, anything else
|
|
916
|
+
* names a preset. Sniffed by extension rather than split into two flags
|
|
917
|
+
* because the user already knows which they typed — `kodak.cube` cannot be a
|
|
918
|
+
* preset id (presets never carry a dot) and a preset id cannot be a LUT
|
|
919
|
+
* (`.cube` is the one format the parser reads), so the classification is
|
|
920
|
+
* lossless. Case-insensitive on the extension: `KODAK.CUBE` is the same file
|
|
921
|
+
* on the case-preserving filesystems the LUT dir lives on. Validation is NOT
|
|
922
|
+
* here — the shape goes through `resolveColorGrade` like every other layer.
|
|
923
|
+
*/
|
|
924
|
+
export function colorGradeFlagValue(value: string): { preset?: string; lut?: string } {
|
|
925
|
+
return value.toLowerCase().endsWith(".cube") ? { lut: value } : { preset: value };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The effective color grade across all three surfaces — override > flag >
|
|
930
|
+
* config, `resolveWatermark`'s typed-beats-config precedence grown one layer:
|
|
931
|
+
* the overrides doc is the editor's per-project say, so it beats even a typed
|
|
932
|
+
* flag (the `resolveSrcTimingPins` rationale — a per-project decision made in
|
|
933
|
+
* the editor outranks a per-run flag, never merges with it). An explicit
|
|
934
|
+
* `false` at a switching layer (`colorGrade: false` in the doc, or a typed
|
|
935
|
+
* `--no-color-grade`) is OFF, not fall-through: "no grade" is a decision, and
|
|
936
|
+
* letting a lower layer overrule it would make the disable impossible to
|
|
937
|
+
* express.
|
|
938
|
+
*
|
|
939
|
+
* Every layer is validated through `resolveColorGrade`, and an INVALID layer
|
|
940
|
+
* is ignored — warned about by name, then the NEXT layer applies (decision
|
|
941
|
+
* 2026-08-30): the alternative, an invalid override going all the way to
|
|
942
|
+
* "off", would let one stale editor write silently strip the config grade a
|
|
943
|
+
* channel's whole look depends on. Warnings are RETURNED, not printed
|
|
944
|
+
* (`resolveSfxLevel`'s shape), so the whole matrix is testable without a TTY.
|
|
945
|
+
* `source` names the winning layer so the ▸ line can say where a grade came
|
|
946
|
+
* from — the watermark's "(from config; --no-… overrides)" visibility rule.
|
|
947
|
+
*/
|
|
948
|
+
export function resolveProductionColorGrade(p: {
|
|
949
|
+
override: ColorGrade | false | undefined;
|
|
950
|
+
flag: string | false | undefined;
|
|
951
|
+
config: unknown;
|
|
952
|
+
}): { grade?: ColorGrade; source?: "override" | "flag" | "config"; warnings: string[] } {
|
|
953
|
+
const warnings: string[] = [];
|
|
954
|
+
if (p.override === false) return { warnings };
|
|
955
|
+
if (p.override !== undefined) {
|
|
956
|
+
// Schema-valid already (OverrideDocSchema parsed the doc), but the
|
|
957
|
+
// unknown-preset check lives in resolveColorGrade, not the schema — this
|
|
958
|
+
// is the layer where a preset the editor knew and this build doesn't
|
|
959
|
+
// falls through instead of failing the doc.
|
|
960
|
+
const r = resolveColorGrade(p.override, "overrides.json");
|
|
961
|
+
if (r.grade) return { grade: r.grade, source: "override", warnings };
|
|
962
|
+
if (r.warning) warnings.push(r.warning);
|
|
963
|
+
}
|
|
964
|
+
if (p.flag === false) return { warnings };
|
|
965
|
+
if (p.flag !== undefined) {
|
|
966
|
+
const r = resolveColorGrade(colorGradeFlagValue(p.flag), "--color-grade");
|
|
967
|
+
if (r.grade) return { grade: r.grade, source: "flag", warnings };
|
|
968
|
+
if (r.warning) warnings.push(r.warning);
|
|
969
|
+
}
|
|
970
|
+
// "config", not "config colorGrade": resolveColorGrade's warning already
|
|
971
|
+
// spells the key (`⚠ <source> colorGrade ignored — …`).
|
|
972
|
+
const r = resolveColorGrade(p.config, "config");
|
|
973
|
+
if (r.grade) return { grade: r.grade, source: "config", warnings };
|
|
974
|
+
if (r.warning) warnings.push(r.warning);
|
|
975
|
+
return { warnings };
|
|
976
|
+
}
|
|
977
|
+
|
|
864
978
|
/**
|
|
865
979
|
* `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
|
|
866
980
|
* and a run that quietly did nothing because the boolean was missing is the
|
|
@@ -2577,9 +2691,34 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2577
2691
|
`--whisper-language overrides)`,
|
|
2578
2692
|
);
|
|
2579
2693
|
}
|
|
2694
|
+
// Local whisper.cpp or an OpenAI-compatible server (2026-09-01 weak-CPU
|
|
2695
|
+
// field report). Resolved BEFORE the key, like the language, so what
|
|
2696
|
+
// actually decodes is what the cache is keyed on.
|
|
2697
|
+
const backendPick = resolveWhisperBackend(opts.whisperBackend, cfg, process.env);
|
|
2698
|
+
if (!backendPick.ok) throw new Error(backendPick.message);
|
|
2699
|
+
const backend = backendPick.backend;
|
|
2700
|
+
// BEFORE the key is built, so a translate request can never cross the
|
|
2701
|
+
// cache with a remote one: the OpenAI-compatible API translates on a
|
|
2702
|
+
// DIFFERENT endpoint with a DIFFERENT default model, and swapping both
|
|
2703
|
+
// behind one flag would be a surprise rather than a convenience.
|
|
2704
|
+
if (backend.kind === "remote" && opts.whisperTranslate === true) {
|
|
2705
|
+
throw new Error(
|
|
2706
|
+
"--whisper-translate needs the local backend (the OpenAI-compatible API translates on a " +
|
|
2707
|
+
"different endpoint and model) — use --whisper-backend local, or drop the flag.",
|
|
2708
|
+
);
|
|
2709
|
+
}
|
|
2580
2710
|
const requestedKey: TranscriptKey = {
|
|
2581
|
-
model
|
|
2711
|
+
// The REMOTE model name when remote — one field, both engines, so an
|
|
2712
|
+
// A/B between two Groq models re-keys the cache exactly like a local one.
|
|
2713
|
+
model: backend.kind === "remote" ? backend.model : requestedModel,
|
|
2582
2714
|
...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
|
|
2715
|
+
// Spread-omitted on local so local key files stay byte-identical to
|
|
2716
|
+
// every one written before remote existed (the translate posture). The
|
|
2717
|
+
// URL goes through openaiTranscriptionsUrl so ".../v1" and ".../v1/"
|
|
2718
|
+
// key identically — a trailing slash is not a different server.
|
|
2719
|
+
...(backend.kind === "remote"
|
|
2720
|
+
? { backend: `remote:${openaiTranscriptionsUrl(backend.baseUrl)}` }
|
|
2721
|
+
: {}),
|
|
2583
2722
|
// Omitted when off, so a non-translate run's key stays byte-identical to
|
|
2584
2723
|
// every pre-flag key file (the dictionary posture).
|
|
2585
2724
|
...(opts.whisperTranslate === true ? { translate: true } : {}),
|
|
@@ -2617,51 +2756,100 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2617
2756
|
`re-transcribing with ${fmt(requestedKey)}`,
|
|
2618
2757
|
);
|
|
2619
2758
|
}
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2759
|
+
if (backend.kind === "remote") {
|
|
2760
|
+
// No whisper binary and no model file on this branch — the whole point
|
|
2761
|
+
// of remote is that neither is installed (2026-09-01 field report).
|
|
2762
|
+
// ffmpeg still is: the upload sidecar is an encode.
|
|
2763
|
+
const host = remoteWhisperHost(backend.baseUrl);
|
|
2764
|
+
const uploadPath = join(work, "audio-upload.ogg");
|
|
2765
|
+
transcript = await phases.time("transcribe", async () => {
|
|
2766
|
+
await encodeUploadAudio(tools, audioPath, uploadPath);
|
|
2767
|
+
const bytes = statSync(uploadPath).size;
|
|
2768
|
+
if (bytes > REMOTE_UPLOAD_MAX_BYTES) {
|
|
2769
|
+
// Named here rather than paid for as somebody else's 413 after the
|
|
2770
|
+
// whole upload: chunking is out of scope for v1, so the error has
|
|
2771
|
+
// to carry both escape hatches itself.
|
|
2772
|
+
throw new Error(
|
|
2773
|
+
`the compressed audio is ${(bytes / 1_000_000).toFixed(1)} MB, over the ` +
|
|
2774
|
+
`${(REMOTE_UPLOAD_MAX_BYTES / 1_000_000).toFixed(0)} MB single-file limit for remote ` +
|
|
2775
|
+
`transcription (about 100 minutes of speech at this bitrate).\n` +
|
|
2776
|
+
`Transcribe locally with --whisper-backend local, split the take, or point ` +
|
|
2777
|
+
`OSSCLIP_WHISPER_URL at a server with a larger cap (Groq's dev tier allows 100 MB).`,
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
2780
|
+
const anim = isInteractive()
|
|
2781
|
+
? new StageAnimator(
|
|
2782
|
+
"REMOTE ASR",
|
|
2783
|
+
`Transcribing via ${host} (${backend.model})...`,
|
|
2784
|
+
"whisper",
|
|
2785
|
+
).start()
|
|
2786
|
+
: null;
|
|
2787
|
+
if (!anim) console.log(`▸ transcribing remotely (${host}, ${backend.model})…`);
|
|
2788
|
+
try {
|
|
2789
|
+
return await createOpenAiCompatibleProvider({
|
|
2790
|
+
baseUrl: backend.baseUrl,
|
|
2791
|
+
model: backend.model,
|
|
2792
|
+
...(backend.apiKey !== undefined ? { apiKey: backend.apiKey } : {}),
|
|
2793
|
+
}).transcribe(uploadPath, {
|
|
2794
|
+
// From the KEY, like the local branch: whatever re-keys the cache
|
|
2795
|
+
// is what actually decoded, so the two can never disagree.
|
|
2796
|
+
language: requestedKey.language,
|
|
2797
|
+
prompt: whisperPromptFor(dictionary),
|
|
2798
|
+
});
|
|
2799
|
+
} finally {
|
|
2800
|
+
// In a finally, unlike the local branch's trailing stop(): an HTTP
|
|
2801
|
+
// failure here is EXPECTED (a wrong key, a rate limit), and a
|
|
2802
|
+
// spinner still animating would overwrite the hint the user needs.
|
|
2803
|
+
anim?.stop();
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
} else {
|
|
2807
|
+
await preflight(
|
|
2808
|
+
cfg.whisperPath,
|
|
2809
|
+
"Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
|
|
2635
2810
|
);
|
|
2811
|
+
const model = requestedKey.model;
|
|
2812
|
+
// whisperModelPath/modelUrl are THE resolution and URL sources (shared
|
|
2813
|
+
// with doctor and setup) — this error used to hold its own copy of the
|
|
2814
|
+
// ggerganov URL, which 404'd for curated/custom names and the suggested
|
|
2815
|
+
// `curl -L` then saved the 404 HTML as a fake model.
|
|
2816
|
+
const modelPath = whisperModelPath(model, cfg.modelDir);
|
|
2817
|
+
if (!existsSync(modelPath)) {
|
|
2818
|
+
throw new Error(
|
|
2819
|
+
`whisper model not found at ${modelPath}.\n` +
|
|
2820
|
+
`Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
|
|
2821
|
+
` curl -L -o ${modelPath} ${modelUrl(model, validModelSources(cfg.modelSources))}`,
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
const whisperAnim = isInteractive()
|
|
2825
|
+
? new StageAnimator(
|
|
2826
|
+
"WHISPER ASR",
|
|
2827
|
+
`Transcribing audio stream with ${basename(modelPath)}...`,
|
|
2828
|
+
"whisper",
|
|
2829
|
+
).start()
|
|
2830
|
+
: null;
|
|
2831
|
+
if (!whisperAnim) console.log(`▸ transcribing (${basename(modelPath)})…`);
|
|
2832
|
+
transcript = await phases.time("transcribe", () =>
|
|
2833
|
+
runWhisper(
|
|
2834
|
+
{
|
|
2835
|
+
whisperPath: cfg.whisperPath,
|
|
2836
|
+
modelPath,
|
|
2837
|
+
outBase: join(work, "whisper"),
|
|
2838
|
+
// The RESOLVED language, not the raw flag — a config/model-implied
|
|
2839
|
+
// code must reach the spawn exactly as it reached the cache key.
|
|
2840
|
+
language: requestedKey.language,
|
|
2841
|
+
// Vocabulary biasing (F4) — undefined for an empty dictionary, so
|
|
2842
|
+
// the spawned args stay byte-identical to every pre-dictionary run.
|
|
2843
|
+
// From the KEY, like the language: whatever re-keys the cache is
|
|
2844
|
+
// what actually ran, so the two can never disagree.
|
|
2845
|
+
...(requestedKey.translate === true ? { translate: true } : {}),
|
|
2846
|
+
prompt: whisperPromptFor(dictionary),
|
|
2847
|
+
},
|
|
2848
|
+
audioPath,
|
|
2849
|
+
),
|
|
2850
|
+
);
|
|
2851
|
+
if (whisperAnim) whisperAnim.stop();
|
|
2636
2852
|
}
|
|
2637
|
-
const whisperAnim = isInteractive()
|
|
2638
|
-
? new StageAnimator(
|
|
2639
|
-
"WHISPER ASR",
|
|
2640
|
-
`Transcribing audio stream with ${basename(modelPath)}...`,
|
|
2641
|
-
"whisper",
|
|
2642
|
-
).start()
|
|
2643
|
-
: null;
|
|
2644
|
-
if (!whisperAnim) console.log(`▸ transcribing (${basename(modelPath)})…`);
|
|
2645
|
-
transcript = await phases.time("transcribe", () =>
|
|
2646
|
-
runWhisper(
|
|
2647
|
-
{
|
|
2648
|
-
whisperPath: cfg.whisperPath,
|
|
2649
|
-
modelPath,
|
|
2650
|
-
outBase: join(work, "whisper"),
|
|
2651
|
-
// The RESOLVED language, not the raw flag — a config/model-implied
|
|
2652
|
-
// code must reach the spawn exactly as it reached the cache key.
|
|
2653
|
-
language: requestedKey.language,
|
|
2654
|
-
// Vocabulary biasing (F4) — undefined for an empty dictionary, so
|
|
2655
|
-
// the spawned args stay byte-identical to every pre-dictionary run.
|
|
2656
|
-
// From the KEY, like the language: whatever re-keys the cache is
|
|
2657
|
-
// what actually ran, so the two can never disagree.
|
|
2658
|
-
...(requestedKey.translate === true ? { translate: true } : {}),
|
|
2659
|
-
prompt: whisperPromptFor(dictionary),
|
|
2660
|
-
},
|
|
2661
|
-
audioPath,
|
|
2662
|
-
),
|
|
2663
|
-
);
|
|
2664
|
-
if (whisperAnim) whisperAnim.stop();
|
|
2665
2853
|
console.log(`▸ transcribed ${transcript.words.length} words`);
|
|
2666
2854
|
await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
|
|
2667
2855
|
}
|
|
@@ -4714,6 +4902,88 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4714
4902
|
// on a bar-free source): there is no re-encode to scale, the render plays
|
|
4715
4903
|
// the source itself, and the window emissions below must then stay in true
|
|
4716
4904
|
// source pixels — which the identity `mezzFactor` below guarantees.
|
|
4905
|
+
// ---- Color grade (`--color-grade` / config `colorGrade` / the editor's
|
|
4906
|
+
// overrides.json) ---------------------------------------------------------
|
|
4907
|
+
// Resolved HERE, before the mezzanine encode, because the feature's two
|
|
4908
|
+
// halves split at exactly this seam: a PRESET grade rides render-props as
|
|
4909
|
+
// an SVG filter spec (the props assembly below), while a LUT grade is baked
|
|
4910
|
+
// INTO the mezzanine (ingest.ts's `lut` option) — ffmpeg's lut3d on the
|
|
4911
|
+
// encode pass costs nothing per rendered frame, where a 33³ trilinear
|
|
4912
|
+
// lookup in the browser would. Precedence and validation live in
|
|
4913
|
+
// `resolveProductionColorGrade`; every warning it returns prints once here,
|
|
4914
|
+
// and every failure path proceeds UNGRADED — a grade must cost the look at
|
|
4915
|
+
// worst, never the run.
|
|
4916
|
+
const gradeResolution = resolveProductionColorGrade({
|
|
4917
|
+
override: overrideDoc.colorGrade,
|
|
4918
|
+
flag: opts.colorGrade,
|
|
4919
|
+
config: cfg.colorGrade,
|
|
4920
|
+
});
|
|
4921
|
+
for (const w of gradeResolution.warnings) console.log(w);
|
|
4922
|
+
/** Preset grades: the spec render-props carries (absent = no grade). */
|
|
4923
|
+
let colorGradeSpec: SvgGradeFilterSpec | undefined;
|
|
4924
|
+
/** LUT grades: the baked .cube the mezzanine encode applies (absent = none). */
|
|
4925
|
+
let gradeLut: { path: string; hash: string } | undefined;
|
|
4926
|
+
if (gradeResolution.grade !== undefined) {
|
|
4927
|
+
// The watermark's visibility rule: a grade sourced anywhere but the
|
|
4928
|
+
// typed flag says so, so a config- or editor-sourced look never
|
|
4929
|
+
// surprises the author on upload.
|
|
4930
|
+
const gradeFromNote =
|
|
4931
|
+
gradeResolution.source === "config"
|
|
4932
|
+
? " (from config; --no-color-grade overrides)"
|
|
4933
|
+
: gradeResolution.source === "override"
|
|
4934
|
+
? " (editor override)"
|
|
4935
|
+
: "";
|
|
4936
|
+
const resolvedLook = resolveGradeToLook(gradeResolution.grade);
|
|
4937
|
+
if (resolvedLook.kind === "preset") {
|
|
4938
|
+
colorGradeSpec = gradeToSvgFilterSpec(resolvedLook);
|
|
4939
|
+
console.log(`▸ color grade: ${gradeResolution.grade.preset}${gradeFromNote}`);
|
|
4940
|
+
} else if (!mezzanineWillBuild) {
|
|
4941
|
+
// --no-mezzanine on a bar-free source: the render plays the source
|
|
4942
|
+
// file itself, so there is no encode to bake the LUT into. Warn and
|
|
4943
|
+
// proceed ungraded rather than force a mezzanine the user refused.
|
|
4944
|
+
console.log(
|
|
4945
|
+
`⚠ color grade skipped — a .cube LUT is baked into the mezzanine, ` +
|
|
4946
|
+
`and --no-mezzanine means this run doesn't build one`,
|
|
4947
|
+
);
|
|
4948
|
+
} else {
|
|
4949
|
+
try {
|
|
4950
|
+
// Basename only, enforced before any path math: `lut` is a NAME the
|
|
4951
|
+
// schema documents as living in ~/.ossclip/luts, and resolving a
|
|
4952
|
+
// separator-carrying value would turn a config key into a file probe
|
|
4953
|
+
// (the SfxAddedPlacement id's "nothing may ever resolve a path
|
|
4954
|
+
// against it" rule, applied at the one place this name meets the
|
|
4955
|
+
// filesystem).
|
|
4956
|
+
if (basename(resolvedLook.lutRef) !== resolvedLook.lutRef) {
|
|
4957
|
+
throw new Error(
|
|
4958
|
+
`"${resolvedLook.lutRef}" is not a bare filename — LUTs live in ${join(CONFIG_DIR, "luts")}`,
|
|
4959
|
+
);
|
|
4960
|
+
}
|
|
4961
|
+
const lutPath = join(CONFIG_DIR, "luts", resolvedLook.lutRef);
|
|
4962
|
+
const baseLut = parseCubeLut(readFileSync(lutPath, "utf8"));
|
|
4963
|
+
// Tweaks + intensity are baked into the cube (bakeCube composes
|
|
4964
|
+
// `params` on top of the base sample), so the hash keys the WHOLE
|
|
4965
|
+
// grade: change the intensity and the mezzanine filename changes
|
|
4966
|
+
// with it (`mezzanineFileName`'s existence-keyed cache).
|
|
4967
|
+
const cubeText = bakeCube({
|
|
4968
|
+
base: baseLut,
|
|
4969
|
+
params: resolvedLook.tweaks,
|
|
4970
|
+
intensity: resolvedLook.intensity,
|
|
4971
|
+
});
|
|
4972
|
+
const hash = lutHash(cubeText);
|
|
4973
|
+
const bakedPath = join(work, `grade-${hash}.cube`);
|
|
4974
|
+
await writeFile(bakedPath, cubeText);
|
|
4975
|
+
gradeLut = { path: bakedPath, hash };
|
|
4976
|
+
console.log(`▸ color grade: LUT ${resolvedLook.lutRef}${gradeFromNote}`);
|
|
4977
|
+
} catch (err) {
|
|
4978
|
+
// ENOENT and a malformed .cube land here alike: name the problem,
|
|
4979
|
+
// proceed ungraded. parseCubeLut's errors already carry the line.
|
|
4980
|
+
console.log(
|
|
4981
|
+
`⚠ color grade skipped — ${err instanceof Error ? err.message : String(err)}`,
|
|
4982
|
+
);
|
|
4983
|
+
}
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4717
4987
|
const mezzScale = mezzanineWillBuild
|
|
4718
4988
|
? mezzanineScale(
|
|
4719
4989
|
{ width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
|
|
@@ -4726,7 +4996,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4726
4996
|
// why): mezzanine caching is existence-keyed, so a pre-pass full-res
|
|
4727
4997
|
// mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
|
|
4728
4998
|
// windows — the scaled file rebuilds once under its own name.
|
|
4729
|
-
|
|
4999
|
+
// `gradeLut?.hash` rides the name so a graded mezzanine can never satisfy
|
|
5000
|
+
// an ungraded run (or vice versa) — the LUT is pixels in the file, and
|
|
5001
|
+
// the cache is existence-keyed.
|
|
5002
|
+
const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale, gradeLut?.hash));
|
|
4730
5003
|
if (!existsSync(mezz)) {
|
|
4731
5004
|
const mezzAnim = isInteractive()
|
|
4732
5005
|
? new StageAnimator(
|
|
@@ -4747,6 +5020,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4747
5020
|
await makeMezzanine(tools, input, mezz, {
|
|
4748
5021
|
cropVf: cropVf || undefined,
|
|
4749
5022
|
scale: mezzScale ?? undefined,
|
|
5023
|
+
// The LUT grade's whole delivery: baked into the encode, so the
|
|
5024
|
+
// render (and the editor's preview, which plays the same file) see
|
|
5025
|
+
// graded pixels with no per-frame cost.
|
|
5026
|
+
lut: gradeLut,
|
|
4750
5027
|
});
|
|
4751
5028
|
if (mezzAnim) mezzAnim.stop();
|
|
4752
5029
|
}
|
|
@@ -5069,6 +5346,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
5069
5346
|
// one, and the composition reads an absent key as silence, not as an empty
|
|
5070
5347
|
// track it still has to mount.
|
|
5071
5348
|
...(sfxCues.length > 0 ? { sfxCues } : {}),
|
|
5349
|
+
// `--color-grade` PRESET looks, written only when one resolved (the
|
|
5350
|
+
// watermark's absent-means-off contract): the two-stage SVG filter spec
|
|
5351
|
+
// ({tableR, tableG, tableB, colorMatrix}, gradeToSvgFilterSpec) the
|
|
5352
|
+
// composition mounts over the video. A LUT grade deliberately writes
|
|
5353
|
+
// NOTHING here — it was baked into the mezzanine above, so the pixels
|
|
5354
|
+
// the renderer plays already carry it, and a spec on top would grade
|
|
5355
|
+
// twice.
|
|
5356
|
+
...(colorGradeSpec ? { colorGrade: colorGradeSpec } : {}),
|
|
5072
5357
|
};
|
|
5073
5358
|
await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
|
|
5074
5359
|
|
package/src/program.ts
CHANGED
|
@@ -411,6 +411,12 @@ export function buildProgram(): Command {
|
|
|
411
411
|
"(whisper's -tr; pair with --whisper-language for the SOURCE language)",
|
|
412
412
|
false,
|
|
413
413
|
)
|
|
414
|
+
.option(
|
|
415
|
+
"--whisper-backend <backend>",
|
|
416
|
+
"local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
|
|
417
|
+
"audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
|
|
418
|
+
"configuring that URL already implies remote, so this flag is mainly `local` to opt out",
|
|
419
|
+
)
|
|
414
420
|
// COMMA-SEPARATED in one value, not variadic: a variadic option swallows
|
|
415
421
|
// the optional positional [input] whenever the flag precedes the path,
|
|
416
422
|
// and commander offers no way to give the positional priority.
|
|
@@ -472,6 +478,21 @@ export function buildProgram(): Command {
|
|
|
472
478
|
"(set it once with coverInVideo: true in ~/.ossclip/config.json)",
|
|
473
479
|
)
|
|
474
480
|
.option("--no-cover-in-video", "no cover overlay, even when the config turns it on")
|
|
481
|
+
// The watermark's tri-state carrying a VALUE: commander folds the pair
|
|
482
|
+
// onto one key (string when typed, false for --no-color-grade, undefined
|
|
483
|
+
// when neither — which is what lets overrides.json and then the config's
|
|
484
|
+
// `colorGrade` decide). The value is NOT parsed here, unlike --sfx-level:
|
|
485
|
+
// it may be a preset id OR a .cube filename, so an enum parse can't hold
|
|
486
|
+
// it — classification and validation live at the consumer
|
|
487
|
+
// (colorGradeFlagValue / resolveProductionColorGrade in produce.ts),
|
|
488
|
+
// where a typo warns and the run proceeds ungraded rather than dying.
|
|
489
|
+
.option(
|
|
490
|
+
"--color-grade <look>",
|
|
491
|
+
"color grade the footage: a preset (talking-head | teal-orange | filmic-fade | " +
|
|
492
|
+
"cwa | punchy | mono) or a .cube LUT filename from ~/.ossclip/luts " +
|
|
493
|
+
'(set it once with colorGrade: {"preset": "..."} in ~/.ossclip/config.json)',
|
|
494
|
+
)
|
|
495
|
+
.option("--no-color-grade", "no color grade, even when the config sets one")
|
|
475
496
|
// Same tri-state shape as --watermark above (positive declared first so
|
|
476
497
|
// commander's default stays undefined = "not typed"): the config's
|
|
477
498
|
// `youtube` key supplies the default (resolveYoutube), and a typed
|
|
@@ -646,6 +667,14 @@ export function buildProgram(): Command {
|
|
|
646
667
|
opts.whisperLanguage !== undefined
|
|
647
668
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
648
669
|
: undefined;
|
|
670
|
+
// An enum, unlike --whisper-language: there are exactly two backends,
|
|
671
|
+
// and a typo'd `--whisper-backend groq` silently running local whisper
|
|
672
|
+
// on the weak CPU the flag exists to spare is the --source-fit crop
|
|
673
|
+
// all over again.
|
|
674
|
+
const whisperBackend =
|
|
675
|
+
opts.whisperBackend !== undefined
|
|
676
|
+
? z.enum(["local", "remote"]).parse(opts.whisperBackend)
|
|
677
|
+
: undefined;
|
|
649
678
|
// --add-jump-cuts / --no-jump-cuts land on DIFFERENT commander keys
|
|
650
679
|
// (see the option declarations for why the pair can't share one);
|
|
651
680
|
// jumpCutsFlag reunites them into the tri-state ProduceOptions
|
|
@@ -705,6 +734,9 @@ export function buildProgram(): Command {
|
|
|
705
734
|
whisperModel: opts.whisperModel,
|
|
706
735
|
whisperLanguage,
|
|
707
736
|
whisperTranslate: opts.whisperTranslate === true,
|
|
737
|
+
// undefined = "not typed", so a configured whisperUrl decides
|
|
738
|
+
// (resolveWhisperBackend at the use site).
|
|
739
|
+
whisperBackend,
|
|
708
740
|
// Split/trim/drop-empties (dictionaryFlag) — undefined stays
|
|
709
741
|
// undefined so the config's dictionary can supply the default.
|
|
710
742
|
dictionary: dictionaryFlag(opts.dictionary),
|
|
@@ -722,6 +754,12 @@ export function buildProgram(): Command {
|
|
|
722
754
|
// The watermark's tri-state again, resolved by resolveCoverInVideo
|
|
723
755
|
// at the use site against the config's `coverInVideo`.
|
|
724
756
|
coverInVideo: opts.coverInVideo,
|
|
757
|
+
// string | false | undefined straight through: undefined = "not
|
|
758
|
+
// typed" lets overrides.json and then the config's `colorGrade`
|
|
759
|
+
// decide, and the value itself is classified and validated at the
|
|
760
|
+
// use site (resolveProductionColorGrade) — see the option's own
|
|
761
|
+
// comment for why no parse happens here.
|
|
762
|
+
colorGrade: opts.colorGrade,
|
|
725
763
|
// The same tri-state contract as watermark, resolved by
|
|
726
764
|
// resolveYoutube at the use site; --portrait rides along untyped =
|
|
727
765
|
// undefined so the config's path can supply it.
|
|
@@ -834,6 +872,15 @@ export function buildProgram(): Command {
|
|
|
834
872
|
"(whisper's -tr; pair with --whisper-language for the SOURCE language)",
|
|
835
873
|
false,
|
|
836
874
|
)
|
|
875
|
+
.option(
|
|
876
|
+
"--whisper-backend <backend>",
|
|
877
|
+
// Same sentence as produce's, deliberately: `transcribe` is the command
|
|
878
|
+
// a user drives while SETTING remote transcription up, so the half that
|
|
879
|
+
// says the URL is the real switch cannot be the half that is dropped here.
|
|
880
|
+
"local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
|
|
881
|
+
"audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
|
|
882
|
+
"configuring that URL already implies remote, so this flag is mainly `local` to opt out",
|
|
883
|
+
)
|
|
837
884
|
.action(async (input: string, opts) => {
|
|
838
885
|
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
839
886
|
const result = await produce(input, {
|
|
@@ -850,6 +897,12 @@ export function buildProgram(): Command {
|
|
|
850
897
|
opts.whisperLanguage !== undefined
|
|
851
898
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
852
899
|
: undefined,
|
|
900
|
+
// Parsed, not coerced — produce's reasoning: a typo must error, not
|
|
901
|
+
// fall back to the local backend the flag exists to avoid.
|
|
902
|
+
whisperBackend:
|
|
903
|
+
opts.whisperBackend !== undefined
|
|
904
|
+
? z.enum(["local", "remote"]).parse(opts.whisperBackend)
|
|
905
|
+
: undefined,
|
|
853
906
|
});
|
|
854
907
|
telemetry.record("transcribe_completed", {
|
|
855
908
|
cleanup_level: cleanup,
|
|
@@ -886,6 +939,15 @@ export function buildProgram(): Command {
|
|
|
886
939
|
"--whisper-language <code>",
|
|
887
940
|
"transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
|
|
888
941
|
)
|
|
942
|
+
.option(
|
|
943
|
+
"--whisper-backend <backend>",
|
|
944
|
+
// Same sentence as produce's, deliberately: `transcribe` is the command
|
|
945
|
+
// a user drives while SETTING remote transcription up, so the half that
|
|
946
|
+
// says the URL is the real switch cannot be the half that is dropped here.
|
|
947
|
+
"local | remote. local (default) runs whisper.cpp on this machine; remote posts the " +
|
|
948
|
+
"audio to the OpenAI-compatible server in OSSCLIP_WHISPER_URL (config: whisperUrl) — " +
|
|
949
|
+
"configuring that URL already implies remote, so this flag is mainly `local` to opt out",
|
|
950
|
+
)
|
|
889
951
|
.option(
|
|
890
952
|
"--blooper-marker <word>",
|
|
891
953
|
"mark the flubbed take wherever you say this word out loud (e.g. blooper). Off unless given",
|
|
@@ -914,6 +976,12 @@ export function buildProgram(): Command {
|
|
|
914
976
|
opts.whisperLanguage !== undefined
|
|
915
977
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
916
978
|
: undefined,
|
|
979
|
+
// Parsed, not coerced — produce's reasoning: a typo must error, not
|
|
980
|
+
// fall back to the local backend the flag exists to avoid.
|
|
981
|
+
whisperBackend:
|
|
982
|
+
opts.whisperBackend !== undefined
|
|
983
|
+
? z.enum(["local", "remote"]).parse(opts.whisperBackend)
|
|
984
|
+
: undefined,
|
|
917
985
|
blooperMarker: opts.blooperMarker,
|
|
918
986
|
collapseRetakes: opts.collapseRetakes,
|
|
919
987
|
sort: opts.sort === "mtime" ? "mtime" : "name",
|
package/src/setup/plan.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
whisperAsset,
|
|
10
10
|
whisperModelPath,
|
|
11
11
|
} from "./manifest";
|
|
12
|
+
import { resolveWhisperBackend } from "../whisper-backend";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The planning half of `ossclip setup` — pure over injected probes, like
|
|
@@ -111,10 +112,25 @@ export async function planSetup(
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
// Remote transcription (2026-09-01 weak-CPU field report) makes whisper.cpp
|
|
116
|
+
// and the model OPTIONAL: on the machine the report came from, downloading
|
|
117
|
+
// a 1.5 GB model to run an engine that is too slow to use is exactly the
|
|
118
|
+
// cliff remote exists to remove. Reported as `satisfied` with the reason,
|
|
119
|
+
// never as a silent skip — and a local install that ALREADY works still
|
|
120
|
+
// reports itself (ground rule one: setup never uninstalls, and a user who
|
|
121
|
+
// has both keeps the `--whisper-backend local` escape hatch working).
|
|
122
|
+
const remote = resolveWhisperBackend(undefined, cfg, p.env);
|
|
123
|
+
const remoteDetail =
|
|
124
|
+
remote.ok && remote.backend.kind === "remote"
|
|
125
|
+
? `remote transcription configured (${remote.backend.baseUrl}) — local whisper not needed`
|
|
126
|
+
: null;
|
|
127
|
+
|
|
114
128
|
const whisperOk = await p.binRuns(cfg.whisperPath, "--help");
|
|
115
129
|
const whisperForceable = isManaged(cfg.whisperPath, opts.configDir);
|
|
116
130
|
if (whisperOk && !(opts.force && whisperForceable)) {
|
|
117
131
|
steps.push({ kind: "whisper", status: "satisfied", detail: cfg.whisperPath });
|
|
132
|
+
} else if (remoteDetail !== null) {
|
|
133
|
+
steps.push({ kind: "whisper", status: "satisfied", detail: remoteDetail });
|
|
118
134
|
} else {
|
|
119
135
|
const asset = whisperAsset(p.platform, p.arch);
|
|
120
136
|
if (asset) {
|
|
@@ -153,6 +169,8 @@ export async function planSetup(
|
|
|
153
169
|
const known = MODELS[model];
|
|
154
170
|
if (p.exists(modelPath)) {
|
|
155
171
|
steps.push({ kind: "model", status: "satisfied", detail: modelPath });
|
|
172
|
+
} else if (remoteDetail !== null) {
|
|
173
|
+
steps.push({ kind: "model", status: "satisfied", detail: remoteDetail });
|
|
156
174
|
} else if (isAbsolute(model)) {
|
|
157
175
|
steps.push({
|
|
158
176
|
kind: "model",
|