@ossclip/core 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
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`.
@@ -184,6 +199,30 @@ export interface OssclipConfig {
184
199
  * (`publishConfigured` in the CLI), never coerced.
185
200
  */
186
201
  postizUrl?: string;
202
+ /**
203
+ * Base URL of an OpenAI-compatible transcription server, ending in `/v1`
204
+ * (Groq: "https://api.groq.com/openai/v1"; a self-hosted speaches:
205
+ * "http://localhost:8000/v1"). Set, transcription runs REMOTELY instead of
206
+ * on this machine's CPU — the 2026-09-01 field report from an i3 2nd gen,
207
+ * where whisper is the dominant cost of a run. Unset, local whisper-cli
208
+ * stays the default, and `--whisper-backend local` overrides per run.
209
+ *
210
+ * Non-secret, the `postizUrl` posture, so it may live here; the API key is
211
+ * `OSSCLIP_WHISPER_API_KEY` in the ENVIRONMENT only (env.ts's documented
212
+ * rule) — and OPTIONAL, because self-hosted servers run keyless. Validated
213
+ * at the consumer (`resolveWhisperBackend` in the CLI), never coerced.
214
+ */
215
+ whisperUrl?: string;
216
+ /**
217
+ * Model name sent to that server ("whisper-large-v3-turbo" on Groq,
218
+ * "Systran/faster-whisper-large-v3" on a speaches box). Deliberately NOT
219
+ * `model`, which names the local ggml file — a remote run must not be able
220
+ * to send "small.en" to a server that has never heard of it. The default
221
+ * lives at the consumer (`resolveWhisperBackend`), not in DEFAULTS: an
222
+ * unset key here must stay unset so nothing writes a remote model name into
223
+ * a local-only config.
224
+ */
225
+ whisperRemoteModel?: string;
187
226
  /**
188
227
  * `--resolution`'s default for this machine: "auto" (keep what the source
189
228
  * has, capped at 2160), "1080" (the built-in default), "1440" or "2160".
@@ -321,6 +360,13 @@ export function resolveConfig(
321
360
  // warning naming the problem and the safe default, never a coercion.
322
361
  dictionary: fileCfg.dictionary,
323
362
  theme: fileCfg.theme,
363
+ // File-only, the `theme` posture, and NO env spelling (a grade is a
364
+ // structured object no env var can carry honestly): validated at the
365
+ // consumer (`resolveProductionColorGrade` in produce.ts via
366
+ // `resolveColorGrade`), where a malformed value earns one warning naming
367
+ // the source and an ungraded run — the postizUrl lesson says the mapping
368
+ // must still copy it, or the key is invisible at runtime.
369
+ colorGrade: fileCfg.colorGrade,
324
370
  // File-only, the same posture: both are validated where they are USED —
325
371
  // `validModelSources` / `resolveWhisperLanguage` — so a hand-edited
326
372
  // non-record or non-string earns one warning there, never a coercion.
@@ -345,6 +391,15 @@ export function resolveConfig(
345
391
  // deliberately lives in the environment (publish.ts's
346
392
  // `publishConfigured`), so this is only the instance URL.
347
393
  postizUrl: fileCfg.postizUrl,
394
+ // Env spellings ON PURPOSE, unlike postizUrl: the Groq quickstart is
395
+ // "export two vars and run", and a user trying a free tier for the first
396
+ // time should not have to hand-edit config.json to do it. Env beats file
397
+ // so a one-off `OSSCLIP_WHISPER_URL=… ossclip produce` works against a
398
+ // machine whose config says otherwise. Both are strings straight through
399
+ // — validated at the consumer (`resolveWhisperBackend`), never coerced,
400
+ // and the postizUrl lesson above is why they are copied here at all.
401
+ whisperUrl: env.OSSCLIP_WHISPER_URL ?? fileCfg.whisperUrl,
402
+ whisperRemoteModel: env.OSSCLIP_WHISPER_REMOTE_MODEL ?? fileCfg.whisperRemoteModel,
348
403
  resolution: fileCfg.resolution,
349
404
  };
350
405
  }
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";