ossclip 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/editor-dist/assets/index-pWbFr8vc.js +168 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +18 -0
- package/src/edit.ts +67 -2
- package/src/interactive/produce-wizard.ts +7 -1
- package/src/produce.ts +185 -1
- package/src/program.ts +21 -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.36",
|
|
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.36",
|
|
40
|
+
"@ossclip/renderer": "0.1.36",
|
|
41
|
+
"@ossclip/scenes": "0.1.36"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
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/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,6 +56,14 @@ 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,
|
|
@@ -119,6 +127,7 @@ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
|
|
|
119
127
|
import { captionRegenProvider } from "./caption-regen-panel";
|
|
120
128
|
import { binOnPath } from "./llm-detect";
|
|
121
129
|
import {
|
|
130
|
+
YOUTUBE_PRIVACIES,
|
|
122
131
|
attachDeliveryMedia,
|
|
123
132
|
buildPublishPosts,
|
|
124
133
|
publishConfigured,
|
|
@@ -436,6 +445,10 @@ export async function startEditServer(
|
|
|
436
445
|
* `unknown` because it is file-only and typed at the consumer
|
|
437
446
|
* (`resolveSfxBundledPack`), the `audience` rule. */
|
|
438
447
|
sfxBundledPack?: unknown;
|
|
448
|
+
/** The config-level default grade the Color panel's "Default" entry
|
|
449
|
+
* names — `unknown` because it is file-only and typed at the consumer
|
|
450
|
+
* (`resolveColorGrade`), the `sfxBundledPack` rule. */
|
|
451
|
+
colorGrade?: unknown;
|
|
439
452
|
} & RetranscribeConfig;
|
|
440
453
|
/** Env seam for the publish endpoints — tests inject their own so the
|
|
441
454
|
* runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
|
|
@@ -481,6 +494,10 @@ export async function startEditServer(
|
|
|
481
494
|
* loader rather than only that the routes serve what they were handed.
|
|
482
495
|
*/
|
|
483
496
|
loadSfx?: typeof loadSfxLibrary;
|
|
497
|
+
/** The LUT library `/api/luts` serves — the `loadSfx` seam for grades:
|
|
498
|
+
* tests inject a hand-written library instead of depending on whatever
|
|
499
|
+
* the developer keeps in ~/.ossclip/luts. */
|
|
500
|
+
loadLuts?: typeof loadLutLibrary;
|
|
484
501
|
} = {},
|
|
485
502
|
): Promise<EditServer> {
|
|
486
503
|
// MUTABLE since R17 §83: the server can start with no project (the page
|
|
@@ -1971,6 +1988,14 @@ export async function startEditServer(
|
|
|
1971
1988
|
// What uploads (the CLI's --delivery): auto (default) builds
|
|
1972
1989
|
// the cached delivery encode, master sends the untouched render.
|
|
1973
1990
|
delivery: z.enum(["auto", "master"]).optional(),
|
|
1991
|
+
// YouTube's privacy status (the CLI's --youtube-privacy),
|
|
1992
|
+
// spelled ONCE — the flag's own value list, so the panel and
|
|
1993
|
+
// the CLI can never accept different words. Absent leaves
|
|
1994
|
+
// buildPostsPayload's safe private default alone: until this
|
|
1995
|
+
// rode along, every panel publish landed private with no way
|
|
1996
|
+
// to say otherwise, and two videos the user believed were
|
|
1997
|
+
// published sat private on the channel (2026-08-29).
|
|
1998
|
+
youtubePrivacy: z.enum(YOUTUBE_PRIVACIES).optional(),
|
|
1974
1999
|
})
|
|
1975
2000
|
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
1976
2001
|
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
@@ -2077,7 +2102,14 @@ export async function startEditServer(
|
|
|
2077
2102
|
}
|
|
2078
2103
|
capGroups = sizeCapGroups(picked);
|
|
2079
2104
|
}
|
|
2080
|
-
|
|
2105
|
+
// Same options object the CLI's publish path passes — undefined
|
|
2106
|
+
// stays undefined so buildPostsPayload's safe private default is
|
|
2107
|
+
// still the ONE place that decides an absent privacy.
|
|
2108
|
+
const posts = buildPublishPosts(pack, picked, {
|
|
2109
|
+
...(parsed.data.youtubePrivacy !== undefined
|
|
2110
|
+
? { youtubePrivacy: parsed.data.youtubePrivacy }
|
|
2111
|
+
: {}),
|
|
2112
|
+
}).map((p) => ({
|
|
2081
2113
|
...p,
|
|
2082
2114
|
caption: parsed.data.captions?.[p.target.id] ?? p.caption,
|
|
2083
2115
|
}));
|
|
@@ -2624,6 +2656,39 @@ export async function startEditServer(
|
|
|
2624
2656
|
});
|
|
2625
2657
|
}
|
|
2626
2658
|
|
|
2659
|
+
if (url.pathname === "/api/luts" && req.method === "GET") {
|
|
2660
|
+
// The Color panel's .cube menu plus the config-level default grade.
|
|
2661
|
+
// NO workdir guard, /api/sfx/library's rule: both halves are
|
|
2662
|
+
// machine-global (~/.ossclip/luts and config.json), so the panel can
|
|
2663
|
+
// build its dropdown before a project is open. Read per request like
|
|
2664
|
+
// every other loadCfg consumer — a LUT dropped while the editor is
|
|
2665
|
+
// up appears on the next refresh, not on a restart.
|
|
2666
|
+
const library = (opts.loadLuts ?? loadLutLibrary)();
|
|
2667
|
+
// The config grade rides along VALIDATED, not raw: a malformed
|
|
2668
|
+
// config value is what produce ignores (`resolveProductionColorGrade`
|
|
2669
|
+
// warns and proceeds without it), so a "Default (…)" entry built
|
|
2670
|
+
// from it would offer an inherit that renders as nothing. Null means
|
|
2671
|
+
// the panel shows no Default entry, and the warning is dropped for
|
|
2672
|
+
// the sfxLibrary helper's reason — no console here, produce prints
|
|
2673
|
+
// it on the run that grades.
|
|
2674
|
+
const configGrade = resolveColorGrade(
|
|
2675
|
+
(opts.loadCfg ?? loadConfig)().colorGrade,
|
|
2676
|
+
"config",
|
|
2677
|
+
).grade;
|
|
2678
|
+
return send(200, {
|
|
2679
|
+
// METADATA only, the sfx library rule: the absolute `path` stays
|
|
2680
|
+
// server-side. `file` (the basename, extension and all) is what an
|
|
2681
|
+
// editor-written override must carry — `ColorGrade.lut` documents
|
|
2682
|
+
// the basename, produce joins it against ~/.ossclip/luts verbatim,
|
|
2683
|
+
// and a stem-only id would drop the `.CUBE` an exporter spelled.
|
|
2684
|
+
items: library.items.map((l) => ({ id: l.id, title: l.title, file: basename(l.path) })),
|
|
2685
|
+
// A ~/.ossclip/luts author's only surface, like the sfx panel:
|
|
2686
|
+
// the loader degraded instead of throwing, so show the reason.
|
|
2687
|
+
issues: library.issues,
|
|
2688
|
+
configGrade: configGrade ?? null,
|
|
2689
|
+
});
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2627
2692
|
if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
|
|
2628
2693
|
// Click-to-preview. The path comes from the LOADED LIBRARY, never
|
|
2629
2694
|
// 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-three 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,6 +18,12 @@ 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
|
package/src/produce.ts
CHANGED
|
@@ -124,6 +124,18 @@ import {
|
|
|
124
124
|
makeMezzanine,
|
|
125
125
|
mezzanineFileName,
|
|
126
126
|
mezzanineScale,
|
|
127
|
+
// The color-grade pipeline (2026-08-30): validation, the preset/LUT split,
|
|
128
|
+
// the SVG filter spec preset grades ride render-props as, and the .cube
|
|
129
|
+
// bake+hash LUT grades ride the mezzanine as.
|
|
130
|
+
CONFIG_DIR,
|
|
131
|
+
resolveColorGrade,
|
|
132
|
+
resolveGradeToLook,
|
|
133
|
+
gradeToSvgFilterSpec,
|
|
134
|
+
parseCubeLut,
|
|
135
|
+
bakeCube,
|
|
136
|
+
lutHash,
|
|
137
|
+
type ColorGrade,
|
|
138
|
+
type SvgGradeFilterSpec,
|
|
127
139
|
scaleContentTimeline,
|
|
128
140
|
scaleFramingWindows,
|
|
129
141
|
measureFace,
|
|
@@ -725,6 +737,16 @@ export interface ProduceOptions {
|
|
|
725
737
|
* ignore an uploaded cover.
|
|
726
738
|
*/
|
|
727
739
|
coverInVideo?: boolean;
|
|
740
|
+
/**
|
|
741
|
+
* `--color-grade <look>` / `--no-color-grade` — the watermark's tri-state
|
|
742
|
+
* carrying a VALUE: a string when typed (a preset id, or a `.cube`
|
|
743
|
+
* filename — `colorGradeFlagValue` classifies by extension), `false` for a
|
|
744
|
+
* typed --no-color-grade, undefined when neither so overrides.json and
|
|
745
|
+
* then the config's `colorGrade` decide (`resolveProductionColorGrade`).
|
|
746
|
+
* Deliberately unparsed in transit: validation warns-and-proceeds at the
|
|
747
|
+
* use site, because a grade typo must cost the look, never the run.
|
|
748
|
+
*/
|
|
749
|
+
colorGrade?: string | false;
|
|
728
750
|
/**
|
|
729
751
|
* `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
|
|
730
752
|
* true/false when TYPED, undefined when not — undefined lets the config's
|
|
@@ -861,6 +883,71 @@ export function resolveCoverInVideo(
|
|
|
861
883
|
return flag ?? configValue === true;
|
|
862
884
|
}
|
|
863
885
|
|
|
886
|
+
/**
|
|
887
|
+
* `--color-grade`'s value classified into the ColorGradeSchema shape: a value
|
|
888
|
+
* ending in `.cube` names a LUT file in `~/.ossclip/luts`, anything else
|
|
889
|
+
* names a preset. Sniffed by extension rather than split into two flags
|
|
890
|
+
* because the user already knows which they typed — `kodak.cube` cannot be a
|
|
891
|
+
* preset id (presets never carry a dot) and a preset id cannot be a LUT
|
|
892
|
+
* (`.cube` is the one format the parser reads), so the classification is
|
|
893
|
+
* lossless. Case-insensitive on the extension: `KODAK.CUBE` is the same file
|
|
894
|
+
* on the case-preserving filesystems the LUT dir lives on. Validation is NOT
|
|
895
|
+
* here — the shape goes through `resolveColorGrade` like every other layer.
|
|
896
|
+
*/
|
|
897
|
+
export function colorGradeFlagValue(value: string): { preset?: string; lut?: string } {
|
|
898
|
+
return value.toLowerCase().endsWith(".cube") ? { lut: value } : { preset: value };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* The effective color grade across all three surfaces — override > flag >
|
|
903
|
+
* config, `resolveWatermark`'s typed-beats-config precedence grown one layer:
|
|
904
|
+
* the overrides doc is the editor's per-project say, so it beats even a typed
|
|
905
|
+
* flag (the `resolveSrcTimingPins` rationale — a per-project decision made in
|
|
906
|
+
* the editor outranks a per-run flag, never merges with it). An explicit
|
|
907
|
+
* `false` at a switching layer (`colorGrade: false` in the doc, or a typed
|
|
908
|
+
* `--no-color-grade`) is OFF, not fall-through: "no grade" is a decision, and
|
|
909
|
+
* letting a lower layer overrule it would make the disable impossible to
|
|
910
|
+
* express.
|
|
911
|
+
*
|
|
912
|
+
* Every layer is validated through `resolveColorGrade`, and an INVALID layer
|
|
913
|
+
* is ignored — warned about by name, then the NEXT layer applies (decision
|
|
914
|
+
* 2026-08-30): the alternative, an invalid override going all the way to
|
|
915
|
+
* "off", would let one stale editor write silently strip the config grade a
|
|
916
|
+
* channel's whole look depends on. Warnings are RETURNED, not printed
|
|
917
|
+
* (`resolveSfxLevel`'s shape), so the whole matrix is testable without a TTY.
|
|
918
|
+
* `source` names the winning layer so the ▸ line can say where a grade came
|
|
919
|
+
* from — the watermark's "(from config; --no-… overrides)" visibility rule.
|
|
920
|
+
*/
|
|
921
|
+
export function resolveProductionColorGrade(p: {
|
|
922
|
+
override: ColorGrade | false | undefined;
|
|
923
|
+
flag: string | false | undefined;
|
|
924
|
+
config: unknown;
|
|
925
|
+
}): { grade?: ColorGrade; source?: "override" | "flag" | "config"; warnings: string[] } {
|
|
926
|
+
const warnings: string[] = [];
|
|
927
|
+
if (p.override === false) return { warnings };
|
|
928
|
+
if (p.override !== undefined) {
|
|
929
|
+
// Schema-valid already (OverrideDocSchema parsed the doc), but the
|
|
930
|
+
// unknown-preset check lives in resolveColorGrade, not the schema — this
|
|
931
|
+
// is the layer where a preset the editor knew and this build doesn't
|
|
932
|
+
// falls through instead of failing the doc.
|
|
933
|
+
const r = resolveColorGrade(p.override, "overrides.json");
|
|
934
|
+
if (r.grade) return { grade: r.grade, source: "override", warnings };
|
|
935
|
+
if (r.warning) warnings.push(r.warning);
|
|
936
|
+
}
|
|
937
|
+
if (p.flag === false) return { warnings };
|
|
938
|
+
if (p.flag !== undefined) {
|
|
939
|
+
const r = resolveColorGrade(colorGradeFlagValue(p.flag), "--color-grade");
|
|
940
|
+
if (r.grade) return { grade: r.grade, source: "flag", warnings };
|
|
941
|
+
if (r.warning) warnings.push(r.warning);
|
|
942
|
+
}
|
|
943
|
+
// "config", not "config colorGrade": resolveColorGrade's warning already
|
|
944
|
+
// spells the key (`⚠ <source> colorGrade ignored — …`).
|
|
945
|
+
const r = resolveColorGrade(p.config, "config");
|
|
946
|
+
if (r.grade) return { grade: r.grade, source: "config", warnings };
|
|
947
|
+
if (r.warning) warnings.push(r.warning);
|
|
948
|
+
return { warnings };
|
|
949
|
+
}
|
|
950
|
+
|
|
864
951
|
/**
|
|
865
952
|
* `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
|
|
866
953
|
* and a run that quietly did nothing because the boolean was missing is the
|
|
@@ -4714,6 +4801,88 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4714
4801
|
// on a bar-free source): there is no re-encode to scale, the render plays
|
|
4715
4802
|
// the source itself, and the window emissions below must then stay in true
|
|
4716
4803
|
// source pixels — which the identity `mezzFactor` below guarantees.
|
|
4804
|
+
// ---- Color grade (`--color-grade` / config `colorGrade` / the editor's
|
|
4805
|
+
// overrides.json) ---------------------------------------------------------
|
|
4806
|
+
// Resolved HERE, before the mezzanine encode, because the feature's two
|
|
4807
|
+
// halves split at exactly this seam: a PRESET grade rides render-props as
|
|
4808
|
+
// an SVG filter spec (the props assembly below), while a LUT grade is baked
|
|
4809
|
+
// INTO the mezzanine (ingest.ts's `lut` option) — ffmpeg's lut3d on the
|
|
4810
|
+
// encode pass costs nothing per rendered frame, where a 33³ trilinear
|
|
4811
|
+
// lookup in the browser would. Precedence and validation live in
|
|
4812
|
+
// `resolveProductionColorGrade`; every warning it returns prints once here,
|
|
4813
|
+
// and every failure path proceeds UNGRADED — a grade must cost the look at
|
|
4814
|
+
// worst, never the run.
|
|
4815
|
+
const gradeResolution = resolveProductionColorGrade({
|
|
4816
|
+
override: overrideDoc.colorGrade,
|
|
4817
|
+
flag: opts.colorGrade,
|
|
4818
|
+
config: cfg.colorGrade,
|
|
4819
|
+
});
|
|
4820
|
+
for (const w of gradeResolution.warnings) console.log(w);
|
|
4821
|
+
/** Preset grades: the spec render-props carries (absent = no grade). */
|
|
4822
|
+
let colorGradeSpec: SvgGradeFilterSpec | undefined;
|
|
4823
|
+
/** LUT grades: the baked .cube the mezzanine encode applies (absent = none). */
|
|
4824
|
+
let gradeLut: { path: string; hash: string } | undefined;
|
|
4825
|
+
if (gradeResolution.grade !== undefined) {
|
|
4826
|
+
// The watermark's visibility rule: a grade sourced anywhere but the
|
|
4827
|
+
// typed flag says so, so a config- or editor-sourced look never
|
|
4828
|
+
// surprises the author on upload.
|
|
4829
|
+
const gradeFromNote =
|
|
4830
|
+
gradeResolution.source === "config"
|
|
4831
|
+
? " (from config; --no-color-grade overrides)"
|
|
4832
|
+
: gradeResolution.source === "override"
|
|
4833
|
+
? " (editor override)"
|
|
4834
|
+
: "";
|
|
4835
|
+
const resolvedLook = resolveGradeToLook(gradeResolution.grade);
|
|
4836
|
+
if (resolvedLook.kind === "preset") {
|
|
4837
|
+
colorGradeSpec = gradeToSvgFilterSpec(resolvedLook);
|
|
4838
|
+
console.log(`▸ color grade: ${gradeResolution.grade.preset}${gradeFromNote}`);
|
|
4839
|
+
} else if (!mezzanineWillBuild) {
|
|
4840
|
+
// --no-mezzanine on a bar-free source: the render plays the source
|
|
4841
|
+
// file itself, so there is no encode to bake the LUT into. Warn and
|
|
4842
|
+
// proceed ungraded rather than force a mezzanine the user refused.
|
|
4843
|
+
console.log(
|
|
4844
|
+
`⚠ color grade skipped — a .cube LUT is baked into the mezzanine, ` +
|
|
4845
|
+
`and --no-mezzanine means this run doesn't build one`,
|
|
4846
|
+
);
|
|
4847
|
+
} else {
|
|
4848
|
+
try {
|
|
4849
|
+
// Basename only, enforced before any path math: `lut` is a NAME the
|
|
4850
|
+
// schema documents as living in ~/.ossclip/luts, and resolving a
|
|
4851
|
+
// separator-carrying value would turn a config key into a file probe
|
|
4852
|
+
// (the SfxAddedPlacement id's "nothing may ever resolve a path
|
|
4853
|
+
// against it" rule, applied at the one place this name meets the
|
|
4854
|
+
// filesystem).
|
|
4855
|
+
if (basename(resolvedLook.lutRef) !== resolvedLook.lutRef) {
|
|
4856
|
+
throw new Error(
|
|
4857
|
+
`"${resolvedLook.lutRef}" is not a bare filename — LUTs live in ${join(CONFIG_DIR, "luts")}`,
|
|
4858
|
+
);
|
|
4859
|
+
}
|
|
4860
|
+
const lutPath = join(CONFIG_DIR, "luts", resolvedLook.lutRef);
|
|
4861
|
+
const baseLut = parseCubeLut(readFileSync(lutPath, "utf8"));
|
|
4862
|
+
// Tweaks + intensity are baked into the cube (bakeCube composes
|
|
4863
|
+
// `params` on top of the base sample), so the hash keys the WHOLE
|
|
4864
|
+
// grade: change the intensity and the mezzanine filename changes
|
|
4865
|
+
// with it (`mezzanineFileName`'s existence-keyed cache).
|
|
4866
|
+
const cubeText = bakeCube({
|
|
4867
|
+
base: baseLut,
|
|
4868
|
+
params: resolvedLook.tweaks,
|
|
4869
|
+
intensity: resolvedLook.intensity,
|
|
4870
|
+
});
|
|
4871
|
+
const hash = lutHash(cubeText);
|
|
4872
|
+
const bakedPath = join(work, `grade-${hash}.cube`);
|
|
4873
|
+
await writeFile(bakedPath, cubeText);
|
|
4874
|
+
gradeLut = { path: bakedPath, hash };
|
|
4875
|
+
console.log(`▸ color grade: LUT ${resolvedLook.lutRef}${gradeFromNote}`);
|
|
4876
|
+
} catch (err) {
|
|
4877
|
+
// ENOENT and a malformed .cube land here alike: name the problem,
|
|
4878
|
+
// proceed ungraded. parseCubeLut's errors already carry the line.
|
|
4879
|
+
console.log(
|
|
4880
|
+
`⚠ color grade skipped — ${err instanceof Error ? err.message : String(err)}`,
|
|
4881
|
+
);
|
|
4882
|
+
}
|
|
4883
|
+
}
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4717
4886
|
const mezzScale = mezzanineWillBuild
|
|
4718
4887
|
? mezzanineScale(
|
|
4719
4888
|
{ width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
|
|
@@ -4726,7 +4895,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4726
4895
|
// why): mezzanine caching is existence-keyed, so a pre-pass full-res
|
|
4727
4896
|
// mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
|
|
4728
4897
|
// windows — the scaled file rebuilds once under its own name.
|
|
4729
|
-
|
|
4898
|
+
// `gradeLut?.hash` rides the name so a graded mezzanine can never satisfy
|
|
4899
|
+
// an ungraded run (or vice versa) — the LUT is pixels in the file, and
|
|
4900
|
+
// the cache is existence-keyed.
|
|
4901
|
+
const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale, gradeLut?.hash));
|
|
4730
4902
|
if (!existsSync(mezz)) {
|
|
4731
4903
|
const mezzAnim = isInteractive()
|
|
4732
4904
|
? new StageAnimator(
|
|
@@ -4747,6 +4919,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4747
4919
|
await makeMezzanine(tools, input, mezz, {
|
|
4748
4920
|
cropVf: cropVf || undefined,
|
|
4749
4921
|
scale: mezzScale ?? undefined,
|
|
4922
|
+
// The LUT grade's whole delivery: baked into the encode, so the
|
|
4923
|
+
// render (and the editor's preview, which plays the same file) see
|
|
4924
|
+
// graded pixels with no per-frame cost.
|
|
4925
|
+
lut: gradeLut,
|
|
4750
4926
|
});
|
|
4751
4927
|
if (mezzAnim) mezzAnim.stop();
|
|
4752
4928
|
}
|
|
@@ -5069,6 +5245,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
5069
5245
|
// one, and the composition reads an absent key as silence, not as an empty
|
|
5070
5246
|
// track it still has to mount.
|
|
5071
5247
|
...(sfxCues.length > 0 ? { sfxCues } : {}),
|
|
5248
|
+
// `--color-grade` PRESET looks, written only when one resolved (the
|
|
5249
|
+
// watermark's absent-means-off contract): the two-stage SVG filter spec
|
|
5250
|
+
// ({tableR, tableG, tableB, colorMatrix}, gradeToSvgFilterSpec) the
|
|
5251
|
+
// composition mounts over the video. A LUT grade deliberately writes
|
|
5252
|
+
// NOTHING here — it was baked into the mezzanine above, so the pixels
|
|
5253
|
+
// the renderer plays already carry it, and a spec on top would grade
|
|
5254
|
+
// twice.
|
|
5255
|
+
...(colorGradeSpec ? { colorGrade: colorGradeSpec } : {}),
|
|
5072
5256
|
};
|
|
5073
5257
|
await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
|
|
5074
5258
|
|
package/src/program.ts
CHANGED
|
@@ -472,6 +472,21 @@ export function buildProgram(): Command {
|
|
|
472
472
|
"(set it once with coverInVideo: true in ~/.ossclip/config.json)",
|
|
473
473
|
)
|
|
474
474
|
.option("--no-cover-in-video", "no cover overlay, even when the config turns it on")
|
|
475
|
+
// The watermark's tri-state carrying a VALUE: commander folds the pair
|
|
476
|
+
// onto one key (string when typed, false for --no-color-grade, undefined
|
|
477
|
+
// when neither — which is what lets overrides.json and then the config's
|
|
478
|
+
// `colorGrade` decide). The value is NOT parsed here, unlike --sfx-level:
|
|
479
|
+
// it may be a preset id OR a .cube filename, so an enum parse can't hold
|
|
480
|
+
// it — classification and validation live at the consumer
|
|
481
|
+
// (colorGradeFlagValue / resolveProductionColorGrade in produce.ts),
|
|
482
|
+
// where a typo warns and the run proceeds ungraded rather than dying.
|
|
483
|
+
.option(
|
|
484
|
+
"--color-grade <look>",
|
|
485
|
+
"color grade the footage: a preset (talking-head | teal-orange | filmic-fade | " +
|
|
486
|
+
"cwa | punchy | mono) or a .cube LUT filename from ~/.ossclip/luts " +
|
|
487
|
+
'(set it once with colorGrade: {"preset": "..."} in ~/.ossclip/config.json)',
|
|
488
|
+
)
|
|
489
|
+
.option("--no-color-grade", "no color grade, even when the config sets one")
|
|
475
490
|
// Same tri-state shape as --watermark above (positive declared first so
|
|
476
491
|
// commander's default stays undefined = "not typed"): the config's
|
|
477
492
|
// `youtube` key supplies the default (resolveYoutube), and a typed
|
|
@@ -722,6 +737,12 @@ export function buildProgram(): Command {
|
|
|
722
737
|
// The watermark's tri-state again, resolved by resolveCoverInVideo
|
|
723
738
|
// at the use site against the config's `coverInVideo`.
|
|
724
739
|
coverInVideo: opts.coverInVideo,
|
|
740
|
+
// string | false | undefined straight through: undefined = "not
|
|
741
|
+
// typed" lets overrides.json and then the config's `colorGrade`
|
|
742
|
+
// decide, and the value itself is classified and validated at the
|
|
743
|
+
// use site (resolveProductionColorGrade) — see the option's own
|
|
744
|
+
// comment for why no parse happens here.
|
|
745
|
+
colorGrade: opts.colorGrade,
|
|
725
746
|
// The same tri-state contract as watermark, resolved by
|
|
726
747
|
// resolveYoutube at the use site; --portrait rides along untyped =
|
|
727
748
|
// undefined so the config's path can supply it.
|