@pixerate/editor 0.86.0 → 0.88.0

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.
@@ -210,6 +210,255 @@ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
210
210
  return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
211
211
  }
212
212
 
213
+ // src/video-editor/lut.ts
214
+ var VIDEO_LUT_MAX_SIZE = 129;
215
+ function parseVideoCubeLut(text) {
216
+ let size = 0;
217
+ let size1 = 0;
218
+ let title;
219
+ const domainMin = [0, 0, 0];
220
+ const domainMax = [1, 1, 1];
221
+ let data = null;
222
+ let rows = 0;
223
+ const lines = text.split(/\r\n|\r|\n/);
224
+ const fail2 = (line, message) => {
225
+ throw new Error(`Line ${line + 1} of the .cube file: ${message}.`);
226
+ };
227
+ const numbers = (parts, line) => parts.map((p) => {
228
+ const v = Number(p);
229
+ if (!Number.isFinite(v)) fail2(line, `"${p}" is not a number`);
230
+ return v;
231
+ });
232
+ for (let i = 0; i < lines.length; i++) {
233
+ const line = lines[i].replace(/#.*/, "").trim();
234
+ if (!line) continue;
235
+ const parts = line.split(/\s+/);
236
+ const key = parts[0];
237
+ if (/^[A-Za-z]/.test(key)) {
238
+ if (rows) fail2(i, `"${key}" comes after the table; keywords go before it`);
239
+ if (key === "TITLE") title = line.slice(5).trim().replace(/^"|"$/g, "");
240
+ else if (key === "LUT_3D_SIZE") {
241
+ size = Number(parts[1]);
242
+ if (!Number.isInteger(size) || size < 2 || size > VIDEO_LUT_MAX_SIZE) fail2(i, `LUT_3D_SIZE must be a whole number from 2 to ${VIDEO_LUT_MAX_SIZE}, not "${parts[1] ?? ""}"`);
243
+ } else if (key === "LUT_1D_SIZE") size1 = Number(parts[1]) || 1;
244
+ else if (key === "DOMAIN_MIN" || key === "DOMAIN_MAX") {
245
+ const v2 = numbers(parts.slice(1), i);
246
+ if (v2.length !== 3) fail2(i, `${key} needs 3 numbers`);
247
+ (key === "DOMAIN_MIN" ? domainMin : domainMax).splice(0, 3, ...v2);
248
+ } else if (key === "LUT_3D_INPUT_RANGE") {
249
+ const v2 = numbers(parts.slice(1), i);
250
+ if (v2.length !== 2 || !(v2[1] > v2[0])) fail2(i, "LUT_3D_INPUT_RANGE needs a minimum and a larger maximum");
251
+ domainMin.fill(v2[0]);
252
+ domainMax.fill(v2[1]);
253
+ }
254
+ continue;
255
+ }
256
+ if (!size) fail2(i, size1 ? "this is a 1D LUT (LUT_1D_SIZE); only 3D LUTs (LUT_3D_SIZE) are supported" : "the table starts before LUT_3D_SIZE");
257
+ data ??= new Float32Array(size * size * size * 3);
258
+ const v = numbers(parts, i);
259
+ if (v.length !== 3) fail2(i, `a table row needs 3 numbers, not ${v.length}`);
260
+ if (rows >= size ** 3) fail2(i, `the table has more than the ${size ** 3} rows LUT_3D_SIZE ${size} needs`);
261
+ data.set(v, rows * 3);
262
+ rows++;
263
+ }
264
+ if (!size) throw new Error(size1 ? "The .cube file is a 1D LUT (LUT_1D_SIZE); only 3D LUTs (LUT_3D_SIZE) are supported." : "The .cube file has no LUT_3D_SIZE line, so it is not a 3D LUT.");
265
+ if (rows !== size ** 3) throw new Error(`The .cube file has ${rows} table rows; LUT_3D_SIZE ${size} needs ${size ** 3}.`);
266
+ for (let c = 0; c < 3; c++) if (!(domainMax[c] > domainMin[c])) throw new Error("The .cube file's DOMAIN_MAX must be larger than its DOMAIN_MIN on every channel.");
267
+ return { ...title ? { title } : {}, size, domainMin, domainMax, data };
268
+ }
269
+ function videoLutFromFunction(size, fn, title) {
270
+ const n2 = size - 1;
271
+ const data = new Float32Array(size ** 3 * 3);
272
+ let i = 0;
273
+ for (let b = 0; b < size; b++)
274
+ for (let g = 0; g < size; g++)
275
+ for (let r = 0; r < size; r++) {
276
+ data.set(fn([r / n2, g / n2, b / n2]), i);
277
+ i += 3;
278
+ }
279
+ return { ...title ? { title } : {}, size, domainMin: [0, 0, 0], domainMax: [1, 1, 1], data };
280
+ }
281
+ function videoLutToCube(lut) {
282
+ const f = (v) => (Math.round(v * 1e6) / 1e6).toString();
283
+ const head = [...lut.title ? [`TITLE "${lut.title.replace(/"/g, "'")}"`] : [], `LUT_3D_SIZE ${lut.size}`];
284
+ if (lut.domainMin.some((v) => v !== 0) || lut.domainMax.some((v) => v !== 1)) head.push(`DOMAIN_MIN ${lut.domainMin.map(f).join(" ")}`, `DOMAIN_MAX ${lut.domainMax.map(f).join(" ")}`);
285
+ const rows = [];
286
+ for (let i = 0; i < lut.data.length; i += 3) rows.push(`${f(lut.data[i])} ${f(lut.data[i + 1])} ${f(lut.data[i + 2])}`);
287
+ return `${head.join("\n")}
288
+ ${rows.join("\n")}
289
+ `;
290
+ }
291
+ var clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
292
+ function applyVideoLut(lut, rgb, out = [0, 0, 0]) {
293
+ const { size, data, domainMin, domainMax } = lut;
294
+ const n2 = size - 1;
295
+ const x = clamp01((rgb[0] - domainMin[0]) / (domainMax[0] - domainMin[0])) * n2;
296
+ const y = clamp01((rgb[1] - domainMin[1]) / (domainMax[1] - domainMin[1])) * n2;
297
+ const z = clamp01((rgb[2] - domainMin[2]) / (domainMax[2] - domainMin[2])) * n2;
298
+ const r0 = Math.min(n2 - 1, Math.floor(x));
299
+ const g0 = Math.min(n2 - 1, Math.floor(y));
300
+ const b0 = Math.min(n2 - 1, Math.floor(z));
301
+ const fr = x - r0;
302
+ const fg = y - g0;
303
+ const fb = z - b0;
304
+ const sr = 3;
305
+ const sg = 3 * size;
306
+ const sb = 3 * size * size;
307
+ const o = r0 * sr + g0 * sg + b0 * sb;
308
+ let a, c, w0, w1, w2, w3;
309
+ if (fr > fg) {
310
+ if (fg > fb) [a, c, w0, w1, w2, w3] = [sr, sr + sg, 1 - fr, fr - fg, fg - fb, fb];
311
+ else if (fr > fb) [a, c, w0, w1, w2, w3] = [sr, sr + sb, 1 - fr, fr - fb, fb - fg, fg];
312
+ else [a, c, w0, w1, w2, w3] = [sb, sb + sr, 1 - fb, fb - fr, fr - fg, fg];
313
+ } else if (fb > fg) [a, c, w0, w1, w2, w3] = [sb, sb + sg, 1 - fb, fb - fg, fg - fr, fr];
314
+ else if (fb > fr) [a, c, w0, w1, w2, w3] = [sg, sg + sb, 1 - fg, fg - fb, fb - fr, fr];
315
+ else [a, c, w0, w1, w2, w3] = [sg, sg + sr, 1 - fg, fg - fr, fr - fb, fb];
316
+ const d = sr + sg + sb;
317
+ for (let k = 0; k < 3; k++) out[k] = clamp01(w0 * data[o + k] + w1 * data[o + a + k] + w2 * data[o + c + k] + w3 * data[o + d + k]);
318
+ return out;
319
+ }
320
+ function applyVideoLutToPixels(lut, data, strength = 1) {
321
+ const s = clamp01(strength);
322
+ if (!s) return;
323
+ const rgb = [0, 0, 0];
324
+ const out = [0, 0, 0];
325
+ for (let i = 0; i < data.length; i += 4) {
326
+ if (!data[i + 3]) continue;
327
+ rgb[0] = data[i] / 255;
328
+ rgb[1] = data[i + 1] / 255;
329
+ rgb[2] = data[i + 2] / 255;
330
+ applyVideoLut(lut, rgb, out);
331
+ for (let k = 0; k < 3; k++) data[i + k] = Math.round((rgb[k] + (out[k] - rgb[k]) * s) * 255);
332
+ }
333
+ }
334
+ var approximations = /* @__PURE__ */ new WeakMap();
335
+ function videoLutSvgApproximation(lut) {
336
+ const cached = approximations.get(lut);
337
+ if (cached) return cached;
338
+ const points = 17;
339
+ const tables = [[], [], []];
340
+ for (let i = 0; i < points; i++) {
341
+ const v = i / (points - 1);
342
+ const out = applyVideoLut(lut, [v, v, v]);
343
+ for (let k = 0; k < 3; k++) tables[k].push(out[k]);
344
+ }
345
+ const curve = (k, v) => {
346
+ const x = clamp01(v) * (points - 1);
347
+ const i = Math.min(points - 2, Math.floor(x));
348
+ return tables[k][i] + (tables[k][i + 1] - tables[k][i]) * (x - i);
349
+ };
350
+ const ata = Array.from({ length: 4 }, () => [0, 0, 0, 0]);
351
+ const atb = Array.from({ length: 3 }, () => [0, 0, 0, 0]);
352
+ const grid = 9;
353
+ for (let b = 0; b < grid; b++)
354
+ for (let g = 0; g < grid; g++)
355
+ for (let r = 0; r < grid; r++) {
356
+ const x = [r / (grid - 1), g / (grid - 1), b / (grid - 1)];
357
+ const z = [curve(0, x[0]), curve(1, x[1]), curve(2, x[2]), 1];
358
+ const y = applyVideoLut(lut, x);
359
+ for (let i = 0; i < 4; i++) {
360
+ for (let j = 0; j < 4; j++) ata[i][j] += z[i] * z[j];
361
+ for (let k = 0; k < 3; k++) atb[k][i] += z[i] * y[k];
362
+ }
363
+ }
364
+ const matrix = atb.map((rhs) => solve4(ata, rhs));
365
+ const result = { tables, matrix };
366
+ approximations.set(lut, result);
367
+ return result;
368
+ }
369
+ function solve4(a, b) {
370
+ const m = a.map((row, i) => [...row, b[i]]);
371
+ for (let col = 0; col < 4; col++) {
372
+ let pivot = col;
373
+ for (let r = col + 1; r < 4; r++) if (Math.abs(m[r][col]) > Math.abs(m[pivot][col])) pivot = r;
374
+ if (Math.abs(m[pivot][col]) < 1e-12) continue;
375
+ [m[col], m[pivot]] = [m[pivot], m[col]];
376
+ for (let r = 0; r < 4; r++) {
377
+ if (r === col) continue;
378
+ const f = m[r][col] / m[col][col];
379
+ for (let c = col; c < 5; c++) m[r][c] -= f * m[col][c];
380
+ }
381
+ }
382
+ return m.map((row, i) => Math.abs(row[i]) < 1e-12 ? 0 : row[4] / row[i]);
383
+ }
384
+ var VideoLutCache = class {
385
+ constructor(loader = async (src) => {
386
+ const res = await fetch(src);
387
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim());
388
+ return res.text();
389
+ }) {
390
+ this.loader = loader;
391
+ }
392
+ loader;
393
+ ready = /* @__PURE__ */ new Map();
394
+ pending = /* @__PURE__ */ new Map();
395
+ failed = /* @__PURE__ */ new Map();
396
+ listeners = /* @__PURE__ */ new Set();
397
+ /** The LUT if loaded, without starting to load it. */
398
+ peek(src) {
399
+ return this.ready.get(src);
400
+ }
401
+ /** The LUT if loaded; otherwise `undefined`, and loading starts (once) unless it failed. */
402
+ get(src) {
403
+ const lut = this.ready.get(src);
404
+ if (!lut && !this.failed.has(src)) void this.load(src).catch(() => {
405
+ });
406
+ return lut;
407
+ }
408
+ /** Why loading a LUT failed, if it did. */
409
+ error(src) {
410
+ return this.failed.get(src);
411
+ }
412
+ /** The LUT, loading and reading it if needed. Rejects with a readable error. */
413
+ load(src) {
414
+ const ready = this.ready.get(src);
415
+ if (ready) return Promise.resolve(ready);
416
+ const failed = this.failed.get(src);
417
+ if (failed) return Promise.reject(new Error(failed));
418
+ let pending = this.pending.get(src);
419
+ if (!pending) {
420
+ pending = Promise.resolve().then(() => this.loader(src)).then((v) => typeof v === "string" ? parseVideoCubeLut(v) : v).then(
421
+ (lut) => {
422
+ this.pending.delete(src);
423
+ this.ready.set(src, lut);
424
+ this.emit();
425
+ return lut;
426
+ },
427
+ (e) => {
428
+ this.pending.delete(src);
429
+ const message = `The LUT "${src}" could not be read: ${e instanceof Error ? e.message : String(e)}`;
430
+ this.failed.set(src, message);
431
+ this.emit();
432
+ throw new Error(message);
433
+ }
434
+ );
435
+ this.pending.set(src, pending);
436
+ }
437
+ return pending;
438
+ }
439
+ /** Uses a LUT the host already has (`.cube` text or a parsed LUT), under `src`. */
440
+ set(src, lut) {
441
+ const parsed = typeof lut === "string" ? parseVideoCubeLut(lut) : lut;
442
+ this.ready.set(src, parsed);
443
+ this.failed.delete(src);
444
+ this.emit();
445
+ return parsed;
446
+ }
447
+ /** Forgets a failure so the next `get` tries again. */
448
+ retry(src) {
449
+ if (this.failed.delete(src)) this.emit();
450
+ }
451
+ subscribe(fn) {
452
+ this.listeners.add(fn);
453
+ return () => {
454
+ this.listeners.delete(fn);
455
+ };
456
+ }
457
+ emit() {
458
+ for (const l of [...this.listeners]) l();
459
+ }
460
+ };
461
+
213
462
  // src/video-editor/grade.ts
