@ossclip/core 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/package.json +1 -1
- package/src/browser.ts +15 -0
- package/src/color-grade.ts +562 -0
- package/src/config.ts +22 -0
- package/src/index.ts +2 -0
- package/src/ingest.ts +51 -8
- package/src/lut-library.ts +81 -0
- package/src/overrides.ts +32 -0
- package/src/recut.ts +32 -3
- package/src/scene-schema.ts +3 -0
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -18,6 +18,21 @@ export * from "./fill";
|
|
|
18
18
|
// on this surface), but only this one function is exposed: the rest of the
|
|
19
19
|
// module is the pipeline's, not the bundle's.
|
|
20
20
|
export { sceneStartSeconds } from "./assemble";
|
|
21
|
+
// The editor's live grade preview computes the SVG filter spec with the SAME
|
|
22
|
+
// math produce bakes into render-props (`gradeToSvgFilterSpec`) — a browser
|
|
23
|
+
// copy of the curve sampler is how the preview and the render would drift.
|
|
24
|
+
// color-grade.ts is already in this surface's runtime graph (./overrides
|
|
25
|
+
// imports ColorGradeSchema for the doc-global `colorGrade` key), so these
|
|
26
|
+
// value exports add no new modules; only the functions the preview needs are
|
|
27
|
+
// exposed, the `sceneStartSeconds` posture.
|
|
28
|
+
export {
|
|
29
|
+
GRADE_PRESETS,
|
|
30
|
+
gradeToSvgFilterSpec,
|
|
31
|
+
resolveGradeToLook,
|
|
32
|
+
type ColorGrade,
|
|
33
|
+
type GradePresetId,
|
|
34
|
+
type SvgGradeFilterSpec,
|
|
35
|
+
} from "./color-grade";
|
|
21
36
|
export { ZOOM_MAX_SCALE, zoomScaleAt, type ZoomSegment } from "./zoom";
|
|
22
37
|
// Pure geometry only — the ffmpeg/cache half lives in ./content-rect-detect
|
|
23
38
|
// and must never enter the Remotion bundle.
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Color grade — the whole module is pure (no filesystem, no TTY) so every
|
|
5
|
+
* curve, LUT sample and matrix can be tested against hand-computed values.
|
|
6
|
+
* `node:crypto` is the one Node import, and it is deterministic.
|
|
7
|
+
*
|
|
8
|
+
* The user-facing shape below is shared by config.json, overrides.json and
|
|
9
|
+
* the CLI, so it is parsed with zod here once rather than coerced at three
|
|
10
|
+
* consumers. `preset` names a bundled parametric look; `lut` names a `.cube`
|
|
11
|
+
* file (basename only — path resolution is the caller's I/O, not ours).
|
|
12
|
+
*/
|
|
13
|
+
export const ColorGradeSchema = z
|
|
14
|
+
.strictObject({
|
|
15
|
+
/** Bundled parametric look id — one of `Object.keys(GRADE_PRESETS)`. */
|
|
16
|
+
preset: z.string().optional(),
|
|
17
|
+
/** `.cube` filename in `~/.ossclip/luts`, basename only. */
|
|
18
|
+
lut: z.string().optional(),
|
|
19
|
+
/** 0..1 blend toward the graded image. Default: the preset's own, or 1 for a LUT. */
|
|
20
|
+
intensity: z.number().min(0).max(1).optional(),
|
|
21
|
+
/** Exposure in EV (stops). */
|
|
22
|
+
exposure: z.number().min(-2).max(2).optional(),
|
|
23
|
+
/** Warm (+) / cool (−), a deliberately small per-channel gain. */
|
|
24
|
+
temperature: z.number().min(-100).max(100).optional(),
|
|
25
|
+
/** 1 = unchanged. */
|
|
26
|
+
saturation: z.number().min(0).max(2).optional(),
|
|
27
|
+
/** 1 = unchanged. */
|
|
28
|
+
contrast: z.number().min(0).max(2).optional(),
|
|
29
|
+
})
|
|
30
|
+
.refine((g) => (g.preset !== undefined) !== (g.lut !== undefined), {
|
|
31
|
+
message: 'set exactly one of "preset" or "lut" — a grade needs one look, not zero or two',
|
|
32
|
+
});
|
|
33
|
+
export type ColorGrade = z.infer<typeof ColorGradeSchema>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The internal parametric look. Tints are small additive per-channel biases
|
|
37
|
+
* weighted by luma (split-toning): `shadowTint` pulls the darks, one minus
|
|
38
|
+
* luma at a time, `highlightTint` the brights.
|
|
39
|
+
*/
|
|
40
|
+
export interface LookParams {
|
|
41
|
+
/** -100..100, warm (+) / cool (−) — mapped to per-channel gains in `applyGrade`. */
|
|
42
|
+
temperature: number;
|
|
43
|
+
/** Green-magenta axis, same tiny-gain scale as temperature. */
|
|
44
|
+
tint: number;
|
|
45
|
+
/** EV. */
|
|
46
|
+
exposure: number;
|
|
47
|
+
/** Pivot-0.5 slope; 1 = unchanged. */
|
|
48
|
+
contrast: number;
|
|
49
|
+
/** Raised black floor, 0..~0.1 in practice. */
|
|
50
|
+
lift: number;
|
|
51
|
+
/** 1 = unchanged, 0 = mono. */
|
|
52
|
+
saturation: number;
|
|
53
|
+
shadowTint: [number, number, number];
|
|
54
|
+
highlightTint: [number, number, number];
|
|
55
|
+
/** What `intensity` means when the user didn't set one. */
|
|
56
|
+
defaultIntensity: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A look that changes nothing — the base every preset and tweak builds on. */
|
|
60
|
+
export const IDENTITY_LOOK: LookParams = {
|
|
61
|
+
temperature: 0,
|
|
62
|
+
tint: 0,
|
|
63
|
+
exposure: 0,
|
|
64
|
+
contrast: 1,
|
|
65
|
+
lift: 0,
|
|
66
|
+
saturation: 1,
|
|
67
|
+
shadowTint: [0, 0, 0],
|
|
68
|
+
highlightTint: [0, 0, 0],
|
|
69
|
+
defaultIntensity: 1,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type GradePresetId = "talking-head" | "teal-orange" | "filmic-fade" | "cwa" | "punchy" | "mono";
|
|
73
|
+
|
|
74
|
+
export const GRADE_PRESETS: Record<GradePresetId, LookParams> = {
|
|
75
|
+
// The flagship for YouTube talking-head footage: skin-safe, so hue shifts
|
|
76
|
+
// stay mild — warmth and a gentle S-curve, nothing that moves skin tones.
|
|
77
|
+
// Tuned on real channel footage (2026-08-30): the source is already warm
|
|
78
|
+
// tungsten light, so temperature stays small; and because contrast runs
|
|
79
|
+
// before lift in applyGrade, a 1.08 slope at pivot 0.5 pulls black down
|
|
80
|
+
// 0.04 — more than a 0.03 lift puts back. 1.06/0.045 keeps the fade.
|
|
81
|
+
"talking-head": {
|
|
82
|
+
...IDENTITY_LOOK,
|
|
83
|
+
temperature: 10,
|
|
84
|
+
contrast: 1.06,
|
|
85
|
+
lift: 0.045,
|
|
86
|
+
saturation: 1.05,
|
|
87
|
+
defaultIntensity: 0.6,
|
|
88
|
+
},
|
|
89
|
+
// The blockbuster split-tone: teal shadows against warm highlights.
|
|
90
|
+
"teal-orange": {
|
|
91
|
+
...IDENTITY_LOOK,
|
|
92
|
+
shadowTint: [-0.03, 0.01, 0.04],
|
|
93
|
+
highlightTint: [0.04, 0.01, -0.03],
|
|
94
|
+
contrast: 1.15,
|
|
95
|
+
saturation: 1.05,
|
|
96
|
+
defaultIntensity: 0.7,
|
|
97
|
+
},
|
|
98
|
+
// Faded film stock: lifted blacks, softened contrast, muted color.
|
|
99
|
+
"filmic-fade": {
|
|
100
|
+
...IDENTITY_LOOK,
|
|
101
|
+
lift: 0.06,
|
|
102
|
+
contrast: 0.95,
|
|
103
|
+
saturation: 0.9,
|
|
104
|
+
temperature: 8,
|
|
105
|
+
defaultIntensity: 0.8,
|
|
106
|
+
},
|
|
107
|
+
// Thumbnail-bright: more contrast and more color, for footage shot flat.
|
|
108
|
+
punchy: {
|
|
109
|
+
...IDENTITY_LOOK,
|
|
110
|
+
contrast: 1.2,
|
|
111
|
+
saturation: 1.15,
|
|
112
|
+
defaultIntensity: 0.7,
|
|
113
|
+
},
|
|
114
|
+
// Ahsan's channel look (2026-08-30), designed against his published footage:
|
|
115
|
+
// camera runs ~0.1 EV under, the room's tungsten already supplies warmth, so
|
|
116
|
+
// exposure does the lifting and temperature barely moves. Teal in the
|
|
117
|
+
// shadows / warm highlights for depth that doesn't announce itself; params
|
|
118
|
+
// are at final strength, hence defaultIntensity 1.
|
|
119
|
+
cwa: {
|
|
120
|
+
...IDENTITY_LOOK,
|
|
121
|
+
temperature: 6,
|
|
122
|
+
exposure: 0.1,
|
|
123
|
+
contrast: 1.09,
|
|
124
|
+
lift: 0.05,
|
|
125
|
+
saturation: 1.06,
|
|
126
|
+
shadowTint: [-0.015, 0.0, 0.02],
|
|
127
|
+
highlightTint: [0.02, 0.005, -0.01],
|
|
128
|
+
defaultIntensity: 1,
|
|
129
|
+
},
|
|
130
|
+
// Black and white with a touch of contrast to keep it from going gray mush.
|
|
131
|
+
mono: {
|
|
132
|
+
...IDENTITY_LOOK,
|
|
133
|
+
saturation: 0,
|
|
134
|
+
contrast: 1.1,
|
|
135
|
+
defaultIntensity: 1,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Rec.709 luma weights — the footage this pipeline grades is Rec.709. */
|
|
140
|
+
const LUMA: [number, number, number] = [0.2126, 0.7152, 0.0722];
|
|
141
|
+
|
|
142
|
+
const clamp01 = (c: number): number => Math.min(1, Math.max(0, c));
|
|
143
|
+
const mix = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Steps 1–4 of the pipeline — the channel-INDEPENDENT portion (exposure,
|
|
147
|
+
* temperature/tint gains, contrast, lift). Split out so `applyGrade` and the
|
|
148
|
+
* SVG filter's per-channel transfer tables are the same math by construction:
|
|
149
|
+
* a curve sampled here IS the curve the evaluator ran.
|
|
150
|
+
*
|
|
151
|
+
* Unclamped on purpose: `applyGrade` clamps once at the end, and clamping
|
|
152
|
+
* here would bake a different (earlier) clip point into the tables.
|
|
153
|
+
*/
|
|
154
|
+
function channelCurve(params: LookParams, channel: 0 | 1 | 2, value: number): number {
|
|
155
|
+
// 1. exposure, in stops.
|
|
156
|
+
let c = value * 2 ** params.exposure;
|
|
157
|
+
// 2. temperature/tint as per-channel gains. 0.0015/unit keeps the full
|
|
158
|
+
// ±100 range at a ±15% gain — a grade, not a gel.
|
|
159
|
+
const gain =
|
|
160
|
+
channel === 0
|
|
161
|
+
? 1 + params.temperature * 0.0015
|
|
162
|
+
: channel === 1
|
|
163
|
+
? 1 + params.tint * 0.0015
|
|
164
|
+
: 1 - params.temperature * 0.0015;
|
|
165
|
+
c *= gain;
|
|
166
|
+
// 3. contrast about a 0.5 pivot. A linear pivot, not a filmic S — fine for
|
|
167
|
+
// v1 in gamma-encoded space, where 0.5 is already perceptual mid-gray.
|
|
168
|
+
c = 0.5 + (c - 0.5) * params.contrast;
|
|
169
|
+
// 4. lift: raise the black floor without touching white.
|
|
170
|
+
c = c * (1 - params.lift) + params.lift;
|
|
171
|
+
return c;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The single source of truth for what a look DOES to a pixel. Everything
|
|
176
|
+
* else — the SVG filter spec, the baked .cube — is an encoding of this
|
|
177
|
+
* function, and the tests hold them to it.
|
|
178
|
+
*
|
|
179
|
+
* Operates in gamma-encoded space (pragmatic: the footage is Rec.709, and a
|
|
180
|
+
* linearize/delinearize round-trip buys little for these mild looks).
|
|
181
|
+
* Input and output are 0..1 per channel; output is clamped.
|
|
182
|
+
*/
|
|
183
|
+
export function applyGrade(
|
|
184
|
+
params: LookParams,
|
|
185
|
+
rgb: [number, number, number],
|
|
186
|
+
): [number, number, number] {
|
|
187
|
+
const c: [number, number, number] = [
|
|
188
|
+
channelCurve(params, 0, rgb[0]),
|
|
189
|
+
channelCurve(params, 1, rgb[1]),
|
|
190
|
+
channelCurve(params, 2, rgb[2]),
|
|
191
|
+
];
|
|
192
|
+
// 5. split-tone: tint shadows and highlights by luma weight.
|
|
193
|
+
const luma = LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2];
|
|
194
|
+
for (let i = 0; i < 3; i++) {
|
|
195
|
+
c[i] = c[i]! + params.shadowTint[i]! * (1 - luma) + params.highlightTint[i]! * luma;
|
|
196
|
+
}
|
|
197
|
+
// 6. saturation: pull toward (or push away from) the pixel's luma.
|
|
198
|
+
const luma2 = LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2];
|
|
199
|
+
return [
|
|
200
|
+
clamp01(mix(luma2, c[0], params.saturation)),
|
|
201
|
+
clamp01(mix(luma2, c[1], params.saturation)),
|
|
202
|
+
clamp01(mix(luma2, c[2], params.saturation)),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `mix(rgb, applyGrade(rgb), intensity)` — the user's blend knob. */
|
|
207
|
+
export function applyGradeWithIntensity(
|
|
208
|
+
params: LookParams,
|
|
209
|
+
intensity: number,
|
|
210
|
+
rgb: [number, number, number],
|
|
211
|
+
): [number, number, number] {
|
|
212
|
+
const graded = applyGrade(params, rgb);
|
|
213
|
+
return [
|
|
214
|
+
mix(rgb[0], graded[0], intensity),
|
|
215
|
+
mix(rgb[1], graded[1], intensity),
|
|
216
|
+
mix(rgb[2], graded[2], intensity),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ResolvedGrade {
|
|
221
|
+
params: LookParams;
|
|
222
|
+
intensity: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The two-stage SVG filter: feComponentTransfer tables, then feColorMatrix. */
|
|
226
|
+
export interface SvgGradeFilterSpec {
|
|
227
|
+
/** 33 samples each, for `<feFuncR type="table">` etc. */
|
|
228
|
+
tableR: number[];
|
|
229
|
+
tableG: number[];
|
|
230
|
+
tableB: number[];
|
|
231
|
+
/** 20 values row-major, for `<feColorMatrix type="matrix">`. */
|
|
232
|
+
colorMatrix: number[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Samples per transfer-table channel — 33 matches the default .cube lattice. */
|
|
236
|
+
const SVG_TABLE_SAMPLES = 33;
|
|
237
|
+
|
|
238
|
+
/** 4x5 affine identity in feColorMatrix's 20-value row-major layout. */
|
|
239
|
+
const IDENTITY_MATRIX: number[] = [
|
|
240
|
+
1, 0, 0, 0, 0,
|
|
241
|
+
0, 1, 0, 0, 0,
|
|
242
|
+
0, 0, 1, 0, 0,
|
|
243
|
+
0, 0, 0, 1, 0,
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
/** `a ∘ b` for 4x5 affine color matrices (apply `b` first, then `a`). */
|
|
247
|
+
function composeColorMatrix(a: number[], b: number[]): number[] {
|
|
248
|
+
const out = new Array<number>(20).fill(0);
|
|
249
|
+
for (let r = 0; r < 4; r++) {
|
|
250
|
+
for (let c = 0; c < 4; c++) {
|
|
251
|
+
let v = 0;
|
|
252
|
+
for (let k = 0; k < 4; k++) v += a[r * 5 + k]! * b[k * 5 + c]!;
|
|
253
|
+
out[r * 5 + c] = v;
|
|
254
|
+
}
|
|
255
|
+
let konst = a[r * 5 + 4]!;
|
|
256
|
+
for (let k = 0; k < 4; k++) konst += a[r * 5 + k]! * b[k * 5 + 4]!;
|
|
257
|
+
out[r * 5 + 4] = konst;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Encode a resolved grade as an SVG filter. Per-channel curves cannot carry
|
|
264
|
+
* cross-channel ops, so the pipeline decomposes into:
|
|
265
|
+
*
|
|
266
|
+
* - Stage A (feComponentTransfer tables): the channel-independent steps 1–4,
|
|
267
|
+
* sampled at 33 points per channel via the same `channelCurve` the
|
|
268
|
+
* evaluator runs.
|
|
269
|
+
* - Stage B (feColorMatrix): split-tone + saturation. Both are affine in
|
|
270
|
+
* rgb — luma is a dot product — so the stage is EXACT: the split-tone
|
|
271
|
+
* matrix (row c gains `(highlight_c − shadow_c)·L`, constant `shadow_c`)
|
|
272
|
+
* composed with the standard Rec.709 saturation matrix.
|
|
273
|
+
*
|
|
274
|
+
* Intensity blends Stage A toward identity tables and Stage B toward the
|
|
275
|
+
* identity matrix, each by `k`. That is an approximation of blending the
|
|
276
|
+
* COMPOSED pipeline (blend-then-compose ≠ compose-then-blend), accepted
|
|
277
|
+
* because it is exact at k=0 and k=1 and visually indistinguishable between,
|
|
278
|
+
* and the alternative — a third filter input carrying the ungraded image —
|
|
279
|
+
* costs an feImage round-trip per frame.
|
|
280
|
+
*/
|
|
281
|
+
export function gradeToSvgFilterSpec(grade: ResolvedGrade): SvgGradeFilterSpec {
|
|
282
|
+
const { params, intensity } = grade;
|
|
283
|
+
const table = (channel: 0 | 1 | 2): number[] => {
|
|
284
|
+
const out: number[] = [];
|
|
285
|
+
for (let i = 0; i < SVG_TABLE_SAMPLES; i++) {
|
|
286
|
+
const x = i / (SVG_TABLE_SAMPLES - 1);
|
|
287
|
+
// Clamp before blending: feComponentTransfer table values live in 0..1,
|
|
288
|
+
// and blending toward x keeps the result there.
|
|
289
|
+
out.push(mix(x, clamp01(channelCurve(params, channel, x)), intensity));
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const d: [number, number, number] = [
|
|
295
|
+
params.highlightTint[0] - params.shadowTint[0],
|
|
296
|
+
params.highlightTint[1] - params.shadowTint[1],
|
|
297
|
+
params.highlightTint[2] - params.shadowTint[2],
|
|
298
|
+
];
|
|
299
|
+
const split: number[] = [
|
|
300
|
+
1 + d[0] * LUMA[0], d[0] * LUMA[1], d[0] * LUMA[2], 0, params.shadowTint[0],
|
|
301
|
+
d[1] * LUMA[0], 1 + d[1] * LUMA[1], d[1] * LUMA[2], 0, params.shadowTint[1],
|
|
302
|
+
d[2] * LUMA[0], d[2] * LUMA[1], 1 + d[2] * LUMA[2], 0, params.shadowTint[2],
|
|
303
|
+
0, 0, 0, 1, 0,
|
|
304
|
+
];
|
|
305
|
+
const s = params.saturation;
|
|
306
|
+
const sat: number[] = [
|
|
307
|
+
(1 - s) * LUMA[0] + s, (1 - s) * LUMA[1], (1 - s) * LUMA[2], 0, 0,
|
|
308
|
+
(1 - s) * LUMA[0], (1 - s) * LUMA[1] + s, (1 - s) * LUMA[2], 0, 0,
|
|
309
|
+
(1 - s) * LUMA[0], (1 - s) * LUMA[1], (1 - s) * LUMA[2] + s, 0, 0,
|
|
310
|
+
0, 0, 0, 1, 0,
|
|
311
|
+
];
|
|
312
|
+
const composed = composeColorMatrix(sat, split);
|
|
313
|
+
const colorMatrix = composed.map((v, i) => mix(IDENTITY_MATRIX[i]!, v, intensity));
|
|
314
|
+
|
|
315
|
+
return { tableR: table(0), tableG: table(1), tableB: table(2), colorMatrix };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A parsed .cube 3D LUT — `data` is r-fastest, length `3 * size³`. */
|
|
319
|
+
export interface CubeLut {
|
|
320
|
+
size: number;
|
|
321
|
+
domainMin: [number, number, number];
|
|
322
|
+
domainMax: [number, number, number];
|
|
323
|
+
data: Float32Array;
|
|
324
|
+
title?: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse Adobe/Resolve `.cube` text. Lenient about FORM — BOM, CRLF, tabs,
|
|
329
|
+
* comments anywhere, keywords in any position — because .cube files come
|
|
330
|
+
* from a dozen exporters that each format differently. Strict about
|
|
331
|
+
* CONTENT — size range, exact line count, finite floats — because a
|
|
332
|
+
* half-parsed LUT is a wrong grade on every frame, and the file is user
|
|
333
|
+
* input the caller will report, not swallow.
|
|
334
|
+
*
|
|
335
|
+
* Values outside 0..1 are PRESERVED here: log/HDR LUTs legitimately exceed
|
|
336
|
+
* the domain, and clamping belongs at apply time (`sampleCubeLut`), not
|
|
337
|
+
* parse time. `Number`, never `parseFloat` with a locale — a comma decimal
|
|
338
|
+
* must fail loudly, not truncate silently.
|
|
339
|
+
*/
|
|
340
|
+
export function parseCubeLut(text: string): CubeLut {
|
|
341
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
342
|
+
let size: number | undefined;
|
|
343
|
+
let title: string | undefined;
|
|
344
|
+
const domainMin: [number, number, number] = [0, 0, 0];
|
|
345
|
+
const domainMax: [number, number, number] = [1, 1, 1];
|
|
346
|
+
const values: number[] = [];
|
|
347
|
+
|
|
348
|
+
const parseTriple = (fields: string[], line: string): [number, number, number] => {
|
|
349
|
+
if (fields.length !== 3) throw new Error(`.cube: expected 3 values per line, got "${line}"`);
|
|
350
|
+
const nums = fields.map(Number) as [number, number, number];
|
|
351
|
+
if (nums.some((n) => !Number.isFinite(n))) {
|
|
352
|
+
throw new Error(`.cube: non-numeric value in line "${line}"`);
|
|
353
|
+
}
|
|
354
|
+
return nums;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
for (const raw of lines) {
|
|
358
|
+
const line = raw.trim();
|
|
359
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
360
|
+
const fields = line.split(/\s+/);
|
|
361
|
+
const first = fields[0]!; // a trimmed non-empty line always has a first field
|
|
362
|
+
const keyword = first.toUpperCase();
|
|
363
|
+
if (keyword === "TITLE") {
|
|
364
|
+
// Everything after the keyword, quotes stripped — titles contain spaces.
|
|
365
|
+
title = line.slice(first.length).trim().replace(/^"(.*)"$/, "$1");
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (keyword === "LUT_1D_SIZE") throw new Error(".cube: 1D LUTs not supported");
|
|
369
|
+
if (keyword === "LUT_3D_SIZE") {
|
|
370
|
+
const n = Number(fields[1]);
|
|
371
|
+
if (!Number.isInteger(n) || n < 2 || n > 256) {
|
|
372
|
+
throw new Error(`.cube: LUT_3D_SIZE must be an integer in 2..256, got "${fields[1]}"`);
|
|
373
|
+
}
|
|
374
|
+
size = n;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (keyword === "DOMAIN_MIN" || keyword === "DOMAIN_MAX") {
|
|
378
|
+
const nums = parseTriple(fields.slice(1), line);
|
|
379
|
+
const target = keyword === "DOMAIN_MIN" ? domainMin : domainMax;
|
|
380
|
+
for (let i = 0; i < 3; i++) target[i] = nums[i]!;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (/^[A-Za-z_]/.test(first)) {
|
|
384
|
+
throw new Error(`.cube: unrecognized keyword "${first}"`);
|
|
385
|
+
}
|
|
386
|
+
values.push(...parseTriple(fields, line));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (size === undefined) throw new Error(".cube: missing LUT_3D_SIZE");
|
|
390
|
+
const expected = 3 * size ** 3;
|
|
391
|
+
if (values.length !== expected) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`.cube: expected ${size}³ = ${expected / 3} data lines, got ${values.length / 3}`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return { size, domainMin, domainMax, data: Float32Array.from(values), title };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Trilinear sample. Input mapped through the LUT's domain; output clamped 0..1. */
|
|
400
|
+
export function sampleCubeLut(
|
|
401
|
+
lut: CubeLut,
|
|
402
|
+
rgb: [number, number, number],
|
|
403
|
+
): [number, number, number] {
|
|
404
|
+
const n = lut.size;
|
|
405
|
+
const axis = (i: 0 | 1 | 2): number => {
|
|
406
|
+
const span = lut.domainMax[i] - lut.domainMin[i];
|
|
407
|
+
const t = span === 0 ? 0 : (rgb[i] - lut.domainMin[i]) / span;
|
|
408
|
+
return clamp01(t) * (n - 1);
|
|
409
|
+
};
|
|
410
|
+
const coord: [number, number, number] = [axis(0), axis(1), axis(2)];
|
|
411
|
+
const lo = coord.map(Math.floor) as [number, number, number];
|
|
412
|
+
const hi = lo.map((v) => Math.min(v + 1, n - 1)) as [number, number, number];
|
|
413
|
+
const f = coord.map((v, i) => v - lo[i]!) as [number, number, number];
|
|
414
|
+
// r fastest in the data layout: index = 3 * (r + n*g + n²*b).
|
|
415
|
+
const at = (r: number, g: number, b: number, ch: number): number =>
|
|
416
|
+
lut.data[3 * (r + n * g + n * n * b) + ch]!;
|
|
417
|
+
const out: [number, number, number] = [0, 0, 0];
|
|
418
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
419
|
+
const c00 = mix(at(lo[0], lo[1], lo[2], ch), at(hi[0], lo[1], lo[2], ch), f[0]);
|
|
420
|
+
const c10 = mix(at(lo[0], hi[1], lo[2], ch), at(hi[0], hi[1], lo[2], ch), f[0]);
|
|
421
|
+
const c01 = mix(at(lo[0], lo[1], hi[2], ch), at(hi[0], lo[1], hi[2], ch), f[0]);
|
|
422
|
+
const c11 = mix(at(lo[0], hi[1], hi[2], ch), at(hi[0], hi[1], hi[2], ch), f[0]);
|
|
423
|
+
out[ch] = clamp01(mix(mix(c00, c10, f[1]), mix(c01, c11, f[1]), f[2]));
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Emit .cube text for a look — the renderer-agnostic encoding. Each lattice
|
|
430
|
+
* point p (r fastest) becomes `mix(p, pipeline(p), intensity)` where the
|
|
431
|
+
* pipeline is the base LUT sample (when one is given) with
|
|
432
|
+
* `applyGrade(params)` composed ON TOP of it (when given): sample first, then
|
|
433
|
+
* tweak. Composition, not either/or — `resolveGradeToLook` hands a LUT grade
|
|
434
|
+
* back as `lutRef` + `tweaks`, and a bake that ignored `params` next to a
|
|
435
|
+
* `base` would silently drop the user's exposure/saturation knobs exactly
|
|
436
|
+
* when a LUT is in play (the pre-2026-08-30 behavior, once a documented
|
|
437
|
+
* deviation). Deterministic on purpose — fixed 6-decimal formatting, stable
|
|
438
|
+
* key order — so `lutHash` of the output is a valid cache key.
|
|
439
|
+
*/
|
|
440
|
+
export function bakeCube(opts: {
|
|
441
|
+
base?: CubeLut;
|
|
442
|
+
params?: LookParams;
|
|
443
|
+
intensity: number;
|
|
444
|
+
size?: number;
|
|
445
|
+
}): string {
|
|
446
|
+
const size = opts.size ?? opts.base?.size ?? 33;
|
|
447
|
+
const pipeline = (p: [number, number, number]): [number, number, number] => {
|
|
448
|
+
const sampled = opts.base ? sampleCubeLut(opts.base, p) : p;
|
|
449
|
+
return opts.params ? applyGrade(opts.params, sampled) : sampled;
|
|
450
|
+
};
|
|
451
|
+
const lines: string[] = ['TITLE "ossclip"', `LUT_3D_SIZE ${size}`];
|
|
452
|
+
for (let b = 0; b < size; b++) {
|
|
453
|
+
for (let g = 0; g < size; g++) {
|
|
454
|
+
for (let r = 0; r < size; r++) {
|
|
455
|
+
const p: [number, number, number] = [r / (size - 1), g / (size - 1), b / (size - 1)];
|
|
456
|
+
const out = pipeline(p);
|
|
457
|
+
lines.push(
|
|
458
|
+
([0, 1, 2] as const).map((i) => mix(p[i], out[i], opts.intensity).toFixed(6)).join(" "),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return `${lines.join("\n")}\n`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Short fingerprint of baked .cube text, for cache filenames. FNV-1a 64-bit,
|
|
468
|
+
* not sha256: this module is in `@ossclip/core/browser`'s graph (overrides.ts
|
|
469
|
+
* imports the schema), so a top-level `node:crypto` import broke the editor's
|
|
470
|
+
* Vite build outright. A cache key needs determinism, not cryptography, and
|
|
471
|
+
* 64 bits is collision-safe at the scale of one user's LUT directory.
|
|
472
|
+
*/
|
|
473
|
+
export function lutHash(cubeText: string): string {
|
|
474
|
+
const PRIME = 0x100000001b3n;
|
|
475
|
+
let h = 0xcbf29ce484222325n;
|
|
476
|
+
for (let i = 0; i < cubeText.length; i++) {
|
|
477
|
+
h ^= BigInt(cubeText.charCodeAt(i));
|
|
478
|
+
h = (h * PRIME) & 0xffffffffffffffffn;
|
|
479
|
+
}
|
|
480
|
+
return h.toString(16).padStart(16, "0").slice(0, 12);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Validate a grade value from config/overrides/CLI. Never throws and never
|
|
485
|
+
* coerces (CLAUDE.md's parse-don't-coerce): a malformed grade is one warning
|
|
486
|
+
* naming the source and no grade at all — a typo must cost the look, not the
|
|
487
|
+
* run. The warning is RETURNED rather than printed so this stays pure, the
|
|
488
|
+
* `resolveSfxBundledPack` shape.
|
|
489
|
+
*
|
|
490
|
+
* An unknown preset id is caught HERE, not in the schema, so the warning can
|
|
491
|
+
* list what exists — a schema enum would reject with zod's generic message.
|
|
492
|
+
*/
|
|
493
|
+
export function resolveColorGrade(
|
|
494
|
+
value: unknown,
|
|
495
|
+
source: string,
|
|
496
|
+
): { grade?: ColorGrade; warning?: string } {
|
|
497
|
+
if (value === undefined) return {};
|
|
498
|
+
const parsed = ColorGradeSchema.safeParse(value);
|
|
499
|
+
if (!parsed.success) {
|
|
500
|
+
return { warning: `⚠ ${source} colorGrade ignored — ${parsed.error.issues[0]?.message}` };
|
|
501
|
+
}
|
|
502
|
+
const preset = parsed.data.preset;
|
|
503
|
+
if (preset !== undefined && !(preset in GRADE_PRESETS)) {
|
|
504
|
+
return {
|
|
505
|
+
warning:
|
|
506
|
+
`⚠ ${source} colorGrade ignored — unknown preset "${preset}" ` +
|
|
507
|
+
`(have: ${Object.keys(GRADE_PRESETS).join(", ")})`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
return { grade: parsed.data };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export interface ResolvedLutGrade {
|
|
514
|
+
kind: "lut";
|
|
515
|
+
/** The `.cube` basename — the caller resolves it under `~/.ossclip/luts`. */
|
|
516
|
+
lutRef: string;
|
|
517
|
+
/** User tweaks as a look, applied ON TOP of the LUT sample. */
|
|
518
|
+
tweaks: LookParams;
|
|
519
|
+
intensity: number;
|
|
520
|
+
}
|
|
521
|
+
export interface ResolvedPresetGrade extends ResolvedGrade {
|
|
522
|
+
kind: "preset";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Merge a validated grade into something the renderer can run: preset params
|
|
527
|
+
* with the user's tweaks composed on top (exposure/temperature ADD,
|
|
528
|
+
* saturation/contrast MULTIPLY — a tweak is relative to the look, not a
|
|
529
|
+
* replacement for it), or a LUT reference with the tweaks as their own look.
|
|
530
|
+
* Intensity falls back to the preset's `defaultIntensity`, or 1 for a LUT.
|
|
531
|
+
*/
|
|
532
|
+
export function resolveGradeToLook(grade: ColorGrade): ResolvedPresetGrade | ResolvedLutGrade {
|
|
533
|
+
if (grade.lut !== undefined) {
|
|
534
|
+
return {
|
|
535
|
+
kind: "lut",
|
|
536
|
+
lutRef: grade.lut,
|
|
537
|
+
tweaks: {
|
|
538
|
+
...IDENTITY_LOOK,
|
|
539
|
+
exposure: grade.exposure ?? 0,
|
|
540
|
+
temperature: grade.temperature ?? 0,
|
|
541
|
+
saturation: grade.saturation ?? 1,
|
|
542
|
+
contrast: grade.contrast ?? 1,
|
|
543
|
+
},
|
|
544
|
+
intensity: grade.intensity ?? 1,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
// `resolveColorGrade` guarantees the id exists; a caller who skipped it and
|
|
548
|
+
// passed an unknown preset gets the identity look rather than a crash.
|
|
549
|
+
const base =
|
|
550
|
+
(GRADE_PRESETS as Partial<Record<string, LookParams>>)[grade.preset ?? ""] ?? IDENTITY_LOOK;
|
|
551
|
+
return {
|
|
552
|
+
kind: "preset",
|
|
553
|
+
params: {
|
|
554
|
+
...base,
|
|
555
|
+
exposure: base.exposure + (grade.exposure ?? 0),
|
|
556
|
+
temperature: base.temperature + (grade.temperature ?? 0),
|
|
557
|
+
saturation: base.saturation * (grade.saturation ?? 1),
|
|
558
|
+
contrast: base.contrast * (grade.contrast ?? 1),
|
|
559
|
+
},
|
|
560
|
+
intensity: grade.intensity ?? base.defaultIntensity,
|
|
561
|
+
};
|
|
562
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -128,6 +128,21 @@ export interface OssclipConfig {
|
|
|
128
128
|
* with a warning instead of silently half-applying.
|
|
129
129
|
*/
|
|
130
130
|
theme?: Partial<Theme>;
|
|
131
|
+
/**
|
|
132
|
+
* Color grade applied to every produce run — the `ColorGradeSchema` shape
|
|
133
|
+
* (`{"preset": "talking-head"}` or `{"lut": "kodak.cube"}`, plus optional
|
|
134
|
+
* intensity/exposure/temperature/saturation/contrast tweaks), for a channel
|
|
135
|
+
* whose look is a standing decision rather than a per-run flag.
|
|
136
|
+
* `--color-grade` / `--no-color-grade` win over this per run, and an
|
|
137
|
+
* overrides.json `colorGrade` beats both (`resolveProductionColorGrade` in
|
|
138
|
+
* produce.ts). File-only, the `theme` posture: a structured value from
|
|
139
|
+
* hand-edited JSON, validated where it is USED (via core's
|
|
140
|
+
* `resolveColorGrade`), so a malformed grade or unknown preset earns one
|
|
141
|
+
* warning naming the source and an ungraded run, never a coerced look.
|
|
142
|
+
* Typed `unknown` deliberately — the type promising `ColorGrade` here would
|
|
143
|
+
* imply a parse `loadConfig` never performs.
|
|
144
|
+
*/
|
|
145
|
+
colorGrade?: unknown;
|
|
131
146
|
/**
|
|
132
147
|
* Run the `--youtube` pack (SEO metadata + AI thumbnail) on every produce,
|
|
133
148
|
* so the preference is a one-time config write like `watermark`.
|
|
@@ -321,6 +336,13 @@ export function resolveConfig(
|
|
|
321
336
|
// warning naming the problem and the safe default, never a coercion.
|
|
322
337
|
dictionary: fileCfg.dictionary,
|
|
323
338
|
theme: fileCfg.theme,
|
|
339
|
+
// File-only, the `theme` posture, and NO env spelling (a grade is a
|
|
340
|
+
// structured object no env var can carry honestly): validated at the
|
|
341
|
+
// consumer (`resolveProductionColorGrade` in produce.ts via
|
|
342
|
+
// `resolveColorGrade`), where a malformed value earns one warning naming
|
|
343
|
+
// the source and an ungraded run — the postizUrl lesson says the mapping
|
|
344
|
+
// must still copy it, or the key is invisible at runtime.
|
|
345
|
+
colorGrade: fileCfg.colorGrade,
|
|
324
346
|
// File-only, the same posture: both are validated where they are USED —
|
|
325
347
|
// `validModelSources` / `resolveWhisperLanguage` — so a hand-edited
|
|
326
348
|
// non-record or non-string earns one warning there, never a coercion.
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ export * from "./retake";
|
|
|
20
20
|
export * from "./captions";
|
|
21
21
|
export * from "./fonts";
|
|
22
22
|
export * from "./sfx-pack";
|
|
23
|
+
export * from "./color-grade";
|
|
24
|
+
export * from "./lut-library";
|
|
23
25
|
export * from "./dictionary";
|
|
24
26
|
export * from "./zoom";
|
|
25
27
|
export * from "./grounding";
|
package/src/ingest.ts
CHANGED
|
@@ -190,11 +190,56 @@ export function mezzanineScale(
|
|
|
190
190
|
* the legacy names so existing workdir caches stay valid; a scaled run
|
|
191
191
|
* rebuilds once under its own name and old workdirs' render-props keep
|
|
192
192
|
* referencing (and rendering from) the file they were emitted against.
|
|
193
|
+
*
|
|
194
|
+
* The LUT hash is in the name for the same reason: grading is baked into the
|
|
195
|
+
* mezzanine at build time, so a warm workdir keyed only on crop/scale would
|
|
196
|
+
* satisfy a graded run with UNGRADED frames (or a re-graded run with the old
|
|
197
|
+
* look). No LUT keeps today's names byte-for-byte, so existing warm workdirs
|
|
198
|
+
* stay valid.
|
|
193
199
|
*/
|
|
194
|
-
export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null): string {
|
|
200
|
+
export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null, lutHash?: string): string {
|
|
195
201
|
const base = cropped ? "mezzanine-content" : "mezzanine";
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
const scaleSeg = scale ? `-${scale.width}x${scale.height}@${Math.round(scale.fps)}` : "";
|
|
203
|
+
const lutSeg = lutHash ? `-lut${lutHash}` : "";
|
|
204
|
+
return `${base}${scaleSeg}${lutSeg}.mp4`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A 3D LUT to bake into the mezzanine; `hash` keys the cache (see `mezzanineFileName`). */
|
|
208
|
+
export interface MezzanineLut {
|
|
209
|
+
path: string;
|
|
210
|
+
hash: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Escape a filesystem path for use as an ffmpeg filter option value.
|
|
215
|
+
*
|
|
216
|
+
* A `-vf` string is parsed twice: once as a filtergraph (where `\` `'` `[`
|
|
217
|
+
* `]` `,` `;` are special) and once as the filter's option value (where `:`
|
|
218
|
+
* `\` `'` are special — `:` is the option separator, so an unescaped drive
|
|
219
|
+
* letter like `C:` truncates the path there). Each level strips one layer of
|
|
220
|
+
* backslashes, so the option-level escapes must themselves be escaped for
|
|
221
|
+
* the graph level: `:` → `\\:`, `'` → `\\\'`, `\` → `\\\\`. Spaces need
|
|
222
|
+
* nothing — the argv goes straight to ffmpeg, no shell in between.
|
|
223
|
+
*/
|
|
224
|
+
export function escapeFilterPath(p: string): string {
|
|
225
|
+
// Level 1: filter option value — `:` `\` `'` are special.
|
|
226
|
+
const option = p.replace(/[\\':]/g, (c) => `\\${c}`);
|
|
227
|
+
// Level 2: filtergraph — escape again so level-1 backslashes survive.
|
|
228
|
+
return option.replace(/[\\'[\],;]/g, (c) => `\\${c}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The mezzanine's `-vf` chain, pure so the ordering contract is testable:
|
|
233
|
+
* LUT strictly AFTER crop/scale — grading the letterbox bars would be
|
|
234
|
+
* wasted math, and grading pre-scale pixels the render never sees changes
|
|
235
|
+
* nothing but costs full-res per-pixel lookups.
|
|
236
|
+
*/
|
|
237
|
+
export function mezzanineVf(opts: { cropVf?: string; scale?: MezzanineScale; lut?: MezzanineLut }): string {
|
|
238
|
+
return [
|
|
239
|
+
...(opts.cropVf ? [opts.cropVf] : []),
|
|
240
|
+
...(opts.scale ? [`scale=${opts.scale.width}:${opts.scale.height}`] : []),
|
|
241
|
+
...(opts.lut ? [`lut3d=file=${escapeFilterPath(opts.lut.path)}:interp=tetrahedral`] : []),
|
|
242
|
+
].join(",");
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
/**
|
|
@@ -206,17 +251,15 @@ export function mezzanineFileName(cropped: boolean, scale: MezzanineScale | null
|
|
|
206
251
|
*
|
|
207
252
|
* `scale` (from `mezzanineScale`) downsizes to display size in the SAME
|
|
208
253
|
* pass, crop first — the scale dims are computed on the post-crop picture.
|
|
254
|
+
* `lut` bakes a 3D grade in last, on exactly the pixels the render will see.
|
|
209
255
|
*/
|
|
210
256
|
export async function makeMezzanine(
|
|
211
257
|
tools: IngestTools,
|
|
212
258
|
src: string,
|
|
213
259
|
out: string,
|
|
214
|
-
opts: { cropVf?: string; scale?: MezzanineScale } = {},
|
|
260
|
+
opts: { cropVf?: string; scale?: MezzanineScale; lut?: MezzanineLut } = {},
|
|
215
261
|
): Promise<void> {
|
|
216
|
-
const vf =
|
|
217
|
-
...(opts.cropVf ? [opts.cropVf] : []),
|
|
218
|
-
...(opts.scale ? [`scale=${opts.scale.width}:${opts.scale.height}`] : []),
|
|
219
|
-
].join(",");
|
|
262
|
+
const vf = mezzanineVf(opts);
|
|
220
263
|
await run(tools.ffmpegPath, [
|
|
221
264
|
"-y", "-i", src,
|
|
222
265
|
...(vf ? ["-vf", vf] : []),
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "./config";
|
|
4
|
+
import { parseCubeLut } from "./color-grade";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The user's .cube LUT directory, discovered — the `sfx-pack.ts` shape for
|
|
8
|
+
* color grades. `parseCubeLut` stays pure; this module is the thin fs layer
|
|
9
|
+
* that walks `~/.ossclip/luts` and reports, per file, either a usable LUT or
|
|
10
|
+
* the reason it is not one. Nothing here throws: a hand-dropped .cube is user
|
|
11
|
+
* input, and a broken one must cost that one menu entry, not the editor.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Where user LUTs live: `~/.ossclip/luts/<name>.cube`. */
|
|
15
|
+
export function userLutDir(): string {
|
|
16
|
+
return join(CONFIG_DIR, "luts");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One LUT the menu can offer. `path` stays server-side, like SFX `absPath`. */
|
|
20
|
+
export interface LutLibraryItem {
|
|
21
|
+
/** The filename stem — what `ColorGrade.lut` (basename) resolves against. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** The .cube's own TITLE when it has one, else the stem. */
|
|
24
|
+
title: string;
|
|
25
|
+
/** Absolute path — the caller's I/O concern, never sent to a client. */
|
|
26
|
+
path: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why one file is not in the library — `SfxPackIssue`'s shape, per file. */
|
|
30
|
+
export interface LutLibraryIssue {
|
|
31
|
+
/** The .cube filename (basename) that failed. */
|
|
32
|
+
file: string;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LutLibrary {
|
|
37
|
+
items: LutLibraryItem[];
|
|
38
|
+
issues: LutLibraryIssue[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every parseable `.cube` under `dir`, plus an issue per file that is not one.
|
|
43
|
+
* Each file is fully parsed here — not just listed — because the menu is the
|
|
44
|
+
* ONLY surface where a broken LUT can be reported before a render silently
|
|
45
|
+
* drops it: `parseCubeLut` is strict about content on purpose, and offering a
|
|
46
|
+
* file the bake will refuse is the exact mismatch the SFX library gate exists
|
|
47
|
+
* to avoid.
|
|
48
|
+
*
|
|
49
|
+
* A missing directory is the normal case (most users never drop a LUT), not
|
|
50
|
+
* an issue. `dir` is a parameter with a default rather than a `homedir()`
|
|
51
|
+
* read inside, so tests point it at a tmp dir and never touch a real home
|
|
52
|
+
* (`loadSfxLibrary`'s rule).
|
|
53
|
+
*/
|
|
54
|
+
export function loadLutLibrary(dir: string = userLutDir()): LutLibrary {
|
|
55
|
+
let names: string[] = [];
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".cube"))
|
|
59
|
+
.map((e) => e.name)
|
|
60
|
+
// Sorted so the menu reads the same on every machine — readdir order is
|
|
61
|
+
// not a promise (loadSfxLibrary's merge-order rule).
|
|
62
|
+
.sort();
|
|
63
|
+
} catch {
|
|
64
|
+
return { items: [], issues: [] };
|
|
65
|
+
}
|
|
66
|
+
const items: LutLibraryItem[] = [];
|
|
67
|
+
const issues: LutLibraryIssue[] = [];
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const path = join(dir, name);
|
|
70
|
+
const stem = basename(name, extname(name));
|
|
71
|
+
try {
|
|
72
|
+
const lut = parseCubeLut(readFileSync(path, "utf8"));
|
|
73
|
+
// TITLE when the exporter wrote one — a human-readable label the stem
|
|
74
|
+
// (often `Vendor_Look_33pt_v2`) cannot match. Empty titles fall back.
|
|
75
|
+
items.push({ id: stem, title: lut.title?.trim() || stem, path });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
issues.push({ file: name, message: e instanceof Error ? e.message : String(e) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { items, issues };
|
|
81
|
+
}
|
package/src/overrides.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
|
+
import { ColorGradeSchema } from "./color-grade";
|
|
2
3
|
import {
|
|
3
4
|
LayoutSchema,
|
|
4
5
|
SceneAnchorSchema,
|
|
@@ -142,6 +143,19 @@ export const SceneOverrideSchema = z.object({
|
|
|
142
143
|
dx: z.number().optional(),
|
|
143
144
|
/** `false` switches the automatic idle-zoom layer off for this scene. */
|
|
144
145
|
autoZoom: z.boolean().optional(),
|
|
146
|
+
/**
|
|
147
|
+
* Audio gain for this window's footage, 1 = as recorded (field report
|
|
148
|
+
* 2026-08-31: one concatenated clip was recorded quieter than the
|
|
149
|
+
* rest). Lives inside `video` on purpose — it is a property of this
|
|
150
|
+
* window's playback, and the key already merges per scene, inherits
|
|
151
|
+
* across split halves, and has patch/clear plumbing. 0 mutes. Above 1
|
|
152
|
+
* amplifies — in the render via allowAmplificationDuringRender, and in
|
|
153
|
+
* the preview via the Player's own WebAudio gain (Remotion ≥4.0.5xx,
|
|
154
|
+
* use-amplification). Max 4: a field clip arrived quiet enough that 2x
|
|
155
|
+
* did not reach its neighbours (2026-08-31); past 4x you are boosting
|
|
156
|
+
* noise floor, not speech.
|
|
157
|
+
*/
|
|
158
|
+
volume: z.number().min(0).max(4).optional(),
|
|
145
159
|
})
|
|
146
160
|
.optional(),
|
|
147
161
|
/**
|
|
@@ -527,6 +541,24 @@ export type SfxAddedPlacement = z.infer<typeof SfxAddedPlacementSchema>;
|
|
|
527
541
|
export const OverrideDocSchema = z.object({
|
|
528
542
|
/** Global style tokens — the look is a system, so these are not per-element. */
|
|
529
543
|
theme: ThemeSchema.partial().default({}),
|
|
544
|
+
/**
|
|
545
|
+
* Doc-global color grade — the `ColorGradeSchema` shape, or `false` for
|
|
546
|
+
* "explicitly no grade on this project". Doc-global like `theme`: a grade
|
|
547
|
+
* is one decision about the whole output, not a per-scene key. Optional
|
|
548
|
+
* with NO default (the `captionsHidden` rule) so every overrides.json
|
|
549
|
+
* written before the key existed parses byte-identically — but UNLIKE
|
|
550
|
+
* `captionsHidden`, an explicit `false` is meaningful and kept: it
|
|
551
|
+
* disables a config-level default grade for this one project, which
|
|
552
|
+
* deleting the key cannot express (absent means "let the flag, then the
|
|
553
|
+
* config, decide" — `resolveProductionColorGrade` in produce.ts owns that
|
|
554
|
+
* precedence). Schema-valid is not yet USABLE: an unknown preset id passes
|
|
555
|
+
* here (the schema cannot list what exists without going stale) and is
|
|
556
|
+
* caught by `resolveColorGrade` at the consumer, where it warns and falls
|
|
557
|
+
* through to the next layer instead of failing the whole doc. Timeless —
|
|
558
|
+
* no seconds anywhere — so `remapOverridesThroughRecut`'s `...doc` spreads
|
|
559
|
+
* carry it through a recut untouched.
|
|
560
|
+
*/
|
|
561
|
+
colorGrade: z.union([ColorGradeSchema, z.literal(false)]).optional(),
|
|
530
562
|
/**
|
|
531
563
|
* Captions OFF for the whole video. Doc-global like `theme`, deliberately
|
|
532
564
|
* NOT a per-scene key: visibility is one decision about the output —
|
package/src/recut.ts
CHANGED
|
@@ -269,6 +269,11 @@ export function resolveCutSourceRanges(
|
|
|
269
269
|
* user cut exactly like an automatic one, with no separate report path
|
|
270
270
|
* needed for "what got removed."
|
|
271
271
|
*/
|
|
272
|
+
/** See the sliver comment inside `subtractRangesFromCutlist` — a keep this
|
|
273
|
+
* short only ever comes from rounded cut edges meeting full-precision span
|
|
274
|
+
* floats, and it crashes the Remotion player if it survives. */
|
|
275
|
+
const SLIVER_EPS = 0.002;
|
|
276
|
+
|
|
272
277
|
export function subtractRangesFromCutlist(
|
|
273
278
|
cutlist: readonly Segment[],
|
|
274
279
|
ranges: readonly { start: number; end: number }[],
|
|
@@ -292,11 +297,35 @@ export function subtractRangesFromCutlist(
|
|
|
292
297
|
const overlapStart = Math.max(cursor, r.start);
|
|
293
298
|
const overlapEnd = Math.min(seg.srcOut, r.end);
|
|
294
299
|
if (overlapStart >= overlapEnd) continue;
|
|
295
|
-
|
|
296
|
-
|
|
300
|
+
// A keep sliver shorter than SLIVER_EPS joins the removal instead of
|
|
301
|
+
// surviving as its own segment (2026-08-31, the ADK crash): the cut
|
|
302
|
+
// writers round `src` to 3 decimals while the cutlist keeps full float
|
|
303
|
+
// precision, so the honest set difference can leave a keep of ~100µs.
|
|
304
|
+
// EdlVideo rounds both ends of such a span to the SAME frame and
|
|
305
|
+
// Remotion throws ("trimAfter must be greater than trimBefore"),
|
|
306
|
+
// blanking the whole player — in the preview AND the next render.
|
|
307
|
+
// 2ms covers the worst rounding drift (0.5ms per edge) and is far
|
|
308
|
+
// under one frame at any real fps, so nothing watchable is lost.
|
|
309
|
+
if (overlapStart - cursor >= SLIVER_EPS) {
|
|
310
|
+
out.push({ srcIn: cursor, srcOut: overlapStart, kind: "keep" });
|
|
311
|
+
out.push({ srcIn: overlapStart, srcOut: overlapEnd, kind: "remove", reason: "user", confidence: 1 });
|
|
312
|
+
} else {
|
|
313
|
+
out.push({ srcIn: cursor, srcOut: overlapEnd, kind: "remove", reason: "user", confidence: 1 });
|
|
314
|
+
}
|
|
297
315
|
cursor = overlapEnd;
|
|
298
316
|
}
|
|
299
|
-
if (
|
|
317
|
+
if (seg.srcOut - cursor >= SLIVER_EPS) {
|
|
318
|
+
out.push({ srcIn: cursor, srcOut: seg.srcOut, kind: "keep" });
|
|
319
|
+
} else if (cursor < seg.srcOut) {
|
|
320
|
+
// Tail sliver: extend the removal that ends at `cursor` (there always
|
|
321
|
+
// is one — cursor only advances past a pushed removal).
|
|
322
|
+
const last = out[out.length - 1];
|
|
323
|
+
if (last && last.kind === "remove" && last.srcOut === cursor) {
|
|
324
|
+
out[out.length - 1] = { ...last, srcOut: seg.srcOut };
|
|
325
|
+
} else {
|
|
326
|
+
out.push({ srcIn: cursor, srcOut: seg.srcOut, kind: "remove", reason: "user", confidence: 1 });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
300
329
|
}
|
|
301
330
|
return out;
|
|
302
331
|
}
|
package/src/scene-schema.ts
CHANGED
|
@@ -146,6 +146,9 @@ export const SceneCueSchema = z
|
|
|
146
146
|
dx: z.number().optional(),
|
|
147
147
|
/** `false` switches the automatic idle-zoom layer off for this scene. */
|
|
148
148
|
autoZoom: z.boolean().optional(),
|
|
149
|
+
/** Audio gain for this window, 1 = as recorded — mirror of
|
|
150
|
+
* `SceneOverrideSchema.video.volume`, which owns the argument. */
|
|
151
|
+
volume: z.number().min(0).max(4).optional(),
|
|
149
152
|
})
|
|
150
153
|
.optional(),
|
|
151
154
|
/**
|