ossclip 0.1.34 → 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-health.ts +51 -0
- package/src/edit-port.ts +296 -0
- package/src/edit.ts +340 -3
- package/src/interactive/offer-editor.ts +29 -4
- package/src/interactive/produce-argv.ts +18 -1
- package/src/interactive/produce-wizard.ts +47 -4
- package/src/produce.ts +652 -3
- package/src/program.ts +83 -6
- package/src/replay-argv.ts +30 -0
- package/editor-dist/assets/index-Dn813PGp.js +0 -166
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,
|
|
@@ -156,6 +168,8 @@ import {
|
|
|
156
168
|
sliceTranscript,
|
|
157
169
|
type Analysis,
|
|
158
170
|
type AppliedRepair,
|
|
171
|
+
type BeatSheet,
|
|
172
|
+
BeatSheetSchema,
|
|
159
173
|
type BeatsValidationIssue,
|
|
160
174
|
type CleanupLevel,
|
|
161
175
|
type ClipWindow,
|
|
@@ -170,6 +184,34 @@ import {
|
|
|
170
184
|
RESOLUTION_CHOICES,
|
|
171
185
|
smallestSource,
|
|
172
186
|
ResolutionChoiceSchema,
|
|
187
|
+
// The --sfx path (2026-08-29): the library loader, the placement call, its
|
|
188
|
+
// deterministic gate, and the resolver that turns word anchors into cues.
|
|
189
|
+
loadSfxLibrary,
|
|
190
|
+
sfxLibraryHash,
|
|
191
|
+
// The `sfxBundledPack` config gate. It lives in core, not beside `resolveSfx`
|
|
192
|
+
// below, because the edit server has to resolve it identically — its own
|
|
193
|
+
// doc-comment has the reason.
|
|
194
|
+
resolveSfxBundledPack,
|
|
195
|
+
generateSfxPlan,
|
|
196
|
+
resolveSfxCues,
|
|
197
|
+
// Scene id → final start second, the scene-anchored placements' clock
|
|
198
|
+
// (2026-08-29). Built from `sceneCues`, never from the raw plan.
|
|
199
|
+
sceneStartSeconds,
|
|
200
|
+
// The user's layer over that plan (Phase 3), and the schema the carried
|
|
201
|
+
// forward plan is parsed back through.
|
|
202
|
+
applySfxOverrides,
|
|
203
|
+
ProductionSfxSchema,
|
|
204
|
+
sfxStagedFile,
|
|
205
|
+
formatSfxAccounting,
|
|
206
|
+
SfxLevelSchema,
|
|
207
|
+
SfxPlanSchema,
|
|
208
|
+
SfxValidationIssueSchema,
|
|
209
|
+
SFX_PROMPT_VERSION,
|
|
210
|
+
type LoadedSfxSound,
|
|
211
|
+
type SfxCue,
|
|
212
|
+
type SfxLevel,
|
|
213
|
+
type SfxPlacement,
|
|
214
|
+
type SfxValidationIssue,
|
|
173
215
|
type ProviderName,
|
|
174
216
|
type ResolutionChoice,
|
|
175
217
|
type Scene,
|
|
@@ -417,6 +459,38 @@ export function existingProducerStamp(work: string): Production["producer"] | un
|
|
|
417
459
|
}
|
|
418
460
|
}
|
|
419
461
|
|
|
462
|
+
/**
|
|
463
|
+
* The `sfx` plan the workdir's LAST run wrote, or undefined (Phase 3).
|
|
464
|
+
*
|
|
465
|
+
* This is how a `--scenes` replay keeps its sound design. The editor's Render
|
|
466
|
+
* pins the reviewed plan with `--scenes` and drops `--produce`
|
|
467
|
+
* (render-replay-args.ts), so the run never reaches the placement call — and
|
|
468
|
+
* the placement call is not what should decide, because the SCENES-REVIEWED
|
|
469
|
+
* doctrine says a render started from the editor must reproduce what the user
|
|
470
|
+
* reviewed. For SFX that reviewed state is exactly `production.json`'s plan
|
|
471
|
+
* plus `overrides.json`'s edits on top of it: re-placing would hand the user a
|
|
472
|
+
* different set of effects than the ones they just dragged, and skipping would
|
|
473
|
+
* hand them silence.
|
|
474
|
+
*
|
|
475
|
+
* Tolerant like `existingProducerStamp` above — a missing, unreadable or
|
|
476
|
+
* sfx-less file all mean "no prior sound design", which is what a first run
|
|
477
|
+
* says too. Parsed through `ProductionSfxSchema`, never cast: production.json
|
|
478
|
+
* is as hand-editable as anything else in the workdir, and a level of "MEME"
|
|
479
|
+
* must not reach the report as a level.
|
|
480
|
+
*/
|
|
481
|
+
export function priorSfxPlan(work: string): { level: SfxLevel; placements: SfxPlacement[] } | undefined {
|
|
482
|
+
try {
|
|
483
|
+
const raw = JSON.parse(readFileSync(join(work, "production.json"), "utf8")) as {
|
|
484
|
+
sfx?: unknown;
|
|
485
|
+
};
|
|
486
|
+
if (raw.sfx === undefined) return undefined;
|
|
487
|
+
const parsed = ProductionSfxSchema.safeParse(raw.sfx);
|
|
488
|
+
return parsed.success ? parsed.data : undefined;
|
|
489
|
+
} catch {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
420
494
|
export function beatCacheKeyCandidates(
|
|
421
495
|
parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
|
|
422
496
|
providerName: string,
|
|
@@ -663,6 +737,16 @@ export interface ProduceOptions {
|
|
|
663
737
|
* ignore an uploaded cover.
|
|
664
738
|
*/
|
|
665
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;
|
|
666
750
|
/**
|
|
667
751
|
* `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
|
|
668
752
|
* true/false when TYPED, undefined when not — undefined lets the config's
|
|
@@ -715,6 +799,21 @@ export interface ProduceOptions {
|
|
|
715
799
|
* a file input.
|
|
716
800
|
*/
|
|
717
801
|
sort?: "name" | "mtime";
|
|
802
|
+
/**
|
|
803
|
+
* `--sfx` (2026-08-29): place sound effects from the loaded pack on the
|
|
804
|
+
* beats the producer planned. Tri-state like `watermark` — true/false when
|
|
805
|
+
* TYPED, undefined when not, so the config's `sfx` key can supply the
|
|
806
|
+
* default (`resolveSfx`). Requires a beat sheet, so it rides `--produce`;
|
|
807
|
+
* without one the step says so and skips.
|
|
808
|
+
*/
|
|
809
|
+
sfx?: boolean;
|
|
810
|
+
/**
|
|
811
|
+
* `--sfx-level`: how much sound design (`subtle | normal | meme`). Already
|
|
812
|
+
* zod-parsed to the union by program.ts, which also folds the implication
|
|
813
|
+
* (`sfxFlag`: a typed level turns `sfx` on); the CONFIG's `sfxLevel` arrives
|
|
814
|
+
* separately as an unvalidated value and `resolveSfxLevel` arbitrates.
|
|
815
|
+
*/
|
|
816
|
+
sfxLevel?: SfxLevel;
|
|
718
817
|
/**
|
|
719
818
|
* Whether `--sort` was TYPED, as opposed to commander filling in its
|
|
720
819
|
* `"name"` default. `sort` alone can't tell those apart, and `--sort` does
|
|
@@ -784,6 +883,168 @@ export function resolveCoverInVideo(
|
|
|
784
883
|
return flag ?? configValue === true;
|
|
785
884
|
}
|
|
786
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
|
+
|
|
951
|
+
/**
|
|
952
|
+
* `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
|
|
953
|
+
* and a run that quietly did nothing because the boolean was missing is the
|
|
954
|
+
* worst possible reading of it. Returns the tri-state `--sfx` carries —
|
|
955
|
+
* `undefined` when neither was typed, so the config's `sfx` key still gets its
|
|
956
|
+
* turn (`resolveSfx`). Pure, so the implication is testable without commander,
|
|
957
|
+
* the `jumpCutsFlag` posture.
|
|
958
|
+
*/
|
|
959
|
+
export function sfxFlag(
|
|
960
|
+
sfx: boolean | undefined,
|
|
961
|
+
sfxLevel: SfxLevel | undefined,
|
|
962
|
+
): boolean | undefined {
|
|
963
|
+
return sfxLevel !== undefined ? true : sfx;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The effective `--sfx` switch — `resolveCoverInVideo`'s semantics verbatim: a
|
|
968
|
+
* TYPED flag wins, and only then does the config's `sfx` supply the default,
|
|
969
|
+
* `=== true` rather than truthy so a hand-edited `"sfx": "yes"` cannot coerce
|
|
970
|
+
* sound effects onto every render.
|
|
971
|
+
*/
|
|
972
|
+
export function resolveSfx(flag: boolean | undefined, configValue: unknown): boolean {
|
|
973
|
+
return flag ?? configValue === true;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* The effective `--sfx-level`. `resolveLlmEffort`'s shape — the warning is
|
|
978
|
+
* RETURNED, not printed, so the resolution stays pure — and its precedence: a
|
|
979
|
+
* typed flag beats a config key, and a malformed config key costs a warning
|
|
980
|
+
* and the default rather than a coerced level. The default matters: `meme`
|
|
981
|
+
* unlocks the meme-tagged sounds, so a typo must never fall UP into it.
|
|
982
|
+
*/
|
|
983
|
+
export function resolveSfxLevel(
|
|
984
|
+
flag: SfxLevel | undefined,
|
|
985
|
+
configValue: unknown,
|
|
986
|
+
): { level: SfxLevel; warning?: string } {
|
|
987
|
+
if (flag !== undefined) return { level: flag };
|
|
988
|
+
if (configValue === undefined) return { level: "normal" };
|
|
989
|
+
const parsed = SfxLevelSchema.safeParse(configValue);
|
|
990
|
+
if (parsed.success) return { level: parsed.data };
|
|
991
|
+
return {
|
|
992
|
+
level: "normal",
|
|
993
|
+
warning: `⚠ config sfxLevel ignored — expected ${SfxLevelSchema.options.join("|")}, using normal`,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* What `sfx-<key>.json` holds: the normalized plan plus the accounting a
|
|
999
|
+
* cached re-run would otherwise have to invent — `planned` is the count the
|
|
1000
|
+
* MODEL returned, which nothing on disk could re-derive, and `issues` are the
|
|
1001
|
+
* planning drops the report explains the shortfall with (the beat-sheet
|
|
1002
|
+
* cache's `graphics`/`issues` rule, §78).
|
|
1003
|
+
*
|
|
1004
|
+
* Parsed on read, never trusted: a workdir file is as hand-editable as
|
|
1005
|
+
* anything else here, and a mangled one must cost a re-plan, not a crash.
|
|
1006
|
+
*/
|
|
1007
|
+
const SfxPlanCacheSchema = SfxPlanSchema.extend({
|
|
1008
|
+
planned: z.number().int().nonnegative().default(0),
|
|
1009
|
+
issues: z.array(SfxValidationIssueSchema).default([]),
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The placement-plan cache key: everything that changes the PLAN — which
|
|
1014
|
+
* prompt asked, about which words, against which beat sheet, at what level,
|
|
1015
|
+
* over which library. The §78 posture the beat sheet's key carries, and pure
|
|
1016
|
+
* for the same reason: "a change that changes the answer must change the key"
|
|
1017
|
+
* has to be assertable without a workdir or an LLM.
|
|
1018
|
+
*
|
|
1019
|
+
* `beatKey` folds the whole beat-sheet key in (provider, model, intent,
|
|
1020
|
+
* framing, clip window…) rather than restating it: the graphics plan is IN the
|
|
1021
|
+
* placement prompt, so a re-plan of the beats is a different question about
|
|
1022
|
+
* the same words. `libraryHash` is pack METADATA only (`sfxLibraryHash`), so
|
|
1023
|
+
* dropping a user pack in `~/.ossclip/sfx` invalidates while re-encoding an
|
|
1024
|
+
* mp3 does not.
|
|
1025
|
+
*/
|
|
1026
|
+
export function sfxCacheKey(parts: {
|
|
1027
|
+
promptVersion: number;
|
|
1028
|
+
beatKey: string;
|
|
1029
|
+
level: SfxLevel;
|
|
1030
|
+
libraryHash: string;
|
|
1031
|
+
/** The repaired transcript's TEXT — the beat key's rule, for the same reason. */
|
|
1032
|
+
words: readonly string[];
|
|
1033
|
+
}): string {
|
|
1034
|
+
return createHash("sha1")
|
|
1035
|
+
.update(
|
|
1036
|
+
JSON.stringify([
|
|
1037
|
+
parts.promptVersion,
|
|
1038
|
+
parts.beatKey,
|
|
1039
|
+
parts.level,
|
|
1040
|
+
parts.libraryHash,
|
|
1041
|
+
parts.words,
|
|
1042
|
+
]),
|
|
1043
|
+
)
|
|
1044
|
+
.digest("hex")
|
|
1045
|
+
.slice(0, 8);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
787
1048
|
/**
|
|
788
1049
|
* Where the cover image lives right now, most-specific first — the EDITOR
|
|
789
1050
|
* panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
|
|
@@ -2821,11 +3082,52 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2821
3082
|
let scenes: Scene[] = [];
|
|
2822
3083
|
/** Editorial output kept for the cover (§31): hook + its thumbnail form. */
|
|
2823
3084
|
let beatSheet: { hook: string; coverText?: string } | undefined;
|
|
3085
|
+
/**
|
|
3086
|
+
* The FULL sheet, moments included — the graphics plan the SFX call needs in
|
|
3087
|
+
* context (a whoosh syncs with a graphic entrance). Separate from
|
|
3088
|
+
* `beatSheet` above, which is deliberately the cover's two fields: this one
|
|
3089
|
+
* exists only while a plan is in hand this run, and is undefined on the
|
|
3090
|
+
* paths that never had one (`--scenes`, a pre-`--sfx` scenes cache).
|
|
3091
|
+
*/
|
|
3092
|
+
let fullSheet: BeatSheet | undefined;
|
|
2824
3093
|
/** The graphics accounting line for report.txt (§118b), and the beat-sheet
|
|
2825
3094
|
* issues that explain it. Cached alongside the beat sheet so a cached
|
|
2826
3095
|
* re-run's report keeps the accounting instead of erasing it (§78). */
|
|
2827
3096
|
let graphicsLine: string | undefined;
|
|
2828
3097
|
let beatIssues: BeatsValidationIssue[] = [];
|
|
3098
|
+
/**
|
|
3099
|
+
* The `--sfx` plan: the level it was planned at and the placements that
|
|
3100
|
+
* survived `normalizeSfxPlan`, stored on production.json and resolved to
|
|
3101
|
+
* cues once the cut is final (`resolveSfxCues`). Undefined = this run has no
|
|
3102
|
+
* sound design at all, which is what an absent `sfx` field means.
|
|
3103
|
+
*/
|
|
3104
|
+
let sfxPlan: { level: SfxLevel; placements: SfxPlacement[] } | undefined;
|
|
3105
|
+
/** The library the plan was made against — the resolver needs the same
|
|
3106
|
+
* sounds (gain, absPath) it planned over, and the stager needs their files. */
|
|
3107
|
+
let sfxSounds: LoadedSfxSound[] = [];
|
|
3108
|
+
/** Planning drops + the count the MODEL returned, carried to the one
|
|
3109
|
+
* accounting line the console and report.txt share (§118b's contract). */
|
|
3110
|
+
let sfxIssues: SfxValidationIssue[] = [];
|
|
3111
|
+
let sfxPlanned = 0;
|
|
3112
|
+
/** Whether the placement step actually ran — see the notice below the
|
|
3113
|
+
* scenes block for the runs where `--sfx` cannot reach it. */
|
|
3114
|
+
let sfxAttempted = false;
|
|
3115
|
+
// Resolved HERE, above the branch that uses it, so BOTH the placement step
|
|
3116
|
+
// and the "this run has no beat sheet" notice read one answer. Typed beats
|
|
3117
|
+
// config for the switch; the level is zod-parsed out of the config, never
|
|
3118
|
+
// coerced (`resolveSfxLevel`).
|
|
3119
|
+
const sfxOn = resolveSfx(opts.sfx, cfg.sfx);
|
|
3120
|
+
const sfxLevelResolved = resolveSfxLevel(opts.sfxLevel, cfg.sfxLevel);
|
|
3121
|
+
if (sfxOn && sfxLevelResolved.warning) console.log(sfxLevelResolved.warning);
|
|
3122
|
+
const sfxLevel = sfxLevelResolved.level;
|
|
3123
|
+
// Which packs this machine offers (`sfxBundledPack`) — resolved next to the
|
|
3124
|
+
// level, and for the same reason: BOTH library loads below (the placement
|
|
3125
|
+
// step and the carry-forward branch) must read one answer, or a run would
|
|
3126
|
+
// plan against one library and stage from another.
|
|
3127
|
+
const sfxBundled = resolveSfxBundledPack(cfg.sfxBundledPack);
|
|
3128
|
+
if (sfxOn && sfxBundled.warning) console.log(sfxBundled.warning);
|
|
3129
|
+
/** The loader's opts, shared by both library loads. */
|
|
3130
|
+
const sfxLoad = { includeBundled: sfxBundled.include };
|
|
2829
3131
|
/** Who planned this run (R16 §78) — stamped into production.json below. */
|
|
2830
3132
|
let producerStamp: Production["producer"];
|
|
2831
3133
|
/** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
|
|
@@ -3008,6 +3310,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3008
3310
|
// cache under the post-resolution key so re-runs and replays hit it.
|
|
3009
3311
|
scenes = clipFresh.scenes;
|
|
3010
3312
|
beatSheet = { hook: clipFresh.beatSheet.hook, coverText: clipFresh.beatSheet.coverText };
|
|
3313
|
+
fullSheet = clipFresh.beatSheet;
|
|
3011
3314
|
console.log(`▸ hook: ${clipFresh.beatSheet.hook}`);
|
|
3012
3315
|
console.log(
|
|
3013
3316
|
`▸ planned ${clipFresh.beatSheet.moments.length} moments, ${scenes.length} scenes` +
|
|
@@ -3035,7 +3338,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3035
3338
|
await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
|
|
3036
3339
|
await writeFile(
|
|
3037
3340
|
join(work, `beatsheet-${adoptKey}.json`),
|
|
3038
|
-
JSON.stringify(
|
|
3341
|
+
JSON.stringify(
|
|
3342
|
+
{ ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
|
|
3343
|
+
null,
|
|
3344
|
+
2,
|
|
3345
|
+
),
|
|
3039
3346
|
);
|
|
3040
3347
|
} else if (existsSync(sceneCache)) {
|
|
3041
3348
|
scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
|
|
@@ -3054,12 +3361,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3054
3361
|
const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
|
|
3055
3362
|
hook: string;
|
|
3056
3363
|
coverText?: string;
|
|
3364
|
+
moments?: unknown;
|
|
3057
3365
|
graphics?: string;
|
|
3058
3366
|
issues?: BeatsValidationIssue[];
|
|
3059
3367
|
};
|
|
3060
3368
|
beatSheet = { hook: cached.hook, coverText: cached.coverText };
|
|
3061
3369
|
graphicsLine = cached.graphics;
|
|
3062
3370
|
beatIssues = cached.issues ?? [];
|
|
3371
|
+
// The MOMENTS ride the cache too (2026-08-29, --sfx): this file used to
|
|
3372
|
+
// keep only the cover's two fields, so a cached run had no graphics
|
|
3373
|
+
// plan to put in front of the placement call — and "produce once, add
|
|
3374
|
+
// --sfx later" is the normal way anyone reaches this feature. Parsed,
|
|
3375
|
+
// not trusted (a hand-edited or pre-`--sfx` file simply has no
|
|
3376
|
+
// `moments`), and its absence costs the SFX step alone: everything the
|
|
3377
|
+
// cache did before this is read above and unaffected.
|
|
3378
|
+
const sheet = BeatSheetSchema.safeParse({
|
|
3379
|
+
hook: cached.hook,
|
|
3380
|
+
coverText: cached.coverText,
|
|
3381
|
+
moments: cached.moments,
|
|
3382
|
+
});
|
|
3383
|
+
if (sheet.success) fullSheet = sheet.data;
|
|
3063
3384
|
}
|
|
3064
3385
|
} else {
|
|
3065
3386
|
const aiAnim = isInteractive()
|
|
@@ -3106,6 +3427,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3106
3427
|
if (aiAnim) aiAnim.stop();
|
|
3107
3428
|
scenes = result.scenes;
|
|
3108
3429
|
beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
|
|
3430
|
+
fullSheet = result.beatSheet;
|
|
3109
3431
|
console.log(`▸ hook: ${result.beatSheet.hook}`);
|
|
3110
3432
|
console.log(
|
|
3111
3433
|
`▸ planned ${result.beatSheet.moments.length} moments, ${scenes.length} scenes` +
|
|
@@ -3132,9 +3454,137 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3132
3454
|
await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
|
|
3133
3455
|
await writeFile(
|
|
3134
3456
|
join(work, `beatsheet-${freshKey}.json`),
|
|
3135
|
-
JSON.stringify(
|
|
3457
|
+
JSON.stringify(
|
|
3458
|
+
{ ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
|
|
3459
|
+
null,
|
|
3460
|
+
2,
|
|
3461
|
+
),
|
|
3136
3462
|
);
|
|
3137
3463
|
}
|
|
3464
|
+
|
|
3465
|
+
// ---- Sound effects (`--sfx`): the placement call -----------------------
|
|
3466
|
+
// HERE, inside the producer branch, because the placement prompt needs the
|
|
3467
|
+
// graphics plan in front of it (Approach A: a whoosh syncs with a graphic
|
|
3468
|
+
// entrance) — and because a beat sheet is the one thing this feature
|
|
3469
|
+
// cannot do without. `--scenes` and a plain run never reach this block;
|
|
3470
|
+
// the notice for that case is printed by the caller below.
|
|
3471
|
+
if (sfxOn) {
|
|
3472
|
+
sfxAttempted = true;
|
|
3473
|
+
const library = loadSfxLibrary(sfxLoad);
|
|
3474
|
+
for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
|
|
3475
|
+
if (library.sounds.length === 0) {
|
|
3476
|
+
// Warn and continue, never kill: an empty library is a packaging or a
|
|
3477
|
+
// user-pack problem, and it costs sound effects, not the video.
|
|
3478
|
+
console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
|
|
3479
|
+
} else {
|
|
3480
|
+
sfxSounds = library.sounds;
|
|
3481
|
+
const sfxKey = sfxCacheKey({
|
|
3482
|
+
promptVersion: SFX_PROMPT_VERSION,
|
|
3483
|
+
// The beat key, not its parts: the graphics plan is IN this prompt,
|
|
3484
|
+
// so a different sheet is a different question (see `sfxCacheKey`).
|
|
3485
|
+
beatKey: hitKey,
|
|
3486
|
+
level: sfxLevel,
|
|
3487
|
+
libraryHash: sfxLibraryHash(library.sounds),
|
|
3488
|
+
words: beatKeyParts.words,
|
|
3489
|
+
});
|
|
3490
|
+
const sfxCache = join(work, `sfx-${sfxKey}.json`);
|
|
3491
|
+
if (existsSync(sfxCache)) {
|
|
3492
|
+
// Parsed, never trusted — a cache file is as hand-editable as any
|
|
3493
|
+
// other artefact in the workdir. A malformed one is a re-plan, not a
|
|
3494
|
+
// crash, so it falls through to the call below.
|
|
3495
|
+
const cached = SfxPlanCacheSchema.safeParse(
|
|
3496
|
+
JSON.parse(await readFile(sfxCache, "utf8")),
|
|
3497
|
+
);
|
|
3498
|
+
if (cached.success) {
|
|
3499
|
+
sfxPlan = { level: sfxLevel, placements: cached.data.placements };
|
|
3500
|
+
sfxPlanned = cached.data.planned;
|
|
3501
|
+
sfxIssues = cached.data.issues;
|
|
3502
|
+
console.log(`▸ sfx cached (${sfxPlan.placements.length} placement(s), level ${sfxLevel})`);
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
if (!sfxPlan && fullSheet === undefined) {
|
|
3506
|
+
// The one case the cache cannot cover: a workdir planned before
|
|
3507
|
+
// `--sfx` existed (or by a pre-2026-08-29 build) has scenes but no
|
|
3508
|
+
// moments, so there is no graphics plan to place against. Say what
|
|
3509
|
+
// to do rather than silently rendering a mute video.
|
|
3510
|
+
console.log(
|
|
3511
|
+
" ⚠ sfx: this workdir's cached plan has no beat sheet to place against — " +
|
|
3512
|
+
"re-plan (delete its scenes-*.json) to get sound effects",
|
|
3513
|
+
);
|
|
3514
|
+
} else if (!sfxPlan) {
|
|
3515
|
+
console.log(`▸ placing sound effects (level ${sfxLevel})…`);
|
|
3516
|
+
const result = await phases.time("llm", () =>
|
|
3517
|
+
generateSfxPlan(provider!, transcript, fullSheet!, library.sounds, sfxLevel),
|
|
3518
|
+
);
|
|
3519
|
+
sfxPlan = { level: sfxLevel, placements: result.plan.placements };
|
|
3520
|
+
sfxPlanned = result.planned;
|
|
3521
|
+
sfxIssues = result.issues;
|
|
3522
|
+
await writeFile(
|
|
3523
|
+
sfxCache,
|
|
3524
|
+
// The accounting rides the cache for §78's reason, the same one
|
|
3525
|
+
// `graphics`/`issues` ride the beat-sheet cache: a cached re-run
|
|
3526
|
+
// must be able to print the SAME accounting line rather than
|
|
3527
|
+
// erasing it, and `planned` is a count only the call itself knew.
|
|
3528
|
+
JSON.stringify(
|
|
3529
|
+
{ placements: sfxPlan.placements, planned: sfxPlanned, issues: sfxIssues },
|
|
3530
|
+
null,
|
|
3531
|
+
2,
|
|
3532
|
+
),
|
|
3533
|
+
);
|
|
3534
|
+
}
|
|
3535
|
+
for (const issue of sfxIssues) {
|
|
3536
|
+
console.log(` ⚠ sfx placement ${issue.placement}: ${issue.issue}`);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
// `--sfx` on a run that never reached the placement call — `--scenes` (which
|
|
3543
|
+
// is what the editor's Render replays), or a plain cut with no producer at
|
|
3544
|
+
// all.
|
|
3545
|
+
//
|
|
3546
|
+
// The plan carries FORWARD from the workdir's last `production.json` here
|
|
3547
|
+
// (Phase 3), and that is the scenes-reviewed doctrine applied to sound: an
|
|
3548
|
+
// editor render replays the REVIEWED state, and for SFX the reviewed state
|
|
3549
|
+
// is the prior plan plus overrides.json's edits on top of it (`priorSfxPlan`
|
|
3550
|
+
// has the full argument). The level comes from that record too — the user
|
|
3551
|
+
// reviewed effects placed at THAT level, and re-reading `--sfx-level` here
|
|
3552
|
+
// would describe them with a number they were never planned under. It is
|
|
3553
|
+
// written back below like any other run's plan, so a chain of editor renders
|
|
3554
|
+
// never breaks.
|
|
3555
|
+
//
|
|
3556
|
+
// No prior record is the only case left with nothing to carry: never a
|
|
3557
|
+
// silent ignore, and the message names the missing half rather than the flag
|
|
3558
|
+
// the user typed.
|
|
3559
|
+
if (sfxOn && !sfxAttempted) {
|
|
3560
|
+
const prior = priorSfxPlan(work);
|
|
3561
|
+
if (prior === undefined) {
|
|
3562
|
+
console.log(
|
|
3563
|
+
" ⚠ sfx: sound effects are placed against the producer's beat sheet — " +
|
|
3564
|
+
"add --produce (the editor's re-plan) to get them",
|
|
3565
|
+
);
|
|
3566
|
+
} else {
|
|
3567
|
+
// The same library the producer branch loads, and for the same two
|
|
3568
|
+
// consumers: the resolver needs each sound's gain and path, the stager
|
|
3569
|
+
// needs its file. An empty library costs the effects, never the video.
|
|
3570
|
+
const library = loadSfxLibrary(sfxLoad);
|
|
3571
|
+
for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
|
|
3572
|
+
if (library.sounds.length === 0) {
|
|
3573
|
+
console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
|
|
3574
|
+
} else {
|
|
3575
|
+
sfxSounds = library.sounds;
|
|
3576
|
+
sfxPlan = prior;
|
|
3577
|
+
// `planned` is the reviewed plan's own size: this run made no model
|
|
3578
|
+
// call, so the only honest denominator for "N of M planned placed" is
|
|
3579
|
+
// what the user approved (the cached-plan branch's rule — the count
|
|
3580
|
+
// belongs to the plan, not to the run).
|
|
3581
|
+
sfxPlanned = prior.placements.length;
|
|
3582
|
+
console.log(
|
|
3583
|
+
`▸ sfx carried forward from the reviewed plan ` +
|
|
3584
|
+
`(${prior.placements.length} placement(s), level ${prior.level})`,
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3138
3588
|
}
|
|
3139
3589
|
|
|
3140
3590
|
// Every LLM call is behind us — repair, beat sheet, one per scene — so this
|
|
@@ -3906,6 +4356,77 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3906
4356
|
);
|
|
3907
4357
|
}
|
|
3908
4358
|
|
|
4359
|
+
// ---- Sound effects: word anchors → cues, and the files they name --------
|
|
4360
|
+
// AFTER the cut is final (`applyUserCuts` above) and after the public dir is
|
|
4361
|
+
// known, because both are inputs: a placement anchored to a word the user's
|
|
4362
|
+
// own cut removed is dropped here, and the copies must land where
|
|
4363
|
+
// `staticFile()` will look.
|
|
4364
|
+
let sfxCues: SfxCue[] = [];
|
|
4365
|
+
/** Planning drops + render drops, counted together by the one accounting
|
|
4366
|
+
* line the console and report.txt share (`formatSfxAccounting`). */
|
|
4367
|
+
let sfxAllIssues: SfxValidationIssue[] = sfxIssues;
|
|
4368
|
+
let sfxLine: string | undefined;
|
|
4369
|
+
if (sfxPlan) {
|
|
4370
|
+
// The user's layer FIRST (Phase 3): retimes, swaps, gains, mutes and the
|
|
4371
|
+
// placements they added themselves, applied before any of the resolver's
|
|
4372
|
+
// arithmetic so a dragged effect is timed, cut-checked and mixed exactly
|
|
4373
|
+
// like a planned one. `sfxPlan` itself is left alone — production.json
|
|
4374
|
+
// stores the MODEL's plan, which is what these edit keys are derived from
|
|
4375
|
+
// (`sfxPlacementKey`), and folding them in would re-key the whole layer on
|
|
4376
|
+
// the next run.
|
|
4377
|
+
const edited = applySfxOverrides(sfxPlan.placements, overrideDoc.sfx);
|
|
4378
|
+
for (const d of edited.dropped) {
|
|
4379
|
+
console.log(
|
|
4380
|
+
d.reason === "stale key"
|
|
4381
|
+
? ` ⚠ sfx edit "${d.key}" no longer matches any planned placement — ` +
|
|
4382
|
+
`the re-plan dropped it`
|
|
4383
|
+
: ` ⚠ sfx edit "${d.key}" matches more than one placement — applied to the first`,
|
|
4384
|
+
);
|
|
4385
|
+
}
|
|
4386
|
+
// The accounting's denominator moves with the user's layer, because the
|
|
4387
|
+
// layer changes the SIZE of the plan the resolver saw: a mute REMOVES a
|
|
4388
|
+
// placement (it was un-planned by the user, not dropped by the pipeline)
|
|
4389
|
+
// and an add appends one. Without this, muting an effect would print as a
|
|
4390
|
+
// reasonless "1 dropped" and adding one as "6 of 5 planned placed".
|
|
4391
|
+
sfxPlanned += edited.placements.length - sfxPlan.placements.length;
|
|
4392
|
+
const resolved = resolveSfxCues(edited.placements, transcript, map, sfxSounds, {
|
|
4393
|
+
// The real check, injected (the resolver stays pure): a pack deleted
|
|
4394
|
+
// between planning and this render must cost the cue here, not a
|
|
4395
|
+
// Remotion 404 after the render has spent its minutes.
|
|
4396
|
+
exists: existsSync,
|
|
4397
|
+
// The scene timing context, from `sceneCues` — the FINAL list, with the
|
|
4398
|
+
// user's moves, trims, splits, pins and deletes already applied. That is
|
|
4399
|
+
// the whole point of the scene link (2026-08-29): a whoosh placed "as
|
|
4400
|
+
// the TitleCard enters" fired at a word, so moving the card in the
|
|
4401
|
+
// editor left the sound behind. Reading the producer's raw `scenes`
|
|
4402
|
+
// here instead would rebuild that bug exactly.
|
|
4403
|
+
sceneStarts: sceneStartSeconds(sceneCues),
|
|
4404
|
+
});
|
|
4405
|
+
sfxCues = resolved.cues;
|
|
4406
|
+
sfxAllIssues = [...sfxIssues, ...resolved.dropped];
|
|
4407
|
+
for (const d of resolved.dropped) console.log(` ⚠ sfx: ${d.issue}`);
|
|
4408
|
+
// Staged into the render's public dir AND the workdir when they differ,
|
|
4409
|
+
// for the Nastaliq font's reason verbatim: the render bundles
|
|
4410
|
+
// `dirname(renderVideo)`, `ossclip edit` serves the workdir, and both
|
|
4411
|
+
// mounts fetch the same served name. Only the sounds actually CUED are
|
|
4412
|
+
// copied — the library is a menu, not a payload.
|
|
4413
|
+
const staged = new Set(sfxCues.map((c) => c.soundFile));
|
|
4414
|
+
for (const sound of sfxSounds) {
|
|
4415
|
+
const rel = sfxStagedFile(sound);
|
|
4416
|
+
if (!staged.has(rel)) continue;
|
|
4417
|
+
for (const dir of new Set([renderPublicDirPath, work])) {
|
|
4418
|
+
// `join` on the filesystem side where `rel` itself stays
|
|
4419
|
+
// POSIX-literal — it is a served URL, these are paths (the
|
|
4420
|
+
// `sideImageDestRel` split).
|
|
4421
|
+
const dest = join(dir, ...rel.split("/"));
|
|
4422
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
4423
|
+
copyFileSync(sound.absPath, dest);
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
sfxLine = formatSfxAccounting(sfxCues.length, sfxPlanned, sfxPlan.level, sfxAllIssues);
|
|
4427
|
+
console.log(`▸ ${sfxLine}`);
|
|
4428
|
+
}
|
|
4429
|
+
|
|
3909
4430
|
const production: Production = {
|
|
3910
4431
|
version: 1,
|
|
3911
4432
|
// `originalInput`, not `input`: for a folder run `input` is by now
|
|
@@ -3935,6 +4456,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3935
4456
|
? { clip: { targetSec: clipTargetSec, ...clipWindow } }
|
|
3936
4457
|
: {}),
|
|
3937
4458
|
scenes: scenes.length > 0 ? scenes : undefined,
|
|
4459
|
+
// The PLAN, not the cues: word anchors survive a re-cut and the editor
|
|
4460
|
+
// edits them (Phase 3), while `render-props.json` carries the resolved
|
|
4461
|
+
// instants. Absent when this run has no sound design, so a no-`--sfx`
|
|
4462
|
+
// production.json is byte-identical to a pre-feature one.
|
|
4463
|
+
sfx: sfxPlan,
|
|
3938
4464
|
producer: producerStamp,
|
|
3939
4465
|
theme,
|
|
3940
4466
|
// The EFFECTIVE output size, not the base frame (`--resolution`): this is
|
|
@@ -4022,6 +4548,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4022
4548
|
.map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
|
|
4023
4549
|
.join("");
|
|
4024
4550
|
}
|
|
4551
|
+
// The SFX accounting, the graphics block's contract exactly (§118b): ONE
|
|
4552
|
+
// formatter feeds the console line printed above and this one, so the two
|
|
4553
|
+
// can never say different things about the same run. The drops are listed
|
|
4554
|
+
// under it for the same reason the beat issues are listed under the graphics
|
|
4555
|
+
// line — the number alone does not say WHICH effect went missing.
|
|
4556
|
+
if (sfxLine) {
|
|
4557
|
+
report +=
|
|
4558
|
+
`\n${sfxLine}\n` +
|
|
4559
|
+
sfxAllIssues.map((i) => ` ⚠ placement ${i.placement}: ${i.issue}\n`).join("");
|
|
4560
|
+
}
|
|
4025
4561
|
if (provider) {
|
|
4026
4562
|
report += formatUsageReport(provider.usage, cfg.pricing);
|
|
4027
4563
|
// A cached run has no usage block to print, and used to leave the report
|
|
@@ -4265,6 +4801,88 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4265
4801
|
// on a bar-free source): there is no re-encode to scale, the render plays
|
|
4266
4802
|
// the source itself, and the window emissions below must then stay in true
|
|
4267
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
|
+
|
|
4268
4886
|
const mezzScale = mezzanineWillBuild
|
|
4269
4887
|
? mezzanineScale(
|
|
4270
4888
|
{ width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
|
|
@@ -4277,7 +4895,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4277
4895
|
// why): mezzanine caching is existence-keyed, so a pre-pass full-res
|
|
4278
4896
|
// mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
|
|
4279
4897
|
// windows — the scaled file rebuilds once under its own name.
|
|
4280
|
-
|
|
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));
|
|
4281
4902
|
if (!existsSync(mezz)) {
|
|
4282
4903
|
const mezzAnim = isInteractive()
|
|
4283
4904
|
? new StageAnimator(
|
|
@@ -4298,6 +4919,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4298
4919
|
await makeMezzanine(tools, input, mezz, {
|
|
4299
4920
|
cropVf: cropVf || undefined,
|
|
4300
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,
|
|
4301
4926
|
});
|
|
4302
4927
|
if (mezzAnim) mezzAnim.stop();
|
|
4303
4928
|
}
|
|
@@ -4614,6 +5239,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4614
5239
|
// actually render.
|
|
4615
5240
|
...(captionsHidden ? { captionsHidden: true } : {}),
|
|
4616
5241
|
...(opts.captions === false ? { captionsHiddenByFlag: true } : {}),
|
|
5242
|
+
// `--sfx`, written only when there is something to play (the watermark's
|
|
5243
|
+
// absent-means-off contract): a run without sound effects keeps a
|
|
5244
|
+
// render-props.json — and an audio graph — byte-identical to a pre-feature
|
|
5245
|
+
// one, and the composition reads an absent key as silence, not as an empty
|
|
5246
|
+
// track it still has to mount.
|
|
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 } : {}),
|
|
4617
5256
|
};
|
|
4618
5257
|
await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
|
|
4619
5258
|
|
|
@@ -4785,6 +5424,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4785
5424
|
portrait,
|
|
4786
5425
|
audience,
|
|
4787
5426
|
thumbnailBrief,
|
|
5427
|
+
// The SFX switch and level, RESOLVED (flag + config folded) — the
|
|
5428
|
+
// watermark's rationale on a flag whose default is config-dependent
|
|
5429
|
+
// (`sfx`/`sfxLevel` in ~/.ossclip/config.json). Unpinned, the editor's
|
|
5430
|
+
// Render would place a DIFFERENT amount of sound design the moment that
|
|
5431
|
+
// config is edited, or none at all on a machine without it — and since
|
|
5432
|
+
// the replay carries the reviewed plan forward rather than re-placing
|
|
5433
|
+
// (`priorSfxPlan`), an unpinned `--sfx` is the difference between the
|
|
5434
|
+
// reviewed sound design and a silent video.
|
|
5435
|
+
sfx: sfxOn,
|
|
5436
|
+
sfxLevel,
|
|
4788
5437
|
});
|
|
4789
5438
|
// produce is the ONLY command that may write command.json — edit.ts's §129
|
|
4790
5439
|
// heal prepends the "produce" literal to any record that doesn't start
|