214
463
  var VIDEO_GRADE_CONTROLS = [
215
464
  { key: "exposure", label: "Exposure", min: -3, max: 3, step: 0.1, description: "Exposure in stops: +1 doubles every channel, -1 halves it (default 0)." },
@@ -230,6 +479,12 @@ var VIDEO_GRADE_PRESETS = {
230
479
  };
231
480
  var VIDEO_GRADE_PRESET_NAMES = Object.keys(VIDEO_GRADE_PRESETS);
232
481
  var clampTo = (c, v) => Math.min(c.max, Math.max(c.min, v));
482
+ function normalizeVideoGradeLut(lut) {
483
+ if (!lut || typeof lut.src !== "string" || !lut.src) return void 0;
484
+ const strength = typeof lut.strength === "number" && Number.isFinite(lut.strength) ? Math.round(Math.min(1, Math.max(0, lut.strength)) * 1e3) / 1e3 : 1;
485
+ if (!strength) return void 0;
486
+ return { src: lut.src, ...strength !== 1 ? { strength } : {}, ...lut.name ? { name: lut.name } : {} };
487
+ }
233
488
  function normalizeVideoGrade(grade) {
234
489
  if (!grade) return void 0;
235
490
  const out = {};
@@ -237,6 +492,8 @@ function normalizeVideoGrade(grade) {
237
492
  const v = grade[c.key];
238
493
  if (typeof v === "number" && Number.isFinite(v) && v !== 0) out[c.key] = Math.round(clampTo(c, v) * 1e3) / 1e3;
239
494
  }
495
+ const lut = normalizeVideoGradeLut(grade.lut);
496
+ if (lut) out.lut = lut;
240
497
  return Object.keys(out).length ? out : void 0;
241
498
  }
242
499
  function videoGradePreset(name, amount = 1) {
@@ -245,6 +502,9 @@ function videoGradePreset(name, amount = 1) {
245
502
  const k = Math.min(1, Math.max(0, amount));
246
503
  return normalizeVideoGrade(Object.fromEntries(Object.entries(preset).map(([key, v]) => [key, v * k])));
247
504
  }
505
+ function videoGradeLook(current, name, amount = 1) {
506
+ return normalizeVideoGrade({ ...videoGradePreset(name, amount), ...current?.lut ? { lut: current.lut } : {} });
507
+ }
248
508
  function videoGradeSteps(grade) {
249
509
  const g = normalizeVideoGrade(grade);
250
510
  if (!g) return [];
@@ -302,21 +562,35 @@ function videoGradeStepMatrix(step) {
302
562
  }
303
563
  }
304
564
  }
305
- function applyVideoGrade(grade, rgb) {
565
+ function applyVideoGrade(grade, rgb, lut) {
306
566
  let v = rgb.map((x) => x / 255);
307
567
  for (const step of videoGradeSteps(grade)) {
308
568
  const m = videoGradeStepMatrix(step);
309
569
  v = m.map((row) => Math.min(1, Math.max(0, row[0] * v[0] + row[1] * v[1] + row[2] * v[2] + row[3])));
310
570
  }
571
+ const setting = normalizeVideoGradeLut(grade?.lut);
572
+ if (setting && lut) {
573
+ const out = applyVideoLut(lut, v);
574
+ const s = setting.strength ?? 1;
575
+ v = v.map((x, k) => x + (out[k] - x) * s);
576
+ }
311
577
  return v.map((x) => Math.round(x * 255));
312
578
  }
313
579
  var fmt2 = (v) => String(Math.round(v * 1e5) / 1e5);
314
580
  function videoGradeStepSvgValues(step) {
315
581
  return videoGradeStepMatrix(step).map((row) => [row[0], row[1], row[2], 0, row[3]].map(fmt2).join(" ")).concat("0 0 0 1 0").join(" ");
316
582
  }
317
- function videoGradeSvgFilter(id, grade) {
583
+ function videoLutSvgPrimitives(lut, strength = 1) {
584
+ const { tables, matrix } = videoLutSvgApproximation(lut);
585
+ const table = (k) => tables[k].map(fmt2).join(" ");
586
+ const values = matrix.map((row) => [row[0], row[1], row[2], 0, row[3]].map(fmt2).join(" ")).concat("0 0 0 1 0").join(" ");
587
+ const s = Math.min(1, Math.max(0, strength));
588
+ return `<feComponentTransfer result="lutIn"><feFuncA type="identity"/></feComponentTransfer><feComponentTransfer><feFuncR type="table" tableValues="${table(0)}"/><feFuncG type="table" tableValues="${table(1)}"/><feFuncB type="table" tableValues="${table(2)}"/></feComponentTransfer><feColorMatrix type="matrix" values="${values}"/>` + (s < 1 ? `<feComposite in2="lutIn" operator="arithmetic" k1="0" k2="${fmt2(s)}" k3="${fmt2(1 - s)}" k4="0"/>` : "");
589
+ }
590
+ function videoGradeSvgFilter(id, grade, lut) {
318
591
  const steps = videoGradeSteps(grade);
319
- return `<filter id="${id}" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">${steps.map((s) => `<feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/>`).join("")}</filter>`;
592
+ const setting = normalizeVideoGradeLut(grade?.lut);
593
+ return `<filter id="${id}" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">${steps.map((s) => `<feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/>`).join("")}${setting && lut ? videoLutSvgPrimitives(lut, setting.strength) : ""}</filter>`;
320
594
  }
321
595
  var videoGradeGainsFilterId = (step) => `pixerate-wb-${[step.r, step.g, step.b].map((v) => Math.round(v * 1e3)).join("-")}`;
322
596
  function videoGradeCssFilter(grade) {
@@ -530,10 +804,17 @@ function parseVideoClipAnimation(v, path) {
530
804
  function parseVideoGrade(v, path) {
531
805
  if (v === void 0) return void 0;
532
806
  if (!isObject(v)) return fail(path, "must be an object such as { exposure, contrast, saturation }");
533
- const known = new Set(VIDEO_GRADE_CONTROLS.map((c) => c.key));
807
+ const known = /* @__PURE__ */ new Set([...VIDEO_GRADE_CONTROLS.map((c) => c.key), "lut"]);
534
808
  const unknown = Object.keys(v).find((k) => !known.has(k));
535
809
  if (unknown) fail(`${path}.${unknown}`, `is not a grade setting; use ${[...known].join(", ")}`);
536
- return compact(Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])));
810
+ return compact({ ...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])), lut: parseVideoGradeLut(v.lut, `${path}.lut`) });
811
+ }
812
+ function parseVideoGradeLut(v, path) {
813
+ if (v === void 0) return void 0;
814
+ if (!isObject(v)) return fail(path, "must be an object { src, strength } naming a .cube file");
815
+ const unknown = Object.keys(v).find((k) => !["src", "strength", "name"].includes(k));
816
+ if (unknown) fail(`${path}.${unknown}`, "is not a LUT setting; use src, strength, name");
817
+ return compact({ src: str(v.src, `${path}.src`), strength: num(v.strength, `${path}.strength`, { optional: true, min: 0, max: 1 }), name: str(v.name, `${path}.name`, true) });
537
818
  }
