@pixerate/editor 0.87.0 → 0.89.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.
- package/THIRD_PARTY_NOTICES.md +8 -2
- package/dist/agent/index.cjs +28 -28
- package/dist/agent/index.js +3 -3
- package/dist/canvas/index.cjs +3 -3
- package/dist/canvas/index.cjs.map +1 -1
- package/dist/canvas/index.js +16 -16
- package/dist/{chunk-TYOAJDFD.cjs → chunk-66IMF6OD.cjs} +3 -3
- package/dist/{chunk-TYOAJDFD.cjs.map → chunk-66IMF6OD.cjs.map} +1 -1
- package/dist/chunk-D4ZCU72J.js +604 -0
- package/dist/chunk-D4ZCU72J.js.map +1 -0
- package/dist/{chunk-IMSX4IVG.js → chunk-FWEO3HSL.js} +4 -4
- package/dist/chunk-H543GD6T.cjs +1696 -0
- package/dist/chunk-H543GD6T.cjs.map +1 -0
- package/dist/{chunk-ROXFP2LQ.cjs → chunk-HNDSPZ5T.cjs} +4 -4
- package/dist/{chunk-ROXFP2LQ.cjs.map → chunk-HNDSPZ5T.cjs.map} +1 -1
- package/dist/{chunk-MXF4DP2C.cjs → chunk-J3B45HTA.cjs} +74 -74
- package/dist/{chunk-MXF4DP2C.cjs.map → chunk-J3B45HTA.cjs.map} +1 -1
- package/dist/{chunk-GNO5PEMH.js → chunk-OK7T2LHK.js} +2 -2
- package/dist/chunk-P5TPUNBZ.js +1696 -0
- package/dist/chunk-P5TPUNBZ.js.map +1 -0
- package/dist/chunk-PDCITYBT.cjs +416 -0
- package/dist/chunk-PDCITYBT.cjs.map +1 -0
- package/dist/{chunk-RKJDBXTY.cjs → chunk-T4XWVPJK.cjs} +3 -3
- package/dist/{chunk-RKJDBXTY.cjs.map → chunk-T4XWVPJK.cjs.map} +1 -1
- package/dist/{chunk-ZQNDSUZO.js → chunk-UKI64KUD.js} +6 -6
- package/dist/{chunk-SVW2UNUC.js → chunk-WON34HKM.js} +2 -2
- package/dist/chunk-YLB2WVNA.js +416 -0
- package/dist/chunk-YLB2WVNA.js.map +1 -0
- package/dist/chunk-ZOOEX3WT.cjs +604 -0
- package/dist/chunk-ZOOEX3WT.cjs.map +1 -0
- package/dist/extensions/index.cjs +6 -6
- package/dist/extensions/index.cjs.map +1 -1
- package/dist/extensions/index.js +5 -5
- package/dist/image-editor/index.cjs +4387 -690
- package/dist/image-editor/index.cjs.map +1 -1
- package/dist/image-editor/index.d.cts +1677 -590
- package/dist/image-editor/index.d.ts +1677 -590
- package/dist/image-editor/index.js +4273 -576
- package/dist/image-editor/index.js.map +1 -1
- package/dist/index.cjs +27 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -10
- package/dist/paint/index.cjs +38 -0
- package/dist/paint/index.cjs.map +1 -0
- package/dist/paint/index.d.cts +167 -0
- package/dist/paint/index.d.ts +167 -0
- package/dist/paint/index.js +38 -0
- package/dist/paint/index.js.map +1 -0
- package/dist/raster/index.cjs +121 -0
- package/dist/raster/index.cjs.map +1 -0
- package/dist/raster/index.d.cts +365 -0
- package/dist/raster/index.d.ts +365 -0
- package/dist/raster/index.js +121 -0
- package/dist/raster/index.js.map +1 -0
- package/dist/serializers/index.cjs +4 -4
- package/dist/serializers/index.cjs.map +1 -1
- package/dist/serializers/index.js +3 -3
- package/dist/surface-fJhkmNQn.d.cts +181 -0
- package/dist/surface-fJhkmNQn.d.ts +181 -0
- package/dist/video-editor/index.cjs +930 -503
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +158 -11
- package/dist/video-editor/index.d.ts +158 -11
- package/dist/video-editor/index.js +841 -414
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +13 -2
- package/skills/pixerate-image-editor/SKILL.md +111 -11
- package/skills/pixerate-video-editor/SKILL.md +11 -2
- /package/dist/{chunk-IMSX4IVG.js.map → chunk-FWEO3HSL.js.map} +0 -0
- /package/dist/{chunk-GNO5PEMH.js.map → chunk-OK7T2LHK.js.map} +0 -0
- /package/dist/{chunk-ZQNDSUZO.js.map → chunk-UKI64KUD.js.map} +0 -0
- /package/dist/{chunk-SVW2UNUC.js.map → chunk-WON34HKM.js.map} +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1801
|
-
var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI *
|
|
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 =
|
|
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:
|
|
1838
|
-
if (tail && time >= videoClipEnd(clip) - tail) a.outgoing = { progress:
|
|
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:
|
|
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:
|
|
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 };
|
|
@@ -2685,7 +2966,7 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
2685
2966
|
if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
|
|
2686
2967
|
if (layer.blend) attrs.push(`style="mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}"`);
|
|
2687
2968
|
if (layer.grade) {
|
|
2688
|
-
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));
|
|
2689
2970
|
attrs.push(`filter="url(#g${i})"`);
|
|
2690
2971
|
}
|
|
2691
2972
|
if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
|
|
@@ -2748,69 +3029,96 @@ function videoContactSheetSvg(project, times, options = {}) {
|
|
|
2748
3029
|
});
|
|
2749
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>`;
|
|
2750
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
|
+
}
|
|
2751
3088
|
function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
|
|
2752
3089
|
ctx.save();
|
|
2753
3090
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
2754
3091
|
ctx.globalAlpha = 1;
|
|
2755
3092
|
ctx.fillStyle = frame.background;
|
|
2756
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));
|
|
2757
3096
|
for (const layer of frame.layers) {
|
|
2758
|
-
const
|
|
3097
|
+
const setting = layer.grade?.lut;
|
|
3098
|
+
const lut = setting ? options.lutFor?.(setting.src) : null;
|
|
3099
|
+
const scratch = lut ? options.scratch?.(cw, ch) : null;
|
|
2759
3100
|
ctx.save();
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
const
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
ctx.
|
|
2775
|
-
|
|
2776
|
-
ctx.
|
|
2777
|
-
}
|
|
2778
|
-
if (layer.wipe) {
|
|
2779
|
-
const r = videoWipeRect(rect, layer.wipe);
|
|
2780
|
-
ctx.beginPath();
|
|
2781
|
-
ctx.rect(r.x, r.y, r.width, r.height);
|
|
2782
|
-
ctx.clip();
|
|
2783
|
-
}
|
|
2784
|
-
if (layer.type === "color") {
|
|
2785
|
-
ctx.fillStyle = layer.color ?? "#000000";
|
|
2786
|
-
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
2787
|
-
} else if (layer.type === "text" && layer.text) {
|
|
2788
|
-
const t = layer.text;
|
|
2789
|
-
const lines = t.text.split("\n");
|
|
2790
|
-
const lh = t.fontSize * 1.2;
|
|
2791
|
-
const cx = rect.x + rect.width / 2;
|
|
2792
|
-
const top = rect.y + rect.height / 2 - lh * (lines.length - 1) / 2;
|
|
2793
|
-
ctx.font = `${t.fontWeight} ${t.fontSize}px ${t.fontFamily}`;
|
|
2794
|
-
ctx.textAlign = "center";
|
|
2795
|
-
ctx.textBaseline = "middle";
|
|
2796
|
-
if (t.background) {
|
|
2797
|
-
const w = Math.max(...lines.map((l) => ctx.measureText(l).width)) + t.fontSize;
|
|
2798
|
-
const h = lh * lines.length + t.fontSize * 0.4;
|
|
2799
|
-
ctx.fillStyle = t.background;
|
|
2800
|
-
ctx.fillRect(cx - w / 2, rect.y + rect.height / 2 - h / 2, w, h);
|
|
2801
|
-
}
|
|
2802
|
-
ctx.fillStyle = t.color;
|
|
2803
|
-
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);
|
|
2804
3118
|
} else {
|
|
2805
|
-
|
|
2806
|
-
if (
|
|
2807
|
-
|
|
2808
|
-
ctx.fillStyle = "#334155";
|
|
2809
|
-
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
2810
|
-
ctx.strokeStyle = "#94a3b8";
|
|
2811
|
-
ctx.lineWidth = 2;
|
|
2812
|
-
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
2813
|
-
}
|
|
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);
|
|
2814
3122
|
}
|
|
2815
3123
|
ctx.restore();
|
|
2816
3124
|
}
|
|
@@ -3088,7 +3396,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3088
3396
|
`enable='between(t,${sec(clip.start)},${sec(end)})'`
|
|
3089
3397
|
];
|
|
3090
3398
|
if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
|
|
3091
|
-
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.`);
|
|
3092
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.`);
|
|
3093
3401
|
graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
|
|
3094
3402
|
cur = next;
|
|
@@ -3108,6 +3416,20 @@ function buildFfmpeg(project, options, measure) {
|
|
|
3108
3416
|
}
|
|
3109
3417
|
const grade = videoGradeFfmpegFilters(clip.grade);
|
|
3110
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
|
+
}
|
|
3111
3433
|
chain.push("format=yuva420p");
|
|
3112
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)'`);
|
|
3113
3435
|
else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
|
|
@@ -3225,6 +3547,9 @@ var VideoPreviewEngine = class {
|
|
|
3225
3547
|
this.canvas = canvas2;
|
|
3226
3548
|
this.ctx = options.context !== void 0 ? options.context : canvas2.getContext?.("2d") ?? null;
|
|
3227
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
|
+
};
|
|
3228
3553
|
const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
|
|
3229
3554
|
this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
|
|
3230
3555
|
};
|
|
@@ -3246,6 +3571,9 @@ var VideoPreviewEngine = class {
|
|
|
3246
3571
|
pixelsTarget;
|
|
3247
3572
|
drawn = null;
|
|
3248
3573
|
complete = true;
|
|
3574
|
+
lutCache;
|
|
3575
|
+
unsubscribeLuts;
|
|
3576
|
+
scratchCanvas;
|
|
3249
3577
|
/** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
|
|
3250
3578
|
readPixels() {
|
|
3251
3579
|
const ctx = this.ctx;
|
|
@@ -3362,11 +3690,33 @@ var VideoPreviewEngine = class {
|
|
|
3362
3690
|
if (!source) this.complete = false;
|
|
3363
3691
|
return source;
|
|
3364
3692
|
};
|
|
3365
|
-
drawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
|
|
3693
|
+
drawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter, lutFor: this.lutFor, scratch: this.scratch });
|
|
3366
3694
|
this.drawn = { time: frame.time, complete: this.complete };
|
|
3367
3695
|
this.options.onFrame?.(frame);
|
|
3368
3696
|
this.pixelsTarget?.drawn(frame.time, this.complete);
|
|
3369
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
|
+
};
|
|
3370
3720
|
defineSvgFilter = (id, markup) => {
|
|
3371
3721
|
if (this.options.defineSvgFilter === null) return;
|
|
3372
3722
|
if (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);
|
|
@@ -3387,6 +3737,7 @@ var VideoPreviewEngine = class {
|
|
|
3387
3737
|
destroy() {
|
|
3388
3738
|
this.destroyed = true;
|
|
3389
3739
|
this.detachPixels();
|
|
3740
|
+
this.unsubscribeLuts();
|
|
3390
3741
|
this.filterHost?.remove();
|
|
3391
3742
|
this.filterHost = null;
|
|
3392
3743
|
this.filters.clear();
|
|
@@ -3404,6 +3755,8 @@ var VideoEditorController = class {
|
|
|
3404
3755
|
audioPeaks;
|
|
3405
3756
|
/** The pixels the preview last drew, for scopes and `get_video_scopes` (a `VideoPreviewEngine` fills it). */
|
|
3406
3757
|
previewPixels = new VideoPreviewPixels();
|
|
3758
|
+
/** Grades' LUTs, loaded on demand for the preview, SVG frames and agents. */
|
|
3759
|
+
luts;
|
|
3407
3760
|
project;
|
|
3408
3761
|
issues;
|
|
3409
3762
|
selection = [];
|
|
@@ -3418,6 +3771,7 @@ var VideoEditorController = class {
|
|
|
3418
3771
|
constructor(options = {}) {
|
|
3419
3772
|
this.history = options.history ?? new HistoryManager();
|
|
3420
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);
|
|
3421
3775
|
this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
|
|
3422
3776
|
this.issues = checkVideoProject(this.project);
|
|
3423
3777
|
this.readOnly = options.readOnly ?? false;
|
|
@@ -3628,7 +3982,7 @@ var VideoEditorController = class {
|
|
|
3628
3982
|
}
|
|
3629
3983
|
/** The frame at `time` (default: the playhead) as SVG. */
|
|
3630
3984
|
toSvg(time = this.player.currentTime, options = {}) {
|
|
3631
|
-
return videoProjectSvg(this.project, time, options);
|
|
3985
|
+
return videoProjectSvg(this.project, time, { lutFor: (src) => this.luts.peek(src), ...options });
|
|
3632
3986
|
}
|
|
3633
3987
|
/** The project as an ffmpeg command. */
|
|
3634
3988
|
toFfmpeg(options = {}) {
|
|
@@ -3644,126 +3998,364 @@ var VideoEditorController = class {
|
|
|
3644
3998
|
}
|
|
3645
3999
|
};
|
|
3646
4000
|
|
|
3647
|
-
// src/video-editor/
|
|
3648
|
-
var
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
var
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
};
|
|
3674
|
-
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.` });
|
|
3675
|
-
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." };
|
|
3676
|
-
var TRANSFORM = object(
|
|
3677
|
-
{
|
|
3678
|
-
x: { type: "number", description: "Offset of the centre to the right of the frame's centre, in pixels (default 0)." },
|
|
3679
|
-
y: { type: "number", description: "Offset of the centre below the frame's centre, in pixels (default 0)." },
|
|
3680
|
-
scale: { type: "number", minimum: 0, description: "Size after fitting (1 = fitted size, default 1)." },
|
|
3681
|
-
rotation: { type: "number", description: "Degrees clockwise (default 0)." }
|
|
3682
|
-
},
|
|
3683
|
-
[],
|
|
3684
|
-
"Where the picture sits after it is fitted."
|
|
3685
|
-
);
|
|
3686
|
-
var TRANSITION = object(
|
|
3687
|
-
{
|
|
3688
|
-
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)." },
|
|
3689
|
-
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." },
|
|
3690
|
-
color: COLOR("For dip: the colour passed through (default black)"),
|
|
3691
|
-
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)." },
|
|
3692
|
-
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." }
|
|
3693
|
-
},
|
|
3694
|
-
["type", "duration"],
|
|
3695
|
-
"A transition from the clip that ends where this one starts on the same track."
|
|
3696
|
-
);
|
|
3697
|
-
var GRADE = object(
|
|
3698
|
-
Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
|
|
3699
|
-
[],
|
|
3700
|
-
"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."
|
|
3701
|
-
);
|
|
3702
|
-
var ANIMATION = (when) => object(
|
|
3703
|
-
{
|
|
3704
|
-
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." },
|
|
3705
|
-
duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
|
|
3706
|
-
},
|
|
3707
|
-
["effect"],
|
|
3708
|
-
when
|
|
3709
|
-
);
|
|
3710
|
-
var CLIP_COMMON = {
|
|
3711
|
-
name: { type: "string", description: "A label for the clip." },
|
|
3712
|
-
opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
3713
|
-
transform: TRANSFORM,
|
|
3714
|
-
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)." },
|
|
3715
|
-
grade: GRADE,
|
|
3716
|
-
animateIn: ANIMATION("How the picture enters, from the clip's start (on top of any fade in). Titles usually rise, pop or fade in."),
|
|
3717
|
-
animateOut: ANIMATION("How the picture leaves, ending at the clip's end."),
|
|
3718
|
-
blend: { type: "string", enum: [...VIDEO_BLEND_MODES], description: "How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert." },
|
|
3719
|
-
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
3720
|
-
fadeOut: MS("Fade down to nothing over this long")
|
|
3721
|
-
};
|
|
3722
|
-
var MEDIA_FIELDS = {
|
|
3723
|
-
in: MS("Where in the source it starts (default 0)"),
|
|
3724
|
-
speed: { type: "number", minimum: 0.05, maximum: 20, description: "Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer." },
|
|
3725
|
-
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
3726
|
-
muted: { type: "boolean", description: "Silence this clip (default false)." },
|
|
3727
|
-
fadeInCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade in (default linear; the picture always fades linearly): linear, equal-power (steady loudness, best for crossfades), s-curve (gentle at both ends), exponential (slow start, natural from silence) or logarithmic (fast start)." },
|
|
3728
|
-
fadeOutCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade out (default linear), the same choices as fadeInCurve; exponential sounds natural fading to silence." }
|
|
3729
|
-
};
|
|
3730
|
-
var TEXT_FIELDS = {
|
|
3731
|
-
text: { type: "string", minLength: 1, description: `The title's text ("\\n" breaks lines).` },
|
|
3732
|
-
color: COLOR("Text colour (default white)"),
|
|
3733
|
-
fontSize: { type: "number", exclusiveMinimum: 0, description: "Pixels (default 64)." },
|
|
3734
|
-
fontFamily: { type: "string", description: "CSS font family (default sans-serif)." },
|
|
3735
|
-
fontWeight: { type: "number", minimum: 100, maximum: 1e3, description: "CSS weight (default 700)." },
|
|
3736
|
-
background: COLOR("A box behind the text (default none)")
|
|
3737
|
-
};
|
|
3738
|
-
var PLACEMENT = {
|
|
3739
|
-
id: { type: "string", minLength: 1, description: 'Id for the clip (default: a fresh one such as "c3").' },
|
|
3740
|
-
start: MS("Where it starts on the timeline (default: the end of the track)"),
|
|
3741
|
-
duration: { type: "number", exclusiveMinimum: 0, description: "Length on the timeline, in ms (default: the rest of the source for media, 5000 for stills, 3000 for titles and colours)." }
|
|
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"
|
|
3742
4027
|
};
|
|
3743
|
-
var
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
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"
|
|
3750
4035
|
};
|
|
3751
|
-
var
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
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"
|
|
3757
4051
|
};
|
|
3758
|
-
var
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain for every clip on it (1 = unchanged)." }
|
|
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"
|
|
3765
4058
|
};
|
|
3766
|
-
|
|
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." },
|
|
4305
|
+
transform: TRANSFORM,
|
|
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)." },
|
|
4307
|
+
grade: GRADE,
|
|
4308
|
+
animateIn: ANIMATION("How the picture enters, from the clip's start (on top of any fade in). Titles usually rise, pop or fade in."),
|
|
4309
|
+
animateOut: ANIMATION("How the picture leaves, ending at the clip's end."),
|
|
4310
|
+
blend: { type: "string", enum: [...VIDEO_BLEND_MODES], description: "How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert." },
|
|
4311
|
+
fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
|
|
4312
|
+
fadeOut: MS("Fade down to nothing over this long")
|
|
4313
|
+
};
|
|
4314
|
+
var MEDIA_FIELDS = {
|
|
4315
|
+
in: MS("Where in the source it starts (default 0)"),
|
|
4316
|
+
speed: { type: "number", minimum: 0.05, maximum: 20, description: "Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer." },
|
|
4317
|
+
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
|
|
4318
|
+
muted: { type: "boolean", description: "Silence this clip (default false)." },
|
|
4319
|
+
fadeInCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade in (default linear; the picture always fades linearly): linear, equal-power (steady loudness, best for crossfades), s-curve (gentle at both ends), exponential (slow start, natural from silence) or logarithmic (fast start)." },
|
|
4320
|
+
fadeOutCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade out (default linear), the same choices as fadeInCurve; exponential sounds natural fading to silence." }
|
|
4321
|
+
};
|
|
4322
|
+
var TEXT_FIELDS = {
|
|
4323
|
+
text: { type: "string", minLength: 1, description: `The title's text ("\\n" breaks lines).` },
|
|
4324
|
+
color: COLOR("Text colour (default white)"),
|
|
4325
|
+
fontSize: { type: "number", exclusiveMinimum: 0, description: "Pixels (default 64)." },
|
|
4326
|
+
fontFamily: { type: "string", description: "CSS font family (default sans-serif)." },
|
|
4327
|
+
fontWeight: { type: "number", minimum: 100, maximum: 1e3, description: "CSS weight (default 700)." },
|
|
4328
|
+
background: COLOR("A box behind the text (default none)")
|
|
4329
|
+
};
|
|
4330
|
+
var PLACEMENT = {
|
|
4331
|
+
id: { type: "string", minLength: 1, description: 'Id for the clip (default: a fresh one such as "c3").' },
|
|
4332
|
+
start: MS("Where it starts on the timeline (default: the end of the track)"),
|
|
4333
|
+
duration: { type: "number", exclusiveMinimum: 0, description: "Length on the timeline, in ms (default: the rest of the source for media, 5000 for stills, 3000 for titles and colours)." }
|
|
4334
|
+
};
|
|
4335
|
+
var NEW_CLIP = {
|
|
4336
|
+
description: "The clip: media (a stretch of an asset), text (a title) or color (a solid fill).",
|
|
4337
|
+
oneOf: [
|
|
4338
|
+
object({ type: { const: "media", description: "A stretch of an asset." }, asset: ASSET_ID, ...PLACEMENT, ...CLIP_COMMON, ...MEDIA_FIELDS }, ["type", "asset"], "A media clip."),
|
|
4339
|
+
object({ type: { const: "text", description: "A title over the picture." }, ...PLACEMENT, ...CLIP_COMMON, ...TEXT_FIELDS }, ["type", "text"], "A title."),
|
|
4340
|
+
object({ type: { const: "color", description: "A solid colour filling the frame." }, color: COLOR("The fill"), ...PLACEMENT, ...CLIP_COMMON }, ["type", "color"], "A colour.")
|
|
4341
|
+
]
|
|
4342
|
+
};
|
|
4343
|
+
var SETTINGS = {
|
|
4344
|
+
name: { type: "string", description: "Project name." },
|
|
4345
|
+
width: { type: "integer", minimum: 16, maximum: 8192, description: "Frame width in pixels." },
|
|
4346
|
+
height: { type: "integer", minimum: 16, maximum: 8192, description: "Frame height in pixels." },
|
|
4347
|
+
fps: { type: "number", exclusiveMinimum: 0, maximum: 240, description: "Frames per second (e.g. 24, 25, 30, 60)." },
|
|
4348
|
+
background: COLOR("What shows where no clip covers the frame (default black)")
|
|
4349
|
+
};
|
|
4350
|
+
var TRACK_FIELDS = {
|
|
4351
|
+
name: { type: "string", description: "Track label." },
|
|
4352
|
+
muted: { type: "boolean", description: "Silence every clip on it." },
|
|
4353
|
+
hidden: { type: "boolean", description: "Leave its pictures out of the frame." },
|
|
4354
|
+
locked: { type: "boolean", description: "Refuse edits to its clips." },
|
|
4355
|
+
syncLocked: { type: "boolean", description: "Move with ripple edits on other sync-locked tracks so it stays in sync (default true). Turn off for a track that should stay put, e.g. a music bed." },
|
|
4356
|
+
volume: { type: "number", minimum: 0, maximum: 4, description: "Gain for every clip on it (1 = unchanged)." }
|
|
4357
|
+
};
|
|
4358
|
+
var ASSET_FIELDS = {
|
|
3767
4359
|
src: { type: "string", minLength: 1, description: "URL, path or data URL the player and exporter can open." },
|
|
3768
4360
|
name: { type: "string", description: "Display name (e.g. the file name)." },
|
|
3769
4361
|
duration: { type: "number", exclusiveMinimum: 0, description: "Length of the source in ms (required to check trims of video and audio)." },
|
|
@@ -3923,14 +4515,18 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
3923
4515
|
annotations: { title: "Render a frame", ...READ },
|
|
3924
4516
|
handler: async ({ time, times, columns, format = "svg" }) => {
|
|
3925
4517
|
const p = project();
|
|
3926
|
-
const
|
|
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."] : []];
|
|
3927
4523
|
if (format === "png") {
|
|
3928
4524
|
if (!options.rasterize) throw new Error('This host cannot make PNG images; call render_video_frame with format "svg".');
|
|
3929
4525
|
const size = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
|
|
3930
4526
|
const image = await options.rasterize(svg, { width: Number(size[1]), height: Number(size[2]) });
|
|
3931
|
-
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(" ") }] : []] };
|
|
3932
4528
|
}
|
|
3933
|
-
return { svg };
|
|
4529
|
+
return { svg, ...notes.length ? { notes } : {} };
|
|
3934
4530
|
}
|
|
3935
4531
|
};
|
|
3936
4532
|
const LOUDNESS_TARGET = {
|
|
@@ -4167,7 +4763,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4167
4763
|
const applyGradePreset = {
|
|
4168
4764
|
name: "apply_video_grade_preset",
|
|
4169
4765
|
title: "Apply a colour look",
|
|
4170
|
-
description: "Gives clips a named colour look at a strength (one undo step), replacing their
|
|
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.",
|
|
4171
4767
|
inputSchema: object(
|
|
4172
4768
|
{
|
|
4173
4769
|
clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to grade." },
|
|
@@ -4179,8 +4775,10 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4179
4775
|
annotations: { title: "Apply a colour look", ...WRITE_IDEMPOTENT },
|
|
4180
4776
|
handler: ({ clips, preset, amount }) => {
|
|
4181
4777
|
gradeTargets(clips);
|
|
4182
|
-
const
|
|
4183
|
-
|
|
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
|
+
});
|
|
4184
4782
|
return apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
|
|
4185
4783
|
}
|
|
4186
4784
|
};
|
|
@@ -4197,6 +4795,43 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4197
4795
|
return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
|
|
4198
4796
|
}
|
|
4199
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
|
+
};
|
|
4200
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)." };
|
|
4201
4836
|
const RANGE_OUT = (r) => ({ from: round(r.from), to: round(r.to), text: r.text });
|
|
4202
4837
|
const timelineWords = (track) => {
|
|
@@ -4639,7 +5274,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4639
5274
|
}
|
|
4640
5275
|
};
|
|
4641
5276
|
const reads = [getProject, getFrame, getTranscript, findText, findFillers, findPauses, findGaps, checkProject, renderFrame, getScopes, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
|
|
4642
|
-
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];
|
|
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];
|
|
4643
5278
|
return {
|
|
4644
5279
|
id: options.id ?? "video",
|
|
4645
5280
|
namespace: options.namespace,
|
|
@@ -4656,227 +5291,6 @@ function createVideoEditorToolProvider(controller, options = {}) {
|
|
|
4656
5291
|
}
|
|
4657
5292
|
};
|
|
4658
5293
|
}
|
|
4659
|
-
|
|
4660
|
-
// src/video-editor/ui.ts
|
|
4661
|
-
var STEPS = [40, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5];
|
|
4662
|
-
function videoRulerTicks(duration, pxPerSecond, fps) {
|
|
4663
|
-
const pxPerMs = pxPerSecond / 1e3;
|
|
4664
|
-
const major = STEPS.find((s) => s * pxPerMs >= 80) ?? STEPS[STEPS.length - 1];
|
|
4665
|
-
const minor = STEPS.slice().reverse().find((s) => s < major && major % s === 0 && s * pxPerMs >= 10);
|
|
4666
|
-
const step = minor ?? major;
|
|
4667
|
-
const ticks = [];
|
|
4668
|
-
for (let t = 0; t <= duration + 1e-6; t += step) {
|
|
4669
|
-
const isMajor = Math.abs(t / major - Math.round(t / major)) < 1e-9;
|
|
4670
|
-
ticks.push({ time: t, x: t * pxPerMs, major: isMajor, ...isMajor ? { label: formatVideoTimecode(t, fps).replace(/^00:/, "") } : {} });
|
|
4671
|
-
}
|
|
4672
|
-
return ticks;
|
|
4673
|
-
}
|
|
4674
|
-
function videoClipShowsWaveform(clip, asset, mode = "audio") {
|
|
4675
|
-
if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
|
|
4676
|
-
return mode === "all" || asset.kind === "audio";
|
|
4677
|
-
}
|
|
4678
|
-
var VIDEO_CLIP_ANIMATION_LABELS = {
|
|
4679
|
-
fade: "Fade",
|
|
4680
|
-
rise: "Rise",
|
|
4681
|
-
drop: "Drop",
|
|
4682
|
-
"slide-left": "Slide left",
|
|
4683
|
-
"slide-right": "Slide right",
|
|
4684
|
-
zoom: "Zoom",
|
|
4685
|
-
pop: "Pop"
|
|
4686
|
-
};
|
|
4687
|
-
var VIDEO_TRANSITION_LABELS = {
|
|
4688
|
-
crossfade: "Crossfade",
|
|
4689
|
-
dip: "Dip to colour",
|
|
4690
|
-
wipe: "Wipe",
|
|
4691
|
-
push: "Push",
|
|
4692
|
-
slide: "Slide",
|
|
4693
|
-
iris: "Iris"
|
|
4694
|
-
};
|
|
4695
|
-
var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
|
|
4696
|
-
var VIDEO_BLEND_MODE_LABELS = {
|
|
4697
|
-
normal: "Normal",
|
|
4698
|
-
multiply: "Multiply",
|
|
4699
|
-
screen: "Screen",
|
|
4700
|
-
overlay: "Overlay",
|
|
4701
|
-
darken: "Darken",
|
|
4702
|
-
lighten: "Lighten",
|
|
4703
|
-
"color-dodge": "Colour dodge",
|
|
4704
|
-
"color-burn": "Colour burn",
|
|
4705
|
-
"hard-light": "Hard light",
|
|
4706
|
-
"soft-light": "Soft light",
|
|
4707
|
-
difference: "Difference",
|
|
4708
|
-
exclusion: "Exclusion",
|
|
4709
|
-
add: "Add"
|
|
4710
|
-
};
|
|
4711
|
-
var VIDEO_FADE_CURVE_LABELS = {
|
|
4712
|
-
linear: "Linear",
|
|
4713
|
-
"equal-power": "Equal power",
|
|
4714
|
-
"s-curve": "S-curve",
|
|
4715
|
-
exponential: "Exponential",
|
|
4716
|
-
logarithmic: "Logarithmic"
|
|
4717
|
-
};
|
|
4718
|
-
function videoClipTint(clip, asset) {
|
|
4719
|
-
if (clip.type === "text") return "#a855f7";
|
|
4720
|
-
if (clip.type === "color") return clip.color;
|
|
4721
|
-
if (!asset) return "#ef4444";
|
|
4722
|
-
return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
|
|
4723
|
-
}
|
|
4724
|
-
function videoClipLabel(clip, asset) {
|
|
4725
|
-
if (clip.name) return clip.name;
|
|
4726
|
-
if (clip.type === "text") return clip.text.split("\n")[0];
|
|
4727
|
-
if (clip.type === "color") return clip.color;
|
|
4728
|
-
const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
|
|
4729
|
-
const speed = videoClipSpeed(clip);
|
|
4730
|
-
return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
|
|
4731
|
-
}
|
|
4732
|
-
function planVideoClipDrag(project, input) {
|
|
4733
|
-
const at = findVideoClip(project, input.clip);
|
|
4734
|
-
if (!at) return null;
|
|
4735
|
-
const { clip, track } = at;
|
|
4736
|
-
const snap = input.snap ?? ((t) => t);
|
|
4737
|
-
if (input.mode === "move") {
|
|
4738
|
-
const raw = Math.max(0, clip.start + input.delta);
|
|
4739
|
-
const head = snap(raw) - raw;
|
|
4740
|
-
const tail = snap(raw + clip.duration) - (raw + clip.duration);
|
|
4741
|
-
const shifts = [head, tail].filter((d) => Math.abs(d) > 1e-9);
|
|
4742
|
-
const start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => Math.abs(b) < Math.abs(a) ? b : a) : 0));
|
|
4743
|
-
const target = input.track ?? track.id;
|
|
4744
|
-
const moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;
|
|
4745
|
-
const mode = input.placement && input.placement !== "refuse" ? { mode: input.placement } : {};
|
|
4746
|
-
return { start, duration: clip.duration, track: target, edit: moved ? { op: "move_clip", clip: clip.id, start, ...target !== track.id ? { track: target } : {}, ...mode } : null };
|
|
4747
|
-
}
|
|
4748
|
-
const minimum = 1e3 / project.fps;
|
|
4749
|
-
if (input.mode === "trim-start") {
|
|
4750
|
-
const to2 = Math.min(snap(Math.max(0, clip.start + input.delta)), videoClipEnd(clip) - minimum);
|
|
4751
|
-
const changed2 = Math.abs(to2 - clip.start) > 1e-6;
|
|
4752
|
-
const plan = input.ripple ? { start: clip.start, duration: clip.duration - (to2 - clip.start) } : { start: to2, duration: videoClipEnd(clip) - to2 };
|
|
4753
|
-
return { ...plan, track: track.id, edit: changed2 ? { op: "trim_clip", clip: clip.id, edge: "start", to: to2, ...input.ripple ? { ripple: true } : {} } : null };
|
|
4754
|
-
}
|
|
4755
|
-
const to = Math.max(snap(videoClipEnd(clip) + input.delta), clip.start + minimum);
|
|
4756
|
-
const changed = Math.abs(to - videoClipEnd(clip)) > 1e-6;
|
|
4757
|
-
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 };
|
|
4758
|
-
}
|
|
4759
|
-
function videoDropTracks(project, clipId) {
|
|
4760
|
-
const at = findVideoClip(project, clipId);
|
|
4761
|
-
if (!at) return [];
|
|
4762
|
-
return project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);
|
|
4763
|
-
}
|
|
4764
|
-
function videoDragLabel(edit) {
|
|
4765
|
-
if (edit.op === "move_clip") return edit.mode === "insert" ? "Insert clip" : edit.mode === "overwrite" ? "Overwrite clip" : "Move clip";
|
|
4766
|
-
return edit.op === "trim_clip" && edit.ripple ? "Ripple trim" : "Trim clip";
|
|
4767
|
-
}
|
|
4768
|
-
function videoAssetDropEdit(project, assetId, options = {}) {
|
|
4769
|
-
const asset = videoAssetMap(project).get(assetId);
|
|
4770
|
-
if (!asset) return null;
|
|
4771
|
-
const kind = asset.kind === "audio" ? "audio" : "video";
|
|
4772
|
-
const track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;
|
|
4773
|
-
if (!track) return null;
|
|
4774
|
-
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 } : {} };
|
|
4775
|
-
}
|
|
4776
|
-
function videoAssetKindOf(typeOrName) {
|
|
4777
|
-
const s = typeOrName.toLowerCase().replace(/[?#].*$/, "");
|
|
4778
|
-
if (s.startsWith("video/") || /\.(mp4|m4v|mov|webm|ogv|mkv)$/.test(s)) return "video";
|
|
4779
|
-
if (s.startsWith("audio/") || /\.(mp3|m4a|aac|wav|ogg|oga|flac|opus)$/.test(s)) return "audio";
|
|
4780
|
-
if (s.startsWith("image/") || /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/.test(s)) return "image";
|
|
4781
|
-
return null;
|
|
4782
|
-
}
|
|
4783
|
-
function probeVideoMedia(src, kind, timeout = 15e3) {
|
|
4784
|
-
return new Promise((resolve, reject) => {
|
|
4785
|
-
if (typeof document === "undefined") return reject(new Error("Media can only be probed in a browser."));
|
|
4786
|
-
const timer = setTimeout(() => reject(new Error(`"${src}" took too long to load.`)), timeout);
|
|
4787
|
-
const done = (probe) => {
|
|
4788
|
-
clearTimeout(timer);
|
|
4789
|
-
resolve(probe);
|
|
4790
|
-
};
|
|
4791
|
-
const failed = () => {
|
|
4792
|
-
clearTimeout(timer);
|
|
4793
|
-
reject(new Error(`"${src}" could not be loaded as ${kind}.`));
|
|
4794
|
-
};
|
|
4795
|
-
if (kind === "image") {
|
|
4796
|
-
const img = new Image();
|
|
4797
|
-
img.onload = () => done({ kind, width: img.naturalWidth, height: img.naturalHeight });
|
|
4798
|
-
img.onerror = failed;
|
|
4799
|
-
img.src = src;
|
|
4800
|
-
return;
|
|
4801
|
-
}
|
|
4802
|
-
const el = document.createElement(kind);
|
|
4803
|
-
el.preload = "metadata";
|
|
4804
|
-
el.onloadedmetadata = () => {
|
|
4805
|
-
const duration = Number.isFinite(el.duration) ? Math.round(el.duration * 1e3) : void 0;
|
|
4806
|
-
const v = el;
|
|
4807
|
-
done({ kind, ...duration ? { duration } : {}, ...kind === "video" && v.videoWidth ? { width: v.videoWidth, height: v.videoHeight } : {} });
|
|
4808
|
-
};
|
|
4809
|
-
el.onerror = failed;
|
|
4810
|
-
el.src = src;
|
|
4811
|
-
});
|
|
4812
|
-
}
|
|
4813
|
-
var KEY_LABELS = {
|
|
4814
|
-
opacity: { label: "Opacity", step: 0.05 },
|
|
4815
|
-
volume: { label: "Volume", step: 0.05 },
|
|
4816
|
-
x: { label: "X", step: 1 },
|
|
4817
|
-
y: { label: "Y", step: 1 },
|
|
4818
|
-
scale: { label: "Scale", step: 0.05 },
|
|
4819
|
-
rotation: { label: "Rotation", step: 1 }
|
|
4820
|
-
};
|
|
4821
|
-
var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? clip.transform?.[property] : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
|
|
4822
|
-
var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
|
|
4823
|
-
function videoKeyframeRows(project, clipId, time) {
|
|
4824
|
-
const at = findVideoClip(project, clipId);
|
|
4825
|
-
if (!at) return [];
|
|
4826
|
-
const { clip } = at;
|
|
4827
|
-
const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
|
|
4828
|
-
const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
|
|
4829
|
-
const audible = clip.type === "media" && videoAssetHasSound(asset);
|
|
4830
|
-
const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
|
|
4831
|
-
const clipTime = playheadInClip(clip, time);
|
|
4832
|
-
return properties.map((property) => {
|
|
4833
|
-
const prop = animatableOf(clip, property);
|
|
4834
|
-
const keys = isVideoKeyframes(prop) ? prop.keys : [];
|
|
4835
|
-
const limits = VIDEO_KEYFRAME_LIMITS[property];
|
|
4836
|
-
return {
|
|
4837
|
-
property,
|
|
4838
|
-
...KEY_LABELS[property],
|
|
4839
|
-
value: Math.round(videoValueAt(prop, clipTime, limits.default) * 1e3) / 1e3,
|
|
4840
|
-
keyed: keys.length > 0,
|
|
4841
|
-
keys: keys.length,
|
|
4842
|
-
onKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),
|
|
4843
|
-
clipTime,
|
|
4844
|
-
...limits.min !== void 0 ? { min: limits.min } : {},
|
|
4845
|
-
...limits.max !== void 0 ? { max: limits.max } : {}
|
|
4846
|
-
};
|
|
4847
|
-
});
|
|
4848
|
-
}
|
|
4849
|
-
function plainValueEdit(clip, property, value) {
|
|
4850
|
-
if (!VIDEO_TRANSFORM_PROPERTIES.includes(property))
|
|
4851
|
-
return value === void 0 ? { op: "update_clip", clip: clip.id, remove: [property] } : { op: "update_clip", clip: clip.id, set: { [property]: value } };
|
|
4852
|
-
const transform = { ...clip.transform, [property]: value };
|
|
4853
|
-
if (value === void 0) delete transform[property];
|
|
4854
|
-
return Object.keys(transform).length ? { op: "update_clip", clip: clip.id, set: { transform } } : { op: "update_clip", clip: clip.id, remove: ["transform"] };
|
|
4855
|
-
}
|
|
4856
|
-
function videoKeyframeValueEdit(project, clipId, property, time, value) {
|
|
4857
|
-
const at = findVideoClip(project, clipId);
|
|
4858
|
-
if (!at) return null;
|
|
4859
|
-
const prop = animatableOf(at.clip, property);
|
|
4860
|
-
if (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);
|
|
4861
|
-
if (value === void 0) return null;
|
|
4862
|
-
return { op: "set_keyframes", clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };
|
|
4863
|
-
}
|
|
4864
|
-
function videoKeyframeToggleEdit(project, clipId, property, time) {
|
|
4865
|
-
const at = findVideoClip(project, clipId);
|
|
4866
|
-
if (!at) return null;
|
|
4867
|
-
const prop = animatableOf(at.clip, property);
|
|
4868
|
-
const t = playheadInClip(at.clip, time);
|
|
4869
|
-
const fallback = VIDEO_KEYFRAME_LIMITS[property].default;
|
|
4870
|
-
if (isVideoKeyframes(prop)) {
|
|
4871
|
-
const key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);
|
|
4872
|
-
if (key) {
|
|
4873
|
-
const rest = removeVideoKey(prop, key.t);
|
|
4874
|
-
return isVideoKeyframes(rest) ? { op: "set_keyframes", clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? void 0 : rest);
|
|
4875
|
-
}
|
|
4876
|
-
}
|
|
4877
|
-
const v = videoValueAt(prop, t, fallback);
|
|
4878
|
-
return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
|
|
4879
|
-
}
|
|
4880
5294
|
export {
|
|
4881
5295
|
VIDEO_BLEND_MODES,
|
|
4882
5296
|
VIDEO_BLEND_MODE_LABELS,
|
|
@@ -4898,6 +5312,7 @@ export {
|
|
|
4898
5312
|
VIDEO_ISSUE_CODES,
|
|
4899
5313
|
VIDEO_KEYFRAME_LIMITS,
|
|
4900
5314
|
VIDEO_KEYFRAME_PROPERTIES,
|
|
5315
|
+
VIDEO_LUT_MAX_SIZE,
|
|
4901
5316
|
VIDEO_PEAK_BLOCK,
|
|
4902
5317
|
VIDEO_PLACEMENT_MODES,
|
|
4903
5318
|
VIDEO_SCOPE_KINDS,
|
|
@@ -4909,11 +5324,14 @@ export {
|
|
|
4909
5324
|
VIDEO_WIPE_DIRECTIONS,
|
|
4910
5325
|
VideoAudioPeaksCache,
|
|
4911
5326
|
VideoEditorController,
|
|
5327
|
+
VideoLutCache,
|
|
4912
5328
|
VideoPreviewEngine,
|
|
4913
5329
|
VideoPreviewPixels,
|
|
4914
5330
|
allVideoClipIds,
|
|
4915
5331
|
applyVideoEdits,
|
|
4916
5332
|
applyVideoGrade,
|
|
5333
|
+
applyVideoLut,
|
|
5334
|
+
applyVideoLutToPixels,
|
|
4917
5335
|
buildVideoAudioPeaks,
|
|
4918
5336
|
checkVideoProject,
|
|
4919
5337
|
checkVideoTimeRemap,
|
|
@@ -4937,12 +5355,15 @@ export {
|
|
|
4937
5355
|
mergeVideoRanges,
|
|
4938
5356
|
nextFreeVideoSlot,
|
|
4939
5357
|
normalizeVideoGrade,
|
|
5358
|
+
normalizeVideoGradeLut,
|
|
4940
5359
|
normalizeVideoWord,
|
|
4941
5360
|
parseVideoAnimatable,
|
|
4942
5361
|
parseVideoAsset,
|
|
4943
5362
|
parseVideoClip,
|
|
4944
5363
|
parseVideoClipAnimation,
|
|
5364
|
+
parseVideoCubeLut,
|
|
4945
5365
|
parseVideoGrade,
|
|
5366
|
+
parseVideoGradeLut,
|
|
4946
5367
|
parseVideoKeyframes,
|
|
4947
5368
|
parseVideoLoudnessMeasurement,
|
|
4948
5369
|
parseVideoMarker,
|
|
@@ -5014,6 +5435,7 @@ export {
|
|
|
5014
5435
|
videoGradeCssFilter,
|
|
5015
5436
|
videoGradeFfmpegFilters,
|
|
5016
5437
|
videoGradeGainsFilterId,
|
|
5438
|
+
videoGradeLook,
|
|
5017
5439
|
videoGradePreset,
|
|
5018
5440
|
videoGradeStepMatrix,
|
|
5019
5441
|
videoGradeStepSvgValues,
|
|
@@ -5033,6 +5455,11 @@ export {
|
|
|
5033
5455
|
videoKeysOf,
|
|
5034
5456
|
videoLuma,
|
|
5035
5457
|
videoLumaWaveform,
|
|
5458
|
+
videoLutEdits,
|
|
5459
|
+
videoLutFromFunction,
|
|
5460
|
+
videoLutSvgApproximation,
|
|
5461
|
+
videoLutSvgPrimitives,
|
|
5462
|
+
videoLutToCube,
|
|
5036
5463
|
videoOverlap,
|
|
5037
5464
|
videoPeakDb,
|
|
5038
5465
|
videoProjectDuration,
|