538
819
  function parseVideoTransition(v, path) {
539
820
  if (v === void 0) return void 0;
@@ -1797,10 +2078,10 @@ function videoTitleFrame(project, clip, clipTime = 0) {
1797
2078
  const layer = { clip: clip.id, track: "", type: "text", opacity: 1, rect: placeVideoRect(project, project, "fill", clip.transform, clipTime), rotation: videoTransformAt(clip.transform, clipTime).rotation, text: videoTextStyle(clip) };
1798
2079
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
1799
2080
  }
1800
- var clamp01 = (v) => Math.min(1, Math.max(0, v));
1801
- var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
2081
+ var clamp012 = (v) => Math.min(1, Math.max(0, v));
2082
+ var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp012(progress))) / 2;
1802
2083
  function videoFadeCurveGain(curve, x) {
1803
- const t = clamp01(x);
2084
+ const t = clamp012(x);
1804
2085
  switch (curve) {
1805
2086
  case "equal-power":
1806
2087
  return Math.sin(t * Math.PI / 2);
@@ -1834,8 +2115,8 @@ function activeOnTrack(track, time) {
1834
2115
  const tail = next?.transition ? next.transition.duration / 2 : 0;
1835
2116
  if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
1836
2117
  const a = { clip };
1837
- if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
1838
- if (tail && time >= videoClipEnd(clip) - tail) a.outgoing = { progress: clamp01((time - (videoClipEnd(clip) - tail)) / (2 * tail)), type: next.transition.type, next };
2118
+ if (lead && time < clip.start + lead) a.incoming = { progress: clamp012((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
2119
+ if (tail && time >= videoClipEnd(clip) - tail) a.outgoing = { progress: clamp012((time - (videoClipEnd(clip) - tail)) / (2 * tail)), type: next.transition.type, next };
1839
2120
  out.push(a);
1840
2121
  }
1841
2122
  return out;
@@ -1891,7 +2172,7 @@ function videoFrameAt(project, time) {
1891
2172
  const across = shiftDirection === "left" || shiftDirection === "right";
1892
2173
  const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
1893
2174
  const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
1894
- const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
2175
+ const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp012(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
1895
2176
  if (wipe) layer.wipe = wipe;
1896
2177
  if (iris) layer.iris = iris;
1897
2178
  if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
@@ -1901,7 +2182,7 @@ function videoFrameAt(project, time) {
1901
2182
  if (clip.type === "text") layer.text = videoTextStyle(clip);
1902
2183
  if (clip.type === "color") layer.color = clip.color;
1903
2184
  if (layer.opacity > 0) layers.push(layer);
1904
- if (dip && dip.opacity > 0) layers.push({ clip: `transition:${incoming ? clip.id : outgoing.next.id}`, track: track.id, type: "color", color: dip.color, opacity: clamp01(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });
2185
+ if (dip && dip.opacity > 0) layers.push({ clip: `transition:${incoming ? clip.id : outgoing.next.id}`, track: track.id, type: "color", color: dip.color, opacity: clamp012(dip.opacity), rect: { x: 0, y: 0, width: project.width, height: project.height }, rotation: 0 });
1905
2186
  }
1906
2187
  }
1907
2188
  return { time, width: project.width, height: project.height, background: project.background ?? "#000000", layers, audio };
@@ -2124,6 +2405,379 @@ var VideoAudioPeaksCache = class {
2124
2405
  }
2125
2406
  };
2126
2407
 
2408
+ // src/video-editor/scopes.ts
2409
+ var VIDEO_SCOPE_KINDS = ["histogram", "waveform", "vectorscope"];
2410
+ var videoLuma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
2411
+ function videoChroma(r, g, b) {
2412
+ const y = videoLuma(r, g, b);
2413
+ return [(b - y) / 1.8556, (r - y) / 1.5748];
2414
+ }
2415
+ function eachPixel(pixels, maxPixels, visit) {
2416
+ const { width, height, data } = pixels;
2417
+ if (!(width > 0 && height > 0) || data.length < width * height * 4) throw new Error(`The pixels are not ${width} \xD7 ${height} RGBA: there are ${data.length} values, and ${width * height * 4} are needed.`);
2418
+ const step = Math.max(1, Math.sqrt(width * height / Math.max(1, maxPixels ?? 262144)));
2419
+ let count = 0;
2420
+ for (let fy = step / 2; fy < height; fy += step) {
2421
+ const y = Math.floor(fy);
2422
+ for (let fx = step / 2; fx < width; fx += step) {
2423
+ const x = Math.floor(fx);
2424
+ const i = (y * width + x) * 4;
2425
+ visit(data[i], data[i + 1], data[i + 2], x, y);
2426
+ count++;
2427
+ }
2428
+ }
2429
+ return count;
2430
+ }
2431
+ var binOf = (v, bins) => Math.min(bins - 1, Math.floor((v + 1e-6) / 256 * bins));
2432
+ function videoHistogram(pixels, options = {}) {
2433
+ const bins = Math.max(1, Math.min(256, Math.round(options.bins ?? 256)));
2434
+ const out = { bins, r: Array(bins).fill(0), g: Array(bins).fill(0), b: Array(bins).fill(0), luma: Array(bins).fill(0), total: 0 };
2435
+ out.total = eachPixel(pixels, options.maxPixels, (r, g, b) => {
2436
+ out.r[binOf(r, bins)]++;
2437
+ out.g[binOf(g, bins)]++;
2438
+ out.b[binOf(b, bins)]++;
2439
+ out.luma[binOf(videoLuma(r, g, b), bins)]++;
2440
+ });
2441
+ return out;
2442
+ }
2443
+ function videoLumaWaveform(pixels, options = {}) {
2444
+ const columns = Math.max(1, Math.min(pixels.width, Math.round(options.columns ?? Math.min(pixels.width, 256))));
2445
+ const levels = Math.max(2, Math.min(256, Math.round(options.levels ?? 128)));
2446
+ const data = new Uint32Array(columns * levels);
2447
+ let peak = 0;
2448
+ eachPixel(pixels, options.maxPixels, (r, g, b, x) => {
2449
+ const c = Math.min(columns - 1, Math.floor(x / pixels.width * columns));
2450
+ const i = c * levels + binOf(videoLuma(r, g, b), levels);
2451
+ if (++data[i] > peak) peak = data[i];
2452
+ });
2453
+ return { columns, levels, data, peak };
2454
+ }
2455
+ var videoVectorscopePoint = (cb, cr, size) => [(cb + 0.5) * (size - 1), (0.5 - cr) * (size - 1)];
2456
+ function videoVectorscope(pixels, options = {}) {
2457
+ const size = Math.max(16, Math.min(512, Math.round(options.size ?? 128)));
2458
+ const data = new Uint32Array(size * size);
2459
+ let peak = 0;
2460
+ eachPixel(pixels, options.maxPixels, (r, g, b) => {
2461
+ const [cb, cr] = videoChroma(r / 255, g / 255, b / 255);
2462
+ const [x, y] = videoVectorscopePoint(cb, cr, size);
2463
+ const i = Math.round(y) * size + Math.round(x);
2464
+ if (++data[i] > peak) peak = data[i];
2465
+ });
2466
+ return { size, data, peak };
2467
+ }
2468
+ var round3 = (v) => Math.round(v * 1e3) / 1e3;
2469
+ var hex2 = (v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
2470
+ function videoExposureStats(pixels, options = {}) {
2471
+ const low = (options.shadowLevel ?? 0.02) * 255;
2472
+ const high = (options.highlightLevel ?? 0.98) * 255;
2473
+ const lumaBins = new Uint32Array(256);
2474
+ let sumY = 0;
2475
+ let sumR = 0;
2476
+ let sumG = 0;
2477
+ let sumB = 0;
2478
+ let sumSat = 0;
2479
+ let maxSat = 0;
2480
+ let shadows = 0;
2481
+ let highlights = 0;
2482
+ let cr = 0;
2483
+ let cg = 0;
2484
+ let cbl = 0;
2485
+ const total = eachPixel(pixels, options.maxPixels, (r, g, b) => {
2486
+ const y = videoLuma(r, g, b);
2487
+ lumaBins[Math.min(255, Math.round(y))]++;
2488
+ sumY += y;
2489
+ sumR += r;
2490
+ sumG += g;
2491
+ sumB += b;
2492
+ const [u, v] = videoChroma(r / 255, g / 255, b / 255);
2493
+ const sat = Math.min(1, Math.hypot(u, v) / 0.5);
2494
+ sumSat += sat;
2495
+ if (sat > maxSat) maxSat = sat;
2496
+ if (y <= low) shadows++;
2497
+ if (y >= high) highlights++;
2498
+ if (r >= high) cr++;
2499
+ if (g >= high) cg++;
2500
+ if (b >= high) cbl++;
2501
+ });
2502
+ const percentile = (p) => {
2503
+ const target = p * total;
2504
+ let seen = 0;
2505
+ for (let i = 0; i < 256; i++) {
2506
+ seen += lumaBins[i];
2507
+ if (seen >= target && seen > 0) return i / 255;
2508
+ }
2509
+ return 1;
2510
+ };
2511
+ let min = 0;
2512
+ while (min < 255 && !lumaBins[min]) min++;
2513
+ let max = 255;
2514
+ while (max > 0 && !lumaBins[max]) max--;
2515
+ return {
2516
+ pixels: total,
2517
+ luma: { mean: round3(sumY / total / 255), median: round3(percentile(0.5)), min: round3(min / 255), max: round3(max / 255), p1: round3(percentile(0.01)), p99: round3(percentile(0.99)) },
2518
+ clipped: { shadows: round3(shadows / total), highlights: round3(highlights / total), r: round3(cr / total), g: round3(cg / total), b: round3(cbl / total) },
2519
+ saturation: { mean: round3(sumSat / total), max: round3(maxSat) },
2520
+ average: `#${hex2(sumR / total)}${hex2(sumG / total)}${hex2(sumB / total)}`
2521
+ };
2522
+ }
2523
+ function videoExposureNotes(stats) {
2524
+ const notes = [];
2525
+ const pct = (v2) => `${Math.round(v2 * 1e3) / 10}%`;
2526
+ if (stats.clipped.shadows >= 0.05) notes.push(`${pct(stats.clipped.shadows)} of the picture is crushed to black; raise exposure or lower contrast.`);
2527
+ if (stats.clipped.highlights >= 0.02) notes.push(`${pct(stats.clipped.highlights)} of the picture is blown to white; lower exposure.`);
2528
+ else {
2529
+ const channels = ["r", "g", "b"].filter((k) => stats.clipped[k] >= 0.05);
2530
+ if (channels.length) notes.push(`The ${channels.map((k) => ({ r: "red", g: "green", b: "blue" })[k]).join(" and ")} channel${channels.length > 1 ? "s clip" : " clips"} in ${pct(Math.max(...channels.map((k) => stats.clipped[k])))} of the picture; lower exposure or saturation.`);
2531
+ }
2532
+ if (stats.luma.mean < 0.2 && stats.clipped.shadows < 0.05) notes.push(`The picture is dark (mean luma ${stats.luma.mean}); raise exposure if it is not meant to be.`);
2533
+ if (stats.luma.mean > 0.75 && stats.clipped.highlights < 0.02) notes.push(`The picture is bright (mean luma ${stats.luma.mean}); lower exposure if it is not meant to be.`);
2534
+ if (stats.luma.p99 - stats.luma.p1 < 0.3 && stats.pixels > 1) notes.push(`The picture is flat (most of it lies between luma ${stats.luma.p1} and ${stats.luma.p99}); raise contrast.`);
2535
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(stats.average.slice(i, i + 2), 16) / 255);
2536
+ const [u, v] = videoChroma(r, g, b);
2537
+ if (Math.hypot(u, v) > 0.04 && stats.saturation.mean < 0.35) {
2538
+ const warm = v - u;
2539
+ notes.push(`The picture has a ${Math.abs(warm) > Math.abs(v + u) * 0.5 ? warm > 0 ? "warm (orange)" : "cool (blue)" : v > 0 ? "magenta" : "green"} cast (average ${stats.average}); adjust temperature and tint.`);
2540
+ }
2541
+ return notes;
2542
+ }
2543
+ var videoVectorscopeAngle = (cb, cr) => (Math.atan2(cr, cb) * 180 / Math.PI % 360 + 360) % 360;
2544
+ function summarizeVideoScopes(pixels, options = {}) {
2545
+ const stats = videoExposureStats(pixels, options);
2546
+ const bins = Math.max(2, Math.min(64, Math.round(options.bins ?? 16)));
2547
+ const h = videoHistogram(pixels, { ...options, bins });
2548
+ const share = (a) => a.map((v) => round3(v / h.total));
2549
+ const columns = Math.max(1, Math.min(32, Math.round(options.columns ?? 8)));
2550
+ const w = videoLumaWaveform(pixels, { ...options, columns, levels: 256 });
2551
+ const waveform = Array.from({ length: w.columns }, (_, c) => {
2552
+ const counts = w.data.subarray(c * 256, c * 256 + 256);
2553
+ const n2 = counts.reduce((a, b) => a + b, 0);
2554
+ let lo = 0;
2555
+ while (lo < 255 && !counts[lo]) lo++;
2556
+ let hi = 255;
2557
+ while (hi > 0 && !counts[hi]) hi--;
2558
+ let seen = 0;
2559
+ let median = 0;
2560
+ for (let i = 0; i < 256; i++) {
2561
+ seen += counts[i];
2562
+ if (seen >= n2 / 2 && n2) {
2563
+ median = i;
2564
+ break;
2565
+ }
2566
+ }
2567
+ return { start: round3(c / w.columns), end: round3((c + 1) / w.columns), min: round3(lo / 255), median: round3(median / 255), max: round3(hi / 255) };
2568
+ }).filter((_, c) => w.data.subarray(c * 256, c * 256 + 256).some((v) => v > 0));
2569
+ const chroma = [];
2570
+ eachPixel(pixels, Math.min(options.maxPixels ?? 65536, 65536), (r, g, b) => {
2571
+ const [u, v] = videoChroma(r / 255, g / 255, b / 255);
2572
+ chroma.push([u, v, Math.hypot(u, v)]);
2573
+ });
2574
+ chroma.sort((a, b) => b[2] - a[2]);
2575
+ const top = chroma.slice(0, Math.max(1, Math.ceil(chroma.length / 10)));
2576
+ const mu = top.reduce((s, c) => s + c[0], 0) / top.length;
2577
+ const mv = top.reduce((s, c) => s + c[1], 0) / top.length;
2578
+ const strength = Math.min(1, top.reduce((s, c) => s + c[2], 0) / top.length / 0.5);
2579
+ const coherence = Math.hypot(mu, mv) / Math.max(1e-9, top.reduce((s, c) => s + c[2], 0) / top.length);
2580
+ return {
2581
+ stats,
2582
+ notes: videoExposureNotes(stats),
2583
+ histogram: { bins, luma: share(h.luma), r: share(h.r), g: share(h.g), b: share(h.b) },
2584
+ waveform,
2585
+ vectorscope: { hue: strength < 0.02 ? null : Math.round(videoVectorscopeAngle(mu, mv)), strength: round3(strength), spread: round3(1 - coherence) }
2586
+ };
2587
+ }
2588
+ var VIDEO_VECTORSCOPE_TARGETS = [
2589
+ ["R", [0.75, 0, 0]],
2590
+ ["Mg", [0.75, 0, 0.75]],
2591
+ ["B", [0, 0, 0.75]],
2592
+ ["Cy", [0, 0.75, 0.75]],
2593
+ ["G", [0, 0.75, 0]],
2594
+ ["Yl", [0.75, 0.75, 0]]
2595
+ ].map(([name, rgb]) => {
2596
+ const [cb, cr] = videoChroma(rgb[0], rgb[1], rgb[2]);
2597
+ return { name, rgb: [...rgb], cb, cr };
2598
+ });
2599
+ function canvas(width, height) {
2600
+ const data = new Uint8ClampedArray(width * height * 4);
2601
+ for (let i = 0; i < data.length; i += 4) {
2602
+ data[i] = 12;
2603
+ data[i + 1] = 14;
2604
+ data[i + 2] = 18;
2605
+ data[i + 3] = 255;
2606
+ }
2607
+ return { width, height, data };
2608
+ }
2609
+ function plot(img, x, y, rgb, alpha = 1, add = false) {
2610
+ const xi = Math.round(x);
2611
+ const yi = Math.round(y);
2612
+ if (xi < 0 || yi < 0 || xi >= img.width || yi >= img.height) return;
2613
+ const i = (yi * img.width + xi) * 4;
2614
+ for (let k = 0; k < 3; k++) img.data[i + k] = add ? img.data[i + k] + rgb[k] * alpha : img.data[i + k] * (1 - alpha) + rgb[k] * alpha;
2615
+ }
2616
+ var GRID = [70, 76, 88];
2617
+ var glow = (count, peak) => count ? 0.25 + 0.75 * (Math.log1p(count) / Math.log1p(Math.max(1, peak))) : 0;
2618
+ function videoHistogramImage(h, width = 256, height = 128) {
2619
+ const img = canvas(width, height);
2620
+ for (const q of [0.25, 0.5, 0.75]) for (let y = 0; y < height; y++) plot(img, q * (width - 1), y, GRID);
2621
+ const inner = (a) => a.length > 2 ? a.slice(1, -1) : a;
2622
+ const peak = Math.max(1, ...inner(h.r), ...inner(h.g), ...inner(h.b), ...inner(h.luma));
2623
+ const height01 = (v) => Math.min(1, Math.sqrt(v / peak));
2624
+ for (let x = 0; x < width; x++) {
2625
+ const bin = Math.min(h.bins - 1, Math.floor(x / width * h.bins));
2626
+ const channels = [
2627
+ [h.r, [200, 40, 40]],
2628
+ [h.g, [40, 180, 40]],
2629
+ [h.b, [50, 80, 220]]
2630
+ ];
2631
+ for (const [a, rgb] of channels) {
2632
+ const top = height - height01(a[bin]) * height;
2633
+ for (let y = Math.ceil(top); y < height; y++) plot(img, x, y, rgb, 0.8, true);
2634
+ }
2635
+ plot(img, x, height - 1 - height01(h.luma[bin]) * (height - 1), [255, 255, 255]);
2636
+ }
2637
+ return img;
2638
+ }
2639
+ function videoWaveformImage(w, width = 256, height = 128) {
2640
+ const img = canvas(width, height);
2641
+ for (const q of [0, 0.25, 0.5, 0.75, 1]) for (let x = 0; x < width; x++) plot(img, x, (1 - q) * (height - 1), GRID);
2642
+ for (let x = 0; x < width; x++) {
2643
+ const c = Math.min(w.columns - 1, Math.floor(x / width * w.columns));
2644
+ for (let y = 0; y < height; y++) {
2645
+ const level = Math.min(w.levels - 1, Math.floor((height - 1 - y) / height * w.levels));
2646
+ const a = glow(w.data[c * w.levels + level], w.peak);
2647
+ if (a) plot(img, x, y, [120, 255, 140], a);
2648
+ }
2649
+ }
2650
+ return img;
2651
+ }
2652
+ function videoVectorscopeImage(v, size = v.size) {
2653
+ const img = canvas(size, size);
2654
+ const c = (size - 1) / 2;
2655
+ for (let a = 0; a < 720; a++) {
2656
+ const t = a / 720 * 2 * Math.PI;
2657
+ plot(img, c + Math.cos(t) * c, c - Math.sin(t) * c, GRID);
2658
+ }
2659
+ const skin = 123 * Math.PI / 180;
2660
+ for (let r = 0; r < c; r++) plot(img, c + Math.cos(skin) * r, c - Math.sin(skin) * r, [140, 110, 90]);
2661
+ for (const t of VIDEO_VECTORSCOPE_TARGETS) {
2662
+ const [x, y] = videoVectorscopePoint(t.cb, t.cr, size);
2663
+ const rgb = t.rgb.map((k) => 80 + k * 200);
2664
+ for (let k = -3; k <= 3; k++) {
2665
+ plot(img, x + k, y - 3, rgb);
2666
+ plot(img, x + k, y + 3, rgb);
2667
+ plot(img, x - 3, y + k, rgb);
2668
+ plot(img, x + 3, y + k, rgb);
2669
+ }
2670
+ }
2671
+ for (let y = 0; y < size; y++)
2672
+ for (let x = 0; x < size; x++) {
2673
+ const sy = Math.min(v.size - 1, Math.floor(y / size * v.size));
2674
+ const sx = Math.min(v.size - 1, Math.floor(x / size * v.size));
2675
+ const a = glow(v.data[sy * v.size + sx], v.peak);
2676
+ if (!a) continue;
2677
+ const cb = sx / (v.size - 1) - 0.5;
2678
+ const cr = 0.5 - sy / (v.size - 1);
2679
+ const r = Math.min(1, Math.max(0, 0.6 + 1.5748 * cr));
2680
+ const g = Math.min(1, Math.max(0, 0.6 - 0.1873 * cb - 0.4681 * cr));
2681
+ const b = Math.min(1, Math.max(0, 0.6 + 1.8556 * cb));
2682
+ plot(img, x, y, [r * 255, g * 255, b * 255], a);
2683
+ }
2684
+ return img;
2685
+ }
2686
+ function videoScopeImage(kind, pixels, width = 256, height = 128, options = {}) {
2687
+ if (kind === "histogram") return videoHistogramImage(videoHistogram(pixels, { ...options, bins: Math.min(256, width) }), width, height);
2688
+ if (kind === "waveform") return videoWaveformImage(videoLumaWaveform(pixels, { ...options, columns: Math.min(pixels.width, width), levels: Math.min(256, height) }), width, height);
2689
+ const size = Math.min(width, height);
2690
+ return videoVectorscopeImage(videoVectorscope(pixels, { ...options, size }), size);
2691
+ }
2692
+ var VideoPreviewPixels = class {
2693
+ reader = null;
2694
+ drawnAt = null;
2695
+ listeners = /* @__PURE__ */ new Set();
2696
+ /** Gives the canvas to read (the latest preview wins). Returns a function that takes it away again. */
2697
+ attach(reader) {
2698
+ this.reader = reader;
2699
+ this.drawnAt = null;
2700
+ return () => {
2701
+ if (this.reader !== reader) return;
2702
+ this.reader = null;
2703
+ this.drawnAt = null;
2704
+ this.emit();
2705
+ };
2706
+ }
2707
+ /** Whether a preview is attached. */
2708
+ get attached() {
2709
+ return this.reader !== null;
2710
+ }
2711
+ /** The preview drew the frame at `time`; `complete` is false while a picture was missing or stale. */
2712
+ drawn(time, complete) {
2713
+ this.drawnAt = { time, complete };
2714
+ this.emit();
2715
+ }
2716
+ /** The last frame drawn, with its pixels; `null` without a preview. Throws when the canvas cannot be read. */
2717
+ read() {
2718
+ if (!this.reader) return null;
2719
+ try {
2720
+ return this.reader();
2721
+ } catch (e) {
2722
+ const reason = e instanceof Error ? e.message : String(e);
2723
+ throw new Error(`The preview's pixels cannot be read (${reason}). Media from another origin must be served with CORS headers (Access-Control-Allow-Origin) for its pixels to be measured.`);
2724
+ }
2725
+ }
2726
+ /** Called after each frame the preview draws. */
2727
+ subscribe(listener) {
2728
+ this.listeners.add(listener);
2729
+ return () => {
2730
+ this.listeners.delete(listener);
2731
+ };
2732
+ }
2733
+ /**
2734
+ * The frame at `time` once it is complete: now, if the last frame drawn was it, else the next
2735
+ * complete drawing of it. Fails after `timeout` ms (default 5000).
2736
+ */
2737
+ next(time, timeout = 5e3) {
2738
+ const matches = () => this.drawnAt && this.drawnAt.complete && Math.abs(this.drawnAt.time - time) < 0.5;
2739
+ return new Promise((resolve, reject) => {
2740
+ const done = (fn) => {
2741
+ clearTimeout(timer);
2742
+ off();
2743
+ fn();
2744
+ };
2745
+ const check = () => {
2746
+ if (!this.reader) return done(() => reject(new Error("The preview was closed before it drew the frame.")));
2747
+ if (!matches()) return;
2748
+ try {
2749
+ const snap = this.read();
2750
+ done(() => snap ? resolve(snap) : reject(new Error("The preview has no pixels to read.")));
2751
+ } catch (e) {
2752
+ done(() => reject(e));
2753
+ }
2754
+ };
2755
+ const off = this.subscribe(check);
2756
+ const timer = setTimeout(() => done(() => reject(new Error(`The preview did not finish drawing ${Math.round(time)} ms within ${timeout} ms; its media may still be loading or may have failed to load. Try again, or check the asset sources.`))), timeout);
2757
+ check();
2758
+ });
2759
+ }
2760
+ emit() {
2761
+ for (const l of [...this.listeners]) l();
2762
+ }
2763
+ };
2764
+ function readVideoScopesPanel(feed, kinds = VIDEO_SCOPE_KINDS, options = {}) {
2765
+ let snap;
2766
+ try {
2767
+ snap = feed.read();
2768
+ } catch (e) {
2769
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
2770
+ }
2771
+ if (!snap) return { ok: false, message: feed.attached ? "The preview has not drawn a frame yet." : "Show the preview to see its scopes." };
2772
+ const width = options.width ?? 256;
2773
+ const height = options.height ?? 128;
2774
+ const opts = { maxPixels: options.maxPixels ?? 65536 };
2775
+ const images = {};
2776
+ for (const kind of kinds) images[kind] = videoScopeImage(kind, snap.pixels, width, height, opts);
2777
+ const stats = videoExposureStats(snap.pixels, opts);
2778
+ return { ok: true, time: snap.time, complete: snap.complete, images, stats, notes: videoExposureNotes(stats) };
2779
+ }
2780
+
2127
2781
  // src/video-editor/transcript.ts
2128
2782
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2129
2783
  function cueTime(s) {
@@ -2312,7 +2966,7 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
2312
2966
  if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
2313
2967
  if (layer.blend) attrs.push(`style="mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}"`);
2314
2968
  if (layer.grade) {
2315
- defs.push(videoGradeSvgFilter(`g${i}`, layer.grade));
2969
+ defs.push(videoGradeSvgFilter(`g${i}`, layer.grade, layer.grade.lut ? options.lutFor?.(layer.grade.lut.src) : null));
2316
2970
  attrs.push(`filter="url(#g${i})"`);
2317
2971
  }
2318
2972
  if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
@@ -2375,69 +3029,96 @@ function videoContactSheetSvg(project, times, options = {}) {
2375
3029
  });
2376
3030
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(W)} ${n(H)}" width="${n(W)}" height="${n(H)}"><rect width="100%" height="100%" fill="#f8fafc"/>${cells.join("")}</svg>`;
2377
3031
  }
3032
+ function paintVideoLayer(ctx, frame, layer, sourceFor, options) {
3033
+ const { rect } = layer;
3034
+ if (layer.iris) {
3035
+ ctx.beginPath();
3036
+ ctx.arc(frame.width / 2, frame.height / 2, videoIrisRadius(frame, layer.iris.progress), 0, 2 * Math.PI);
3037
+ ctx.clip();
3038
+ }
3039
+ if (layer.grade) {
3040
+ const defs = videoGradeSvgDefs(layer.grade);
3041
+ if (options.defineSvgFilter) for (const d of defs) options.defineSvgFilter(d.id, d.markup);
3042
+ const css = videoGradeCssFilter(layer.grade);
3043
+ ctx.filter = options.defineSvgFilter ? css || "none" : css.replace(/\s*url\([^)]*\)/g, "").trim() || "none";
3044
+ }
3045
+ if (layer.rotation) {
3046
+ ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
3047
+ ctx.rotate(layer.rotation * Math.PI / 180);
3048
+ ctx.translate(-(rect.x + rect.width / 2), -(rect.y + rect.height / 2));
3049
+ }
3050
+ if (layer.wipe) {
3051
+ const r = videoWipeRect(rect, layer.wipe);
3052
+ ctx.beginPath();
3053
+ ctx.rect(r.x, r.y, r.width, r.height);
3054
+ ctx.clip();
3055
+ }
3056
+ if (layer.type === "color") {
3057
+ ctx.fillStyle = layer.color ?? "#000000";
3058
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
3059
+ } else if (layer.type === "text" && layer.text) {
3060
+ const t = layer.text;
3061
+ const lines = t.text.split("\n");
3062
+ const lh = t.fontSize * 1.2;
3063
+ const cx = rect.x + rect.width / 2;
3064
+ const top = rect.y + rect.height / 2 - lh * (lines.length - 1) / 2;
3065
+ ctx.font = `${t.fontWeight} ${t.fontSize}px ${t.fontFamily}`;
3066
+ ctx.textAlign = "center";
3067
+ ctx.textBaseline = "middle";
3068
+ if (t.background) {
3069
+ const w = Math.max(...lines.map((l) => ctx.measureText(l).width)) + t.fontSize;
3070
+ const h = lh * lines.length + t.fontSize * 0.4;
3071
+ ctx.fillStyle = t.background;
3072
+ ctx.fillRect(cx - w / 2, rect.y + rect.height / 2 - h / 2, w, h);
3073
+ }
3074
+ ctx.fillStyle = t.color;
3075
+ lines.forEach((l, k) => ctx.fillText(l, cx, top + k * lh));
3076
+ } else {
3077
+ const source = sourceFor(layer);
3078
+ if (source) ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
3079
+ else {
3080
+ ctx.fillStyle = "#334155";
3081
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
3082
+ ctx.strokeStyle = "#94a3b8";
3083
+ ctx.lineWidth = 2;
3084
+ ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
3085
+ }
3086
+ }
3087
+ }
2378
3088
  function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
2379
3089
  ctx.save();
2380
3090
  ctx.setTransform(scale, 0, 0, scale, 0, 0);
2381
3091
  ctx.globalAlpha = 1;
2382
3092
  ctx.fillStyle = frame.background;
2383
3093
  ctx.fillRect(0, 0, frame.width, frame.height);
3094
+ const cw = Math.max(1, Math.round(frame.width * scale));
3095
+ const ch = Math.max(1, Math.round(frame.height * scale));
2384
3096
  for (const layer of frame.layers) {
2385
- const { rect } = layer;
3097
+ const setting = layer.grade?.lut;
3098
+ const lut = setting ? options.lutFor?.(setting.src) : null;
3099
+ const scratch = lut ? options.scratch?.(cw, ch) : null;
2386
3100
  ctx.save();
2387
- ctx.globalAlpha = layer.opacity;
2388
- if (layer.iris) {
2389
- ctx.beginPath();
2390
- ctx.arc(frame.width / 2, frame.height / 2, videoIrisRadius(frame, layer.iris.progress), 0, 2 * Math.PI);
2391
- ctx.clip();
2392
- }
2393
- if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
2394
- if (layer.grade) {
2395
- const defs = videoGradeSvgDefs(layer.grade);
2396
- if (options.defineSvgFilter) for (const d of defs) options.defineSvgFilter(d.id, d.markup);
2397
- const css = videoGradeCssFilter(layer.grade);
2398
- ctx.filter = options.defineSvgFilter ? css : css.replace(/\s*url\([^)]*\)/g, "").trim() || "none";
2399
- }
2400
- if (layer.rotation) {
2401
- ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
2402
- ctx.rotate(layer.rotation * Math.PI / 180);
2403
- ctx.translate(-(rect.x + rect.width / 2), -(rect.y + rect.height / 2));
2404
- }
2405
- if (layer.wipe) {
2406
- const r = videoWipeRect(rect, layer.wipe);
2407
- ctx.beginPath();
2408
- ctx.rect(r.x, r.y, r.width, r.height);
2409
- ctx.clip();
2410
- }
2411
- if (layer.type === "color") {
2412
- ctx.fillStyle = layer.color ?? "#000000";
2413
- ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
2414
- } else if (layer.type === "text" && layer.text) {
2415
- const t = layer.text;
2416
- const lines = t.text.split("\n");
2417
- const lh = t.fontSize * 1.2;
2418
- const cx = rect.x + rect.width / 2;
2419
- const top = rect.y + rect.height / 2 - lh * (lines.length - 1) / 2;
2420
- ctx.font = `${t.fontWeight} ${t.fontSize}px ${t.fontFamily}`;
2421
- ctx.textAlign = "center";
2422
- ctx.textBaseline = "middle";
2423
- if (t.background) {
2424
- const w = Math.max(...lines.map((l) => ctx.measureText(l).width)) + t.fontSize;
2425
- const h = lh * lines.length + t.fontSize * 0.4;
2426
- ctx.fillStyle = t.background;
2427
- ctx.fillRect(cx - w / 2, rect.y + rect.height / 2 - h / 2, w, h);
2428
- }
2429
- ctx.fillStyle = t.color;
2430
- lines.forEach((l, k) => ctx.fillText(l, cx, top + k * lh));
3101
+ if (lut && scratch) {
3102
+ const s = scratch.ctx;
3103
+ s.save();
3104
+ s.setTransform(1, 0, 0, 1, 0, 0);
3105
+ s.clearRect(0, 0, cw, ch);
3106
+ s.setTransform(scale, 0, 0, scale, 0, 0);
3107
+ s.globalAlpha = 1;
3108
+ s.globalCompositeOperation = "source-over";
3109
+ paintVideoLayer(s, frame, layer, sourceFor, options);
3110
+ s.restore();
3111
+ const img = s.getImageData(0, 0, cw, ch);
3112
+ applyVideoLutToPixels(lut, img.data, setting.strength ?? 1);
3113
+ s.putImageData(img, 0, 0);
3114
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3115
+ ctx.globalAlpha = layer.opacity;
3116
+ if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
3117
+ ctx.drawImage(scratch.canvas, 0, 0, cw, ch);
2431
3118
  } else {
2432
- const source = sourceFor(layer);
2433
- if (source) ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
2434
- else {
2435
- ctx.fillStyle = "#334155";
2436
- ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
2437
- ctx.strokeStyle = "#94a3b8";
2438
- ctx.lineWidth = 2;
2439
- ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
2440
- }
3119
+ ctx.globalAlpha = layer.opacity;
3120
+ if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
3121
+ paintVideoLayer(ctx, frame, layer, sourceFor, options);
2441
3122
  }
2442
3123
  ctx.restore();
2443
3124
  }
@@ -2715,7 +3396,7 @@ function buildFfmpeg(project, options, measure) {
2715
3396
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
2716
3397
  ];
2717
3398
  if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
2718
- if (videoGradeFfmpegFilters(clip.grade).length) notes.push(`Title "${clip.id}" is drawn without its grade (drawtext draws straight onto the picture); pass titleImage to grade titles.`);
3399
+ if (videoGradeFfmpegFilters(clip.grade).length || normalizeVideoGradeLut(clip.grade?.lut)) notes.push(`Title "${clip.id}" is drawn without its grade (drawtext draws straight onto the picture); pass titleImage to grade titles.`);
2719
3400
  if (clip.blend && clip.blend !== "normal") notes.push(`Title "${clip.id}" is drawn without its blend mode (drawtext draws straight onto the picture); pass titleImage to blend titles.`);
2720
3401
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
2721
3402
  cur = next;
@@ -2735,6 +3416,20 @@ function buildFfmpeg(project, options, measure) {
2735
3416
  }
2736
3417
  const grade = videoGradeFfmpegFilters(clip.grade);
2737
3418
  if (grade.length) chain.push("format=rgba", ...grade);
3419
+ const lut = normalizeVideoGradeLut(clip.grade?.lut);
3420
+ if (lut) {
3421
+ chain.push("format=gbrap");
3422
+ const path = options.resolveLut ? options.resolveLut(lut.src) : lut.src;
3423
+ if (/^(blob|data|https?):/i.test(path)) notes.push(`ffmpeg's lut3d reads only local files, and clip "${clip.id}"'s LUT is "${path.slice(0, 80)}"; save it and pass resolveLut to give its path.`);
3424
+ const file = ffmpegFilterValue(path);
3425
+ const into2 = `q${next}`;
3426
+ graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[${into2}]`);
3427
+ const lut3d = `lut3d=file=${file}:interp=tetrahedral`;
3428
+ if ((lut.strength ?? 1) >= 1) graph.push(`[${into2}]${lut3d}[r${next}]`);
3429
+ else graph.push(`[${into2}]split[o${next}][p${next}]`, `[p${next}]${lut3d}[s${next}]`, `[s${next}][o${next}]blend=all_mode=normal:all_opacity=${lut.strength}[r${next}]`);
3430
+ source = `[r${next}]`;
3431
+ chain.length = 0;
3432
+ }
2738
3433
  chain.push("format=yuva420p");
2739
3434
  if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
2740
3435
  else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
@@ -2846,12 +3541,19 @@ function buildFfmpeg(project, options, measure) {
2846
3541
 
2847
3542
  // src/video-editor/preview.ts
2848
3543
  var VideoPreviewEngine = class {
2849
- constructor(source, canvas, options = {}) {
3544
+ constructor(source, canvas2, options = {}) {
2850
3545
  this.source = source;
2851
3546
  this.options = options;
2852
- this.canvas = canvas;
2853
- this.ctx = options.context !== void 0 ? options.context : canvas.getContext?.("2d") ?? null;
3547
+ this.canvas = canvas2;
3548
+ this.ctx = options.context !== void 0 ? options.context : canvas2.getContext?.("2d") ?? null;
2854
3549
  this.unsubscribe = source.subscribe(() => this.update());
3550
+ this.lutCache = options.luts !== void 0 ? options.luts : source.luts ?? null;
3551
+ this.unsubscribeLuts = this.lutCache ? this.lutCache.subscribe(() => this.redraw()) : () => {
3552
+ };
3553
+ const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
3554
+ this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
3555
+ };
3556
+ this.pixelsTarget = pixels ?? null;
2855
3557
  this.update();
2856
3558
  }
2857
3559
  source;
@@ -2865,6 +3567,19 @@ var VideoPreviewEngine = class {
2865
3567
  destroyed = false;
2866
3568
  filterHost = null;
2867
3569
  filters = /* @__PURE__ */ new Set();
3570
+ detachPixels;
3571
+ pixelsTarget;
3572
+ drawn = null;
3573
+ complete = true;
3574
+ lutCache;
3575
+ unsubscribeLuts;
3576
+ scratchCanvas;
3577
+ /** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
3578
+ readPixels() {
3579
+ const ctx = this.ctx;
3580
+ if (!ctx?.getImageData || !this.drawn) return null;
3581
+ return { ...this.drawn, pixels: ctx.getImageData(0, 0, this.canvas.width, this.canvas.height) };
3582
+ }
2868
3583
  createMedia(kind) {
2869
3584
  if (this.options.createMedia) return this.options.createMedia(kind);
2870
3585
  const el = document.createElement(kind);
@@ -2956,7 +3671,9 @@ var VideoPreviewEngine = class {
2956
3671
  return img.complete ? img : null;
2957
3672
  }
2958
3673
  const entry = this.media.get(layer.clip);
2959
- return entry && entry.media.readyState >= 2 ? entry.media : null;
3674
+ if (!entry || entry.media.readyState < 2) return null;
3675
+ if (entry.media.seeking) this.complete = false;
3676
+ return entry.media;
2960
3677
  };
2961
3678
  draw(frame) {
2962
3679
  if (!this.ctx) return;
@@ -2967,9 +3684,39 @@ var VideoPreviewEngine = class {
2967
3684
  const h = Math.max(1, Math.round(frame.height * resolution));
2968
3685
  if (this.canvas.width !== w) this.canvas.width = w;
2969
3686
  if (this.canvas.height !== h) this.canvas.height = h;
2970
- drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
3687
+ this.complete = true;
3688
+ const sourceFor = (layer) => {
3689
+ const source = this.sourceFor(layer);
3690
+ if (!source) this.complete = false;
3691
+ return source;
3692
+ };
3693
+ drawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter, lutFor: this.lutFor, scratch: this.scratch });
3694
+ this.drawn = { time: frame.time, complete: this.complete };
2971
3695
  this.options.onFrame?.(frame);
3696
+ this.pixelsTarget?.drawn(frame.time, this.complete);
2972
3697
  }
3698
+ /** A grade's LUT once loaded; while it loads (or if it fails) the frame is drawn without it and counts as incomplete. */
3699
+ lutFor = (src) => {
3700
+ const lut = this.lutCache?.get(src);
3701
+ if (!lut) this.complete = false;
3702
+ return lut;
3703
+ };
3704
+ scratch = (width, height) => {
3705
+ if (this.scratchCanvas === void 0) {
3706
+ if (this.options.createScratch) this.scratchCanvas = this.options.createScratch(width, height);
3707
+ else if (typeof document !== "undefined") {
3708
+ const canvas2 = document.createElement("canvas");
3709
+ const ctx = canvas2.getContext("2d", { willReadFrequently: true });
3710
+ this.scratchCanvas = ctx ? { canvas: canvas2, ctx } : null;
3711
+ } else this.scratchCanvas = null;
3712
+ }
3713
+ const s = this.scratchCanvas;
3714
+ if (!s) return null;
3715
+ const c = s.canvas;
3716
+ if (c.width !== width) c.width = width;
3717
+ if (c.height !== height) c.height = height;
3718
+ return s;
3719
+ };
2973
3720
  defineSvgFilter = (id, markup) => {
2974
3721
  if (this.options.defineSvgFilter === null) return;
2975
3722
  if (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);
@@ -2989,6 +3736,8 @@ var VideoPreviewEngine = class {
2989
3736
  /** Stops following the editor and releases every media element. */
2990
3737
  destroy() {
2991
3738
  this.destroyed = true;
3739
+ this.detachPixels();
3740
+ this.unsubscribeLuts();
2992
3741
  this.filterHost?.remove();
2993
3742
  this.filterHost = null;
2994
3743
  this.filters.clear();
@@ -3004,6 +3753,10 @@ var VideoEditorController = class {
3004
3753
  player;
3005
3754
  /** Sound levels per asset, loaded on demand for waveforms and agents. */
3006
3755
  audioPeaks;
3756
+ /** The pixels the preview last drew, for scopes and `get_video_scopes` (a `VideoPreviewEngine` fills it). */
3757
+ previewPixels = new VideoPreviewPixels();
3758
+ /** Grades' LUTs, loaded on demand for the preview, SVG frames and agents. */
3759
+ luts;
3007
3760
  project;
3008
3761
  issues;
3009
3762
  selection = [];
@@ -3018,6 +3771,7 @@ var VideoEditorController = class {
3018
3771
  constructor(options = {}) {
3019
3772
  this.history = options.history ?? new HistoryManager();
3020
3773
  this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
3774
+ this.luts = options.luts instanceof VideoLutCache ? options.luts : new VideoLutCache(options.luts);
3021
3775
  this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
3022
3776
  this.issues = checkVideoProject(this.project);
3023
3777
  this.readOnly = options.readOnly ?? false;
@@ -3228,7 +3982,7 @@ var VideoEditorController = class {
3228
3982
  }
3229
3983
  /** The frame at `time` (default: the playhead) as SVG. */
3230
3984
  toSvg(time = this.player.currentTime, options = {}) {
3231
- return videoProjectSvg(this.project, time, options);
3985
+ return videoProjectSvg(this.project, time, { lutFor: (src) => this.luts.peek(src), ...options });
3232
3986
  }
3233
3987
  /** The project as an ffmpeg command. */
3234
3988
  toFfmpeg(options = {}) {
@@ -3244,72 +3998,310 @@ var VideoEditorController = class {
3244
3998
  }
3245
3999
  };
3246
4000
 
3247
- // src/video-editor/tools.ts
3248
- var round = (ms) => Math.round(ms * 10) / 10;
3249
- var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
3250
- var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
3251
- var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
3252
- var DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
3253
- var object = (properties, required = [], description) => ({
3254
- type: "object",
3255
- ...description ? { description } : {},
3256
- properties,
3257
- ...required.length ? { required } : {},
3258
- additionalProperties: false
3259
- });
3260
- var MS = (what) => ({ type: "number", minimum: 0, description: `${what}, in milliseconds.` });
3261
- var CLIP_ID = { type: "string", minLength: 1, description: 'A clip id from get_video_project (e.g. "c1").' };
3262
- var TRACK_ID = { type: "string", minLength: 1, description: 'A track id from get_video_project (e.g. "v1" or "a1").' };
3263
- var ASSET_ID = { type: "string", minLength: 1, description: "An asset id from get_video_project." };
3264
- var COLOR = (what) => ({ type: "string", minLength: 1, description: `${what}: a CSS colour such as "#ffcc00".` });
3265
- var RIPPLE = {
3266
- type: "boolean",
3267
- description: "Push (or pull) later material along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap). When the track is sync-locked (the default), every other unlocked sync-locked track moves too: clips across the edit point are cut when time opens, and closing time is refused if another sync-locked track has a clip in that stretch."
4001
+ // src/video-editor/ui.ts
4002
+ var STEPS = [40, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5];
4003
+ function videoRulerTicks(duration, pxPerSecond, fps) {
4004
+ const pxPerMs = pxPerSecond / 1e3;
4005
+ const major = STEPS.find((s) => s * pxPerMs >= 80) ?? STEPS[STEPS.length - 1];
4006
+ const minor = STEPS.slice().reverse().find((s) => s < major && major % s === 0 && s * pxPerMs >= 10);
4007
+ const step = minor ?? major;
4008
+ const ticks = [];
4009
+ for (let t = 0; t <= duration + 1e-6; t += step) {
4010
+ const isMajor = Math.abs(t / major - Math.round(t / major)) < 1e-9;
4011
+ ticks.push({ time: t, x: t * pxPerMs, major: isMajor, ...isMajor ? { label: formatVideoTimecode(t, fps).replace(/^00:/, "") } : {} });
4012
+ }
4013
+ return ticks;
4014
+ }
4015
+ function videoClipShowsWaveform(clip, asset, mode = "audio") {
4016
+ if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
4017
+ return mode === "all" || asset.kind === "audio";
4018
+ }
4019
+ var VIDEO_CLIP_ANIMATION_LABELS = {
4020
+ fade: "Fade",
4021
+ rise: "Rise",
4022
+ drop: "Drop",
4023
+ "slide-left": "Slide left",
4024
+ "slide-right": "Slide right",
4025
+ zoom: "Zoom",
4026
+ pop: "Pop"
3268
4027
  };
3269
- var MODE = {
3270
- type: "string",
3271
- enum: [...VIDEO_PLACEMENT_MODES],
3272
- description: 'What to do with clips already where it lands: "refuse" (default) fails with the next free place; "overwrite" cuts, trims or removes what is underneath on that track; "insert" cuts everything across the start and pushes later material along by its length on this track and every sync-locked track.'
4028
+ var VIDEO_TRANSITION_LABELS = {
4029
+ crossfade: "Crossfade",
4030
+ dip: "Dip to colour",
4031
+ wipe: "Wipe",
4032
+ push: "Push",
4033
+ slide: "Slide",
4034
+ iris: "Iris"
3273
4035
  };
3274
- var DELTA = (what) => ({ type: "number", description: `${what}, in milliseconds of timeline time: positive is later, negative earlier. It is clamped to what the media and neighbours allow; the result says how far it went.` });
3275
- var RANGE_TRACKS = { type: "array", items: TRACK_ID, minItems: 1, uniqueItems: true, description: "Tracks to work on (default: every unlocked track). Locked tracks are refused." };
3276
- var TRANSFORM = object(
3277
- {
3278
- x: { type: "number", description: "Offset of the centre to the right of the frame's centre, in pixels (default 0)." },
3279
- y: { type: "number", description: "Offset of the centre below the frame's centre, in pixels (default 0)." },
3280
- scale: { type: "number", minimum: 0, description: "Size after fitting (1 = fitted size, default 1)." },
3281
- rotation: { type: "number", description: "Degrees clockwise (default 0)." }
3282
- },
3283
- [],
3284
- "Where the picture sits after it is fitted."
3285
- );
3286
- var TRANSITION = object(
3287
- {
3288
- type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre)." },
3289
- duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none." },
3290
- color: COLOR("For dip: the colour passed through (default black)"),
3291
- direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left)." },
3292
- audioCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic." }
3293
- },
3294
- ["type", "duration"],
3295
- "A transition from the clip that ends where this one starts on the same track."
3296
- );
3297
- var GRADE = object(
3298
- Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
3299
- [],
3300
- "Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint). Leave a setting out for no change; the object replaces the clip's whole grade."
3301
- );
3302
- var ANIMATION = (when) => object(
3303
- {
3304
- effect: { type: "string", enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: "fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards." },
3305
- duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
3306
- },
3307
- ["effect"],
3308
- when
3309
- );
3310
- var CLIP_COMMON = {
3311
- name: { type: "string", description: "A label for the clip." },
3312
- opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
4036
+ var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
4037
+ var VIDEO_BLEND_MODE_LABELS = {
4038
+ normal: "Normal",
4039
+ multiply: "Multiply",
4040
+ screen: "Screen",
4041
+ overlay: "Overlay",
4042
+ darken: "Darken",
4043
+ lighten: "Lighten",
4044
+ "color-dodge": "Colour dodge",
4045
+ "color-burn": "Colour burn",
4046
+ "hard-light": "Hard light",
4047
+ "soft-light": "Soft light",
4048
+ difference: "Difference",
4049
+ exclusion: "Exclusion",
4050
+ add: "Add"
4051
+ };
4052
+ var VIDEO_FADE_CURVE_LABELS = {
4053
+ linear: "Linear",
4054
+ "equal-power": "Equal power",
4055
+ "s-curve": "S-curve",
4056
+ exponential: "Exponential",
4057
+ logarithmic: "Logarithmic"
4058
+ };
4059
+ function videoClipTint(clip, asset) {
4060
+ if (clip.type === "text") return "#a855f7";
4061
+ if (clip.type === "color") return clip.color;
4062
+ if (!asset) return "#ef4444";
4063
+ return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
4064
+ }
4065
+ function videoClipLabel(clip, asset) {
4066
+ if (clip.name) return clip.name;
4067
+ if (clip.type === "text") return clip.text.split("\n")[0];
4068
+ if (clip.type === "color") return clip.color;
4069
+ const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
4070
+ const speed = videoClipSpeed(clip);
4071
+ return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
4072
+ }
4073
+ function planVideoClipDrag(project, input) {
4074
+ const at = findVideoClip(project, input.clip);
4075
+ if (!at) return null;
4076
+ const { clip, track } = at;
4077
+ const snap = input.snap ?? ((t) => t);
4078
+ if (input.mode === "move") {
4079
+ const raw = Math.max(0, clip.start + input.delta);
4080
+ const head = snap(raw) - raw;
4081
+ const tail = snap(raw + clip.duration) - (raw + clip.duration);
4082
+ const shifts = [head, tail].filter((d) => Math.abs(d) > 1e-9);
4083
+ const start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => Math.abs(b) < Math.abs(a) ? b : a) : 0));
4084
+ const target = input.track ?? track.id;
4085
+ const moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;
4086
+ const mode = input.placement && input.placement !== "refuse" ? { mode: input.placement } : {};
4087
+ return { start, duration: clip.duration, track: target, edit: moved ? { op: "move_clip", clip: clip.id, start, ...target !== track.id ? { track: target } : {}, ...mode } : null };
4088
+ }
4089
+ const minimum = 1e3 / project.fps;
4090
+ if (input.mode === "trim-start") {
4091
+ const to2 = Math.min(snap(Math.max(0, clip.start + input.delta)), videoClipEnd(clip) - minimum);
4092
+ const changed2 = Math.abs(to2 - clip.start) > 1e-6;
4093
+ const plan = input.ripple ? { start: clip.start, duration: clip.duration - (to2 - clip.start) } : { start: to2, duration: videoClipEnd(clip) - to2 };
4094
+ return { ...plan, track: track.id, edit: changed2 ? { op: "trim_clip", clip: clip.id, edge: "start", to: to2, ...input.ripple ? { ripple: true } : {} } : null };
4095
+ }
4096
+ const to = Math.max(snap(videoClipEnd(clip) + input.delta), clip.start + minimum);
4097
+ const changed = Math.abs(to - videoClipEnd(clip)) > 1e-6;
4098
+ return { start: clip.start, duration: to - clip.start, track: track.id, edit: changed ? { op: "trim_clip", clip: clip.id, edge: "end", to, ...input.ripple ? { ripple: true } : {} } : null };
4099
+ }
4100
+ function videoDropTracks(project, clipId) {
4101
+ const at = findVideoClip(project, clipId);
4102
+ if (!at) return [];
4103
+ return project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);
4104
+ }
4105
+ function videoDragLabel(edit) {
4106
+ if (edit.op === "move_clip") return edit.mode === "insert" ? "Insert clip" : edit.mode === "overwrite" ? "Overwrite clip" : "Move clip";
4107
+ return edit.op === "trim_clip" && edit.ripple ? "Ripple trim" : "Trim clip";
4108
+ }
4109
+ function videoAssetDropEdit(project, assetId, options = {}) {
4110
+ const asset = videoAssetMap(project).get(assetId);
4111
+ if (!asset) return null;
4112
+ const kind = asset.kind === "audio" ? "audio" : "video";
4113
+ const track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;
4114
+ if (!track) return null;
4115
+ return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {}, ...options.mode && options.mode !== "refuse" ? { mode: options.mode } : {} };
4116
+ }
4117
+ function videoAssetKindOf(typeOrName) {
4118
+ const s = typeOrName.toLowerCase().replace(/[?#].*$/, "");
4119
+ if (s.startsWith("video/") || /\.(mp4|m4v|mov|webm|ogv|mkv)$/.test(s)) return "video";
4120
+ if (s.startsWith("audio/") || /\.(mp3|m4a|aac|wav|ogg|oga|flac|opus)$/.test(s)) return "audio";
4121
+ if (s.startsWith("image/") || /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/.test(s)) return "image";
4122
+ return null;
4123
+ }
4124
+ function probeVideoMedia(src, kind, timeout = 15e3) {
4125
+ return new Promise((resolve, reject) => {
4126
+ if (typeof document === "undefined") return reject(new Error("Media can only be probed in a browser."));
4127
+ const timer = setTimeout(() => reject(new Error(`"${src}" took too long to load.`)), timeout);
4128
+ const done = (probe) => {
4129
+ clearTimeout(timer);
4130
+ resolve(probe);
4131
+ };
4132
+ const failed = () => {
4133
+ clearTimeout(timer);
4134
+ reject(new Error(`"${src}" could not be loaded as ${kind}.`));
4135
+ };
4136
+ if (kind === "image") {
4137
+ const img = new Image();
4138
+ img.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });
4139
+ img.onerror = failed;
4140
+ img.src = src;
4141
+ return;
4142
+ }
4143
+ const el = document.createElement(kind);
4144
+ el.preload = "metadata";
4145
+ el.onloadedmetadata = () => {
4146
+ const duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1e3) : void 0;
4147
+ const v = el;
4148
+ done({ kind, ...duration ? { duration } : {}, ...kind === "video" && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {} });
4149
+ };
4150
+ el.onerror = failed;
4151
+ el.src = src;
4152
+ });
4153
+ }
4154
+ var KEY_LABELS = {
4155
+ opacity: { label: "Opacity", step: 0.05 },
4156
+ volume: { label: "Volume", step: 0.05 },
4157
+ x: { label: "X", step: 1 },
4158
+ y: { label: "Y", step: 1 },
4159
+ scale: { label: "Scale", step: 0.05 },
4160
+ rotation: { label: "Rotation", step: 1 }
4161
+ };
4162
+ var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? clip.transform?.[property] : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
4163
+ var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
4164
+ function videoKeyframeRows(project, clipId, time) {
4165
+ const at = findVideoClip(project, clipId);
4166
+ if (!at) return [];
4167
+ const { clip } = at;
4168
+ const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
4169
+ const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
4170
+ const audible = clip.type === "media" && videoAssetHasSound(asset);
4171
+ const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
4172
+ const clipTime = playheadInClip(clip, time);
4173
+ return properties.map((property) => {
4174
+ const prop = animatableOf(clip, property);
4175
+ const keys = isVideoKeyframes(prop) ? prop.keys : [];
4176
+ const limits = VIDEO_KEYFRAME_LIMITS[property];
4177
+ return {
4178
+ property,
4179
+ ...KEY_LABELS[property],
4180
+ value: Math.round(videoValueAt(prop, clipTime, limits.default) * 1e3) / 1e3,
4181
+ keyed: keys.length > 0,
4182
+ keys: keys.length,
4183
+ onKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),
4184
+ clipTime,
4185
+ ...limits.min !== void 0 ? { min: limits.min } : {},
4186
+ ...limits.max !== void 0 ? { max: limits.max } : {}
4187
+ };
4188
+ });
4189
+ }
4190
+ function plainValueEdit(clip, property, value) {
4191
+ if (!VIDEO_TRANSFORM_PROPERTIES.includes(property))
4192
+ return value === void 0 ? { op: "update_clip", clip: clip.id, remove: [property] } : { op: "update_clip", clip: clip.id, set: { [property]: value } };
4193
+ const transform = { ...clip.transform, [property]: value };
4194
+ if (value === void 0) delete transform[property];
4195
+ return Object.keys(transform).length ? { op: "update_clip", clip: clip.id, set: { transform } } : { op: "update_clip", clip: clip.id, remove: ["transform"] };
4196
+ }
4197
+ function videoKeyframeValueEdit(project, clipId, property, time, value) {
4198
+ const at = findVideoClip(project, clipId);
4199
+ if (!at) return null;
4200
+ const prop = animatableOf(at.clip, property);
4201
+ if (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);
4202
+ if (value === void 0) return null;
4203
+ return { op: "set_keyframes", clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };
4204
+ }
4205
+ function videoKeyframeToggleEdit(project, clipId, property, time) {
4206
+ const at = findVideoClip(project, clipId);
4207
+ if (!at) return null;
4208
+ const prop = animatableOf(at.clip, property);
4209
+ const t = playheadInClip(at.clip, time);
4210
+ const fallback = VIDEO_KEYFRAME_LIMITS[property].default;
4211
+ if (isVideoKeyframes(prop)) {
4212
+ const key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);
4213
+ if (key) {
4214
+ const rest = removeVideoKey(prop, key.t);
4215
+ return isVideoKeyframes(rest) ? { op: "set_keyframes", clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? void 0 : rest);
4216
+ }
4217
+ }
4218
+ const v = videoValueAt(prop, t, fallback);
4219
+ return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
4220
+ }
4221
+ function videoLutEdits(project, clips, lut) {
4222
+ const edits = [];
4223
+ for (const id of clips) {
4224
+ const at = findVideoClip(project, id);
4225
+ if (!at) continue;
4226
+ const { lut: current, ...rest } = at.clip.grade ?? {};
4227
+ if (!lut && !current) continue;
4228
+ const grade = normalizeVideoGrade(lut ? { ...rest, lut } : rest);
4229
+ edits.push(grade ? { op: "update_clip", clip: id, set: { grade } } : { op: "update_clip", clip: id, remove: ["grade"] });
4230
+ }
4231
+ return edits;
4232
+ }
4233
+
4234
+ // src/video-editor/tools.ts
4235
+ var round = (ms) => Math.round(ms * 10) / 10;
4236
+ var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
4237
+ var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
4238
+ var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
4239
+ var DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
4240
+ var object = (properties, required = [], description) => ({
4241
+ type: "object",
4242
+ ...description ? { description } : {},
4243
+ properties,
4244
+ ...required.length ? { required } : {},
4245
+ additionalProperties: false
4246
+ });
4247
+ var MS = (what) => ({ type: "number", minimum: 0, description: `${what}, in milliseconds.` });
4248
+ var CLIP_ID = { type: "string", minLength: 1, description: 'A clip id from get_video_project (e.g. "c1").' };
4249
+ var TRACK_ID = { type: "string", minLength: 1, description: 'A track id from get_video_project (e.g. "v1" or "a1").' };
4250
+ var ASSET_ID = { type: "string", minLength: 1, description: "An asset id from get_video_project." };
4251
+ var COLOR = (what) => ({ type: "string", minLength: 1, description: `${what}: a CSS colour such as "#ffcc00".` });
4252
+ var RIPPLE = {
4253
+ type: "boolean",
4254
+ description: "Push (or pull) later material along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap). When the track is sync-locked (the default), every other unlocked sync-locked track moves too: clips across the edit point are cut when time opens, and closing time is refused if another sync-locked track has a clip in that stretch."
4255
+ };
4256
+ var MODE = {
4257
+ type: "string",
4258
+ enum: [...VIDEO_PLACEMENT_MODES],
4259
+ description: 'What to do with clips already where it lands: "refuse" (default) fails with the next free place; "overwrite" cuts, trims or removes what is underneath on that track; "insert" cuts everything across the start and pushes later material along by its length on this track and every sync-locked track.'
4260
+ };
4261
+ var DELTA = (what) => ({ type: "number", description: `${what}, in milliseconds of timeline time: positive is later, negative earlier. It is clamped to what the media and neighbours allow; the result says how far it went.` });
4262
+ var RANGE_TRACKS = { type: "array", items: TRACK_ID, minItems: 1, uniqueItems: true, description: "Tracks to work on (default: every unlocked track). Locked tracks are refused." };
4263
+ var TRANSFORM = object(
4264
+ {
4265
+ x: { type: "number", description: "Offset of the centre to the right of the frame's centre, in pixels (default 0)." },
4266
+ y: { type: "number", description: "Offset of the centre below the frame's centre, in pixels (default 0)." },
4267
+ scale: { type: "number", minimum: 0, description: "Size after fitting (1 = fitted size, default 1)." },
4268
+ rotation: { type: "number", description: "Degrees clockwise (default 0)." }
4269
+ },
4270
+ [],
4271
+ "Where the picture sits after it is fitted."
4272
+ );
4273
+ var TRANSITION = object(
4274
+ {
4275
+ type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre)." },
4276
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none." },
4277
+ color: COLOR("For dip: the colour passed through (default black)"),
4278
+ direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left)." },
4279
+ audioCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic." }
4280
+ },
4281
+ ["type", "duration"],
4282
+ "A transition from the clip that ends where this one starts on the same track."
4283
+ );
4284
+ var LUT_SRC = { type: "string", minLength: 1, description: 'A .cube 3D LUT file the host can load and ffmpeg can read: a URL or path, often from the user (e.g. "luts/teal-orange.cube").' };
4285
+ var LUT_STRENGTH = { type: "number", minimum: 0, maximum: 1, description: "How much of the LUT's change to apply, 0 (none) to 1 (full, the default); 0.3-0.7 for a subtler look." };
4286
+ var GRADE = object(
4287
+ {
4288
+ ...Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
4289
+ lut: object({ src: LUT_SRC, strength: LUT_STRENGTH, name: { type: "string", minLength: 1, description: "A label for the look." } }, ["src"], "A 3D LUT applied after the other settings (set_video_lut checks the file first).")
4290
+ },
4291
+ [],
4292
+ "Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint), then a LUT. Leave a setting out for no change; the object replaces the clip's whole grade."
4293
+ );
4294
+ var ANIMATION = (when) => object(
4295
+ {
4296
+ effect: { type: "string", enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: "fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards." },
4297
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
4298
+ },
4299
+ ["effect"],
4300
+ when
4301
+ );
4302
+ var CLIP_COMMON = {
4303
+ name: { type: "string", description: "A label for the clip." },
4304
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
3313
4305
  transform: TRANSFORM,
3314
4306
  fit: { type: "string", enum: [...VIDEO_FITS], description: "How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours)." },
3315
4307
  grade: GRADE,
@@ -3523,14 +4515,18 @@ function createVideoEditorToolProvider(controller, options = {}) {
3523
4515
  annotations: { title: "Render a frame", ...READ },
3524
4516
  handler: async ({ time, times, columns, format = "svg" }) => {
3525
4517
  const p = project();
3526
- const svg = times ? videoContactSheetSvg(p, times, columns ? { columns } : {}) : videoProjectSvg(p, time ?? controller.getState().time);
4518
+ const lutSrcs = [...new Set(p.tracks.flatMap((t) => t.clips.map((c) => c.grade?.lut?.src).filter((x) => !!x)))];
4519
+ const missing = (await Promise.all(lutSrcs.map((src) => controller.luts.load(src).then(() => null, (e) => e.message)))).filter((m) => !!m);
4520
+ const svgOptions = { lutFor: (src) => controller.luts.peek(src), ...columns ? { columns } : {} };
4521
+ const svg = times ? videoContactSheetSvg(p, times, svgOptions) : videoProjectSvg(p, time ?? controller.getState().time, svgOptions);
4522
+ const notes = [...missing.map((m) => `${m}; that LUT is left out of the picture.`), ...lutSrcs.length > missing.length ? ["LUTs are drawn approximately in SVG (curves and a colour matrix); the preview and export apply them exactly."] : []];
3527
4523
  if (format === "png") {
3528
4524
  if (!options.rasterize) throw new Error('This host cannot make PNG images; call render_video_frame with format "svg".');
3529
4525
  const size = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
3530
4526
  const image = await options.rasterize(svg, { width: Number(size[1]), height: Number(size[2]) });
3531
- return { content: [{ type: "image", data: image.data, mimeType: image.mimeType }] };
4527
+ return { content: [{ type: "image", data: image.data, mimeType: image.mimeType }, ...notes.length ? [{ type: "text", text: notes.join(" ") }] : []] };
3532
4528
  }
3533
- return { svg };
4529
+ return { svg, ...notes.length ? { notes } : {} };
3534
4530
  }
3535
4531
  };
3536
4532
  const LOUDNESS_TARGET = {
@@ -3767,7 +4763,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
3767
4763
  const applyGradePreset = {
3768
4764
  name: "apply_video_grade_preset",
3769
4765
  title: "Apply a colour look",
3770
- description: "Gives clips a named colour look at a strength (one undo step), replacing their grades: warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip's resulting grade; tweak it with update_video_clip set.grade.",
4766
+ description: "Gives clips a named colour look at a strength (one undo step), replacing their grade settings (a LUT stays): warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip's resulting grade; tweak it with update_video_clip set.grade.",
3771
4767
  inputSchema: object(
3772
4768
  {
3773
4769
  clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to grade." },
@@ -3779,8 +4775,10 @@ function createVideoEditorToolProvider(controller, options = {}) {
3779
4775
  annotations: { title: "Apply a colour look", ...WRITE_IDEMPOTENT },
3780
4776
  handler: ({ clips, preset, amount }) => {
3781
4777
  gradeTargets(clips);
3782
- const grade = videoGradePreset(preset, amount ?? 1);
3783
- const edits = clips.map((clip) => grade ? { op: "update_clip", clip, set: { grade } } : { op: "update_clip", clip, remove: ["grade"] });
4778
+ const edits = clips.map((clip) => {
4779
+ const grade = videoGradeLook(findVideoClip(project(), clip).clip.grade, preset, amount ?? 1);
4780
+ return grade ? { op: "update_clip", clip, set: { grade } } : { op: "update_clip", clip, remove: ["grade"] };
4781
+ });
3784
4782
  return apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
3785
4783
  }
3786
4784
  };
@@ -3797,6 +4795,43 @@ function createVideoEditorToolProvider(controller, options = {}) {
3797
4795
  return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
3798
4796
  }
3799
4797
  };
4798
+ const setLut = {
4799
+ name: "set_video_lut",
4800
+ title: "Apply a LUT",
4801
+ description: "Gives clips a 3D LUT (a .cube file) at a strength, applied after the rest of their grade (one undo step); their other grade settings stay. Reads the file first and fails, changing nothing, if it cannot be loaded or is not a 3D .cube. Returns the LUT's title and size and each clip's resulting grade. Remove it with remove_video_lut.",
4802
+ inputSchema: object(
4803
+ {
4804
+ clips: { type: "array", items: CLIP_ID, minItems: 1, uniqueItems: true, description: "Clips on video tracks to grade." },
4805
+ src: LUT_SRC,
4806
+ strength: LUT_STRENGTH,
4807
+ name: { type: "string", minLength: 1, description: "A label for the look (default the file's TITLE)." }
4808
+ },
4809
+ ["clips", "src"]
4810
+ ),
4811
+ annotations: { title: "Apply a LUT", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
4812
+ handler: async ({ clips, src, strength, name }) => {
4813
+ if (strength === 0) throw new Error("A strength of 0 changes nothing; pass a strength above 0, or remove the LUT with remove_video_lut.");
4814
+ gradeTargets(clips);
4815
+ const lut = await controller.luts.load(src);
4816
+ const label = name ?? lut.title;
4817
+ const setting = { src, ...strength !== void 0 && strength !== 1 ? { strength } : {}, ...label ? { name: label } : {} };
4818
+ return apply(videoLutEdits(project(), clips, setting), "Apply LUT", (r) => ({ lut: { src, ...lut.title ? { title: lut.title } : {}, size: lut.size }, clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
4819
+ }
4820
+ };
4821
+ const removeLut = {
4822
+ name: "remove_video_lut",
4823
+ title: "Remove a LUT",
4824
+ description: "Takes the LUT off clips' grades (one undo step), keeping their other grade settings. Fails if none of them has one. Returns each clip's resulting grade.",
4825
+ inputSchema: object({ clips: { type: "array", items: CLIP_ID, minItems: 1, uniqueItems: true, description: "Clips whose LUT to remove." } }, ["clips"]),
4826
+ annotations: { title: "Remove a LUT", ...WRITE_IDEMPOTENT },
4827
+ handler: ({ clips }) => {
4828
+ gradeTargets(clips);
4829
+ const p = project();
4830
+ const withLut = clips.filter((id) => findVideoClip(p, id).clip.grade?.lut);
4831
+ if (!withLut.length) throw new Error(`None of ${clips.map((c) => `"${c}"`).join(", ")} has a LUT; get_video_project shows each clip's grade.`);
4832
+ return apply(videoLutEdits(p, withLut, null), "Remove LUT", (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
4833
+ }
4834
+ };
3800
4835
  const TRANSCRIPT_TRACK = { ...TRACK_ID, description: "Track whose words to read (default: the first audio track with a transcribed clip, else the first video track with one)." };
3801
4836
  const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
3802
4837
  const timelineWords = (track) => {
@@ -4199,8 +5234,47 @@ function createVideoEditorToolProvider(controller, options = {}) {
4199
5234
  return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
4200
5235
  }
4201
5236
  };
4202
- const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
4203
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
5237
+ const pixelsAt = async (time) => {
5238
+ if (options.framePixels) {
5239
+ const px = await options.framePixels(time);
5240
+ if (!px) throw new Error(`The host could not render the frame at ${Math.round(time)} ms; use render_video_frame to look at it instead.`);
5241
+ return px;
5242
+ }
5243
+ const feed = controller.previewPixels;
5244
+ if (!feed.attached) throw new Error("No preview is showing this project, so there are no pixels to measure. The host must show a <VideoPreview> for this editor (or pass framePixels to the tool provider); until then use get_video_frame and render_video_frame.");
5245
+ const state = controller.getState();
5246
+ if (Math.abs(time - state.time) < 0.5) return (await feed.next(state.time, options.framePixelsTimeout)).pixels;
5247
+ const { time: before, playing } = state;
5248
+ if (playing) controller.pause();
5249
+ controller.seek(time);
5250
+ try {
5251
+ return (await feed.next(controller.getState().time, options.framePixelsTimeout)).pixels;
5252
+ } finally {
5253
+ controller.seek(before);
5254
+ if (playing) controller.play();
5255
+ }
5256
+ };
5257
+ const getScopes = {
5258
+ name: "get_video_scopes",
5259
+ title: "Measure a frame with scopes",
5260
+ description: "Measures the rendered picture at a moment as colour scopes would, without changing the project: luma (0 = black, 1 = white) mean, median, min, max and 1st / 99th percentiles; the share of pixels crushed to black or blown to white (and per channel); saturation; the average colour (a cast shows as a tint); a coarse luma and RGB histogram; the luma spread of vertical strips left to right (a waveform); the main hue and strength on a vectorscope; and notes on what to fix. Use it before and after grading (update_video_clip grade, apply_video_grade_preset) to check exposure and colour. Needs the pixels the host draws: fails with a reason when no preview is showing.",
5261
+ inputSchema: object({
5262
+ time: MS("Time from the start to measure (default: the playhead). Another time is shown briefly and the playhead put back"),
5263
+ bins: { type: "integer", minimum: 2, maximum: 64, description: "Histogram steps, darkest first (default 16)." },
5264
+ columns: { type: "integer", minimum: 1, maximum: 32, description: "Waveform strips across the picture, left to right (default 8)." }
5265
+ }),
5266
+ annotations: { title: "Measure a frame with scopes", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
5267
+ handler: async ({ time, bins, columns }) => {
5268
+ const p = project();
5269
+ const end = videoProjectDuration(p);
5270
+ const t = time ?? controller.getState().time;
5271
+ if (time !== void 0 && time > end) throw new Error(`Time ${time} ms is past the end of the project (${end} ms).`);
5272
+ const pixels = await pixelsAt(t);
5273
+ return { time: t, timecode: formatVideoTimecode(t, p.fps), width: pixels.width, height: pixels.height, ...summarizeVideoScopes(pixels, { bins, columns }) };
5274
+ }
5275
+ };
5276
+ const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
5277
+ const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setSpeedRamp, addFreeze, removeRamp, setTranscript, removeRanges, applyGradePreset, copyGrade, setLut, removeLut, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
4204
5278
  return {
4205
5279
  id: options.id ?? "video",
4206
5280
  namespace: options.namespace,
@@ -4217,227 +5291,6 @@ function createVideoEditorToolProvider(controller, options = {}) {
4217
5291
  }
4218
5292
  };
4219
5293
  }
4220
-
4221
- // src/video-editor/ui.ts
4222
- var STEPS = [40, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5];
4223
- function videoRulerTicks(duration, pxPerSecond, fps) {
4224
- const pxPerMs = pxPerSecond / 1e3;
4225
- const major = STEPS.find((s) => s * pxPerMs >= 80) ?? STEPS[STEPS.length - 1];
4226
- const minor = STEPS.slice().reverse().find((s) => s < major && major % s === 0 && s * pxPerMs >= 10);
4227
- const step = minor ?? major;
4228
- const ticks = [];
4229
- for (let t = 0; t <= duration + 1e-6; t += step) {
4230
- const isMajor = Math.abs(t / major - Math.round(t / major)) < 1e-9;
4231
- ticks.push({ time: t, x: t * pxPerMs, major: isMajor, ...isMajor ? { label: formatVideoTimecode(t, fps).replace(/^00:/, "") } : {} });
4232
- }
4233
- return ticks;
4234
- }
4235
- function videoClipShowsWaveform(clip, asset, mode = "audio") {
4236
- if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
4237
- return mode === "all" || asset.kind === "audio";
4238
- }
4239
- var VIDEO_CLIP_ANIMATION_LABELS = {
4240
- fade: "Fade",
4241
- rise: "Rise",
4242
- drop: "Drop",
4243
- "slide-left": "Slide left",
4244
- "slide-right": "Slide right",
4245
- zoom: "Zoom",
4246
- pop: "Pop"
4247
- };
4248
- var VIDEO_TRANSITION_LABELS = {
4249
- crossfade: "Crossfade",
4250
- dip: "Dip to colour",
4251
- wipe: "Wipe",
4252
- push: "Push",
4253
- slide: "Slide",
4254
- iris: "Iris"
4255
- };
4256
- var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
4257
- var VIDEO_BLEND_MODE_LABELS = {
4258
- normal: "Normal",
4259
- multiply: "Multiply",
4260
- screen: "Screen",
4261
- overlay: "Overlay",
4262
- darken: "Darken",
4263
- lighten: "Lighten",
4264
- "color-dodge": "Colour dodge",
4265
- "color-burn": "Colour burn",
4266
- "hard-light": "Hard light",
4267
- "soft-light": "Soft light",
4268
- difference: "Difference",
4269
- exclusion: "Exclusion",
4270
- add: "Add"
4271
- };
4272
- var VIDEO_FADE_CURVE_LABELS = {
4273
- linear: "Linear",
4274
- "equal-power": "Equal power",
4275
- "s-curve": "S-curve",
4276
- exponential: "Exponential",
4277
- logarithmic: "Logarithmic"
4278
- };
4279
- function videoClipTint(clip, asset) {
4280
- if (clip.type === "text") return "#a855f7";
4281
- if (clip.type === "color") return clip.color;
4282
- if (!asset) return "#ef4444";
4283
- return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
4284
- }
4285
- function videoClipLabel(clip, asset) {
4286
- if (clip.name) return clip.name;
4287
- if (clip.type === "text") return clip.text.split("\n")[0];
4288
- if (clip.type === "color") return clip.color;
4289
- const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
4290
- const speed = videoClipSpeed(clip);
4291
- return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
4292
- }
4293
- function planVideoClipDrag(project, input) {
4294
- const at = findVideoClip(project, input.clip);
4295
- if (!at) return null;
4296
- const { clip, track } = at;
4297
- const snap = input.snap ?? ((t) => t);
4298
- if (input.mode === "move") {
4299
- const raw = Math.max(0, clip.start + input.delta);
4300
- const head = snap(raw) - raw;
4301
- const tail = snap(raw + clip.duration) - (raw + clip.duration);
4302
- const shifts = [head, tail].filter((d) => Math.abs(d) > 1e-9);
4303
- const start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => Math.abs(b) < Math.abs(a) ? b : a) : 0));
4304
- const target = input.track ?? track.id;
4305
- const moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;
4306
- const mode = input.placement && input.placement !== "refuse" ? { mode: input.placement } : {};
4307
- return { start, duration: clip.duration, track: target, edit: moved ? { op: "move_clip", clip: clip.id, start, ...target !== track.id ? { track: target } : {}, ...mode } : null };
4308
- }
4309
- const minimum = 1e3 / project.fps;
4310
- if (input.mode === "trim-start") {
4311
- const to2 = Math.min(snap(Math.max(0, clip.start + input.delta)), videoClipEnd(clip) - minimum);
4312
- const changed2 = Math.abs(to2 - clip.start) > 1e-6;
4313
- const plan = input.ripple ? { start: clip.start, duration: clip.duration - (to2 - clip.start) } : { start: to2, duration: videoClipEnd(clip) - to2 };
4314
- return { ...plan, track: track.id, edit: changed2 ? { op: "trim_clip", clip: clip.id, edge: "start", to: to2, ...input.ripple ? { ripple: true } : {} } : null };
4315
- }
4316
- const to = Math.max(snap(videoClipEnd(clip) + input.delta), clip.start + minimum);
4317
- const changed = Math.abs(to - videoClipEnd(clip)) > 1e-6;
4318
- return { start: clip.start, duration: to - clip.start, track: track.id, edit: changed ? { op: "trim_clip", clip: clip.id, edge: "end", to, ...input.ripple ? { ripple: true } : {} } : null };
4319
- }
4320
- function videoDropTracks(project, clipId) {
4321
- const at = findVideoClip(project, clipId);
4322
- if (!at) return [];
4323
- return project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);
4324
- }
4325
- function videoDragLabel(edit) {
4326
- if (edit.op === "move_clip") return edit.mode === "insert" ? "Insert clip" : edit.mode === "overwrite" ? "Overwrite clip" : "Move clip";
4327
- return edit.op === "trim_clip" && edit.ripple ? "Ripple trim" : "Trim clip";
4328
- }
4329
- function videoAssetDropEdit(project, assetId, options = {}) {
4330
- const asset = videoAssetMap(project).get(assetId);
4331
- if (!asset) return null;
4332
- const kind = asset.kind === "audio" ? "audio" : "video";
4333
- const track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;
4334
- if (!track) return null;
4335
- return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {}, ...options.mode && options.mode !== "refuse" ? { mode: options.mode } : {} };
4336
- }
4337
- function videoAssetKindOf(typeOrName) {
4338
- const s = typeOrName.toLowerCase().replace(/[?#].*$/, "");
4339
- if (s.startsWith("video/") || /\.(mp4|m4v|mov|webm|ogv|mkv)$/.test(s)) return "video";
4340
- if (s.startsWith("audio/") || /\.(mp3|m4a|aac|wav|ogg|oga|flac|opus)$/.test(s)) return "audio";
4341
- if (s.startsWith("image/") || /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/.test(s)) return "image";
4342
- return null;
4343
- }
4344
- function probeVideoMedia(src, kind, timeout = 15e3) {
4345
- return new Promise((resolve, reject) => {
4346
- if (typeof document === "undefined") return reject(new Error("Media can only be probed in a browser."));
4347
- const timer = setTimeout(() => reject(new Error(`"${src}" took too long to load.`)), timeout);
4348
- const done = (probe) => {
4349
- clearTimeout(timer);
4350
- resolve(probe);
4351
- };
4352
- const failed = () => {
4353
- clearTimeout(timer);
4354
- reject(new Error(`"${src}" could not be loaded as ${kind}.`));
4355
- };
4356
- if (kind === "image") {
4357
- const img = new Image();
4358
- img.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });
4359
- img.onerror = failed;
4360
- img.src = src;
4361
- return;
4362
- }
4363
- const el = document.createElement(kind);
4364
- el.preload = "metadata";
4365
- el.onloadedmetadata = () => {
4366
- const duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1e3) : void 0;
4367
- const v = el;
4368
- done({ kind, ...duration ? { duration } : {}, ...kind === "video" && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {} });
4369
- };
4370
- el.onerror = failed;
4371
- el.src = src;
4372
- });
4373
- }
4374
- var KEY_LABELS = {
4375
- opacity: { label: "Opacity", step: 0.05 },
4376
- volume: { label: "Volume", step: 0.05 },
4377
- x: { label: "X", step: 1 },
4378
- y: { label: "Y", step: 1 },
4379
- scale: { label: "Scale", step: 0.05 },
4380
- rotation: { label: "Rotation", step: 1 }
4381
- };
4382
- var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? clip.transform?.[property] : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
4383
- var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
4384
- function videoKeyframeRows(project, clipId, time) {
4385
- const at = findVideoClip(project, clipId);
4386
- if (!at) return [];
4387
- const { clip } = at;
4388
- const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
4389
- const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
4390
- const audible = clip.type === "media" && videoAssetHasSound(asset);
4391
- const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
4392
- const clipTime = playheadInClip(clip, time);
4393
- return properties.map((property) => {
4394
- const prop = animatableOf(clip, property);
4395
- const keys = isVideoKeyframes(prop) ? prop.keys : [];
4396
- const limits = VIDEO_KEYFRAME_LIMITS[property];
4397
- return {
4398
- property,
4399
- ...KEY_LABELS[property],
4400
- value: Math.round(videoValueAt(prop, clipTime, limits.default) * 1e3) / 1e3,
4401
- keyed: keys.length > 0,
4402
- keys: keys.length,
4403
- onKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),
4404
- clipTime,
4405
- ...limits.min !== void 0 ? { min: limits.min } : {},
4406
- ...limits.max !== void 0 ? { max: limits.max } : {}
4407
- };
4408
- });
4409
- }
4410
- function plainValueEdit(clip, property, value) {
4411
- if (!VIDEO_TRANSFORM_PROPERTIES.includes(property))
4412
- return value === void 0 ? { op: "update_clip", clip: clip.id, remove: [property] } : { op: "update_clip", clip: clip.id, set: { [property]: value } };
4413
- const transform = { ...clip.transform, [property]: value };
4414
- if (value === void 0) delete transform[property];
4415
- return Object.keys(transform).length ? { op: "update_clip", clip: clip.id, set: { transform } } : { op: "update_clip", clip: clip.id, remove: ["transform"] };
4416
- }
4417
- function videoKeyframeValueEdit(project, clipId, property, time, value) {
4418
- const at = findVideoClip(project, clipId);
4419
- if (!at) return null;
4420
- const prop = animatableOf(at.clip, property);
4421
- if (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);
4422
- if (value === void 0) return null;
4423
- return { op: "set_keyframes", clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };
4424
- }
4425
- function videoKeyframeToggleEdit(project, clipId, property, time) {
4426
- const at = findVideoClip(project, clipId);
4427
- if (!at) return null;
4428
- const prop = animatableOf(at.clip, property);
4429
- const t = playheadInClip(at.clip, time);
4430
- const fallback = VIDEO_KEYFRAME_LIMITS[property].default;
4431
- if (isVideoKeyframes(prop)) {
4432
- const key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);
4433
- if (key) {
4434
- const rest = removeVideoKey(prop, key.t);
4435
- return isVideoKeyframes(rest) ? { op: "set_keyframes", clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? void 0 : rest);
4436
- }
4437
- }
4438
- const v = videoValueAt(prop, t, fallback);
4439
- return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
4440
- }
4441
5294
  export {
4442
5295
  VIDEO_BLEND_MODES,
4443
5296
  VIDEO_BLEND_MODE_LABELS,
@@ -4459,19 +5312,26 @@ export {
4459
5312
  VIDEO_ISSUE_CODES,
4460
5313
  VIDEO_KEYFRAME_LIMITS,
4461
5314
  VIDEO_KEYFRAME_PROPERTIES,
5315
+ VIDEO_LUT_MAX_SIZE,
4462
5316
  VIDEO_PEAK_BLOCK,
4463
5317
  VIDEO_PLACEMENT_MODES,
5318
+ VIDEO_SCOPE_KINDS,
4464
5319
  VIDEO_TIME_EPSILON,
4465
5320
  VIDEO_TRANSFORM_PROPERTIES,
4466
5321
  VIDEO_TRANSITION_LABELS,
4467
5322
  VIDEO_TRANSITION_TYPES,
5323
+ VIDEO_VECTORSCOPE_TARGETS,
4468
5324
  VIDEO_WIPE_DIRECTIONS,
4469
5325
  VideoAudioPeaksCache,
4470
5326
  VideoEditorController,
5327
+ VideoLutCache,
4471
5328
  VideoPreviewEngine,
5329
+ VideoPreviewPixels,
4472
5330
  allVideoClipIds,
4473
5331
  applyVideoEdits,
4474
5332
  applyVideoGrade,
5333
+ applyVideoLut,
5334
+ applyVideoLutToPixels,
4475
5335
  buildVideoAudioPeaks,
4476
5336
  checkVideoProject,
4477
5337
  checkVideoTimeRemap,
@@ -4495,12 +5355,15 @@ export {
4495
5355
  mergeVideoRanges,
4496
5356
  nextFreeVideoSlot,
4497
5357
  normalizeVideoGrade,
5358
+ normalizeVideoGradeLut,
4498
5359
  normalizeVideoWord,
4499
5360
  parseVideoAnimatable,
4500
5361
  parseVideoAsset,
4501
5362
  parseVideoClip,
4502
5363
  parseVideoClipAnimation,
5364
+ parseVideoCubeLut,
4503
5365
  parseVideoGrade,
5366
+ parseVideoGradeLut,
4504
5367
  parseVideoKeyframes,
4505
5368
  parseVideoLoudnessMeasurement,
4506
5369
  parseVideoMarker,
@@ -4514,6 +5377,7 @@ export {
4514
5377
  placeVideoRect,
4515
5378
  planVideoClipDrag,
4516
5379
  probeVideoMedia,
5380
+ readVideoScopesPanel,
4517
5381
  removeVideoKey,
4518
5382
  retimeVideoAnimatable,
4519
5383
  setVideoKey,
@@ -4522,6 +5386,7 @@ export {
4522
5386
  sortVideoKeys,
4523
5387
  sortedVideoClips,
4524
5388
  splitVideoAnimatable,
5389
+ summarizeVideoScopes,
4525
5390
  toVideoEdl,
4526
5391
  toVideoFfmpeg,
4527
5392
  toVideoLoudnessMeasure,
@@ -4533,6 +5398,7 @@ export {
4533
5398
  videoAudioPeakRange,
4534
5399
  videoAudioPeakSpans,
4535
5400
  videoAudioPeaksDuration,
5401
+ videoChroma,
4536
5402
  videoClipAfter,
4537
5403
  videoClipAnimated,
4538
5404
  videoClipAnimationProperties,
@@ -4558,6 +5424,8 @@ export {
4558
5424
  videoDropTracks,
4559
5425
  videoEaseBezier,
4560
5426
  videoEaseProgress,
5427
+ videoExposureNotes,
5428
+ videoExposureStats,
4561
5429
  videoFadeCurveGain,
4562
5430
  videoFrameAt,
4563
5431
  videoFrameDuration,
@@ -4567,12 +5435,15 @@ export {
4567
5435
  videoGradeCssFilter,
4568
5436
  videoGradeFfmpegFilters,
4569
5437
  videoGradeGainsFilterId,
5438
+ videoGradeLook,
4570
5439
  videoGradePreset,
4571
5440
  videoGradeStepMatrix,
4572
5441
  videoGradeStepSvgValues,
4573
5442
  videoGradeSteps,
4574
5443
  videoGradeSvgDefs,
4575
5444
  videoGradeSvgFilter,
5445
+ videoHistogram,
5446
+ videoHistogramImage,
4576
5447
  videoHourFrames,
4577
5448
  videoIrisRadius,
4578
5449
  videoIssueKey,
@@ -4582,12 +5453,20 @@ export {
4582
5453
  videoKeyframeToggleEdit,
4583
5454
  videoKeyframeValueEdit,
4584
5455
  videoKeysOf,
5456
+ videoLuma,
5457
+ videoLumaWaveform,
5458
+ videoLutEdits,
5459
+ videoLutFromFunction,
5460
+ videoLutSvgApproximation,
5461
+ videoLutSvgPrimitives,
5462
+ videoLutToCube,
4585
5463
  videoOverlap,
4586
5464
  videoPeakDb,
4587
5465
  videoProjectDuration,
4588
5466
  videoProjectSvg,
4589
5467
  videoRemoveRangesEdits,
4590
5468
  videoRulerTicks,
5469
+ videoScopeImage,
4591
5470
  videoSlowMotionRamp,
4592
5471
  videoSnapPoints,
4593
5472
  videoSpeedRampEdit,
@@ -4606,6 +5485,11 @@ export {
4606
5485
  videoTransitionEase,
4607
5486
  videoTransitionHasDirection,
4608
5487
  videoValueAt,
5488
+ videoVectorscope,
5489
+ videoVectorscopeAngle,
5490
+ videoVectorscopeImage,
5491
+ videoVectorscopePoint,
5492
+ videoWaveformImage,
4609
5493
  videoWaveformPath,
4610
5494
  videoWipeRect,
4611
5495
  videoWordAt,