@pixerate/editor 0.159.0 → 0.161.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/dist/{chunk-VKF3PWKR.cjs → chunk-3OSXHLIT.cjs} +259 -28
- package/dist/chunk-6DM3PUQD.cjs +767 -0
- package/dist/{chunk-IQ6KKXOU.js → chunk-YN4AYYBJ.js} +243 -12
- package/dist/chunk-ZGOHKS3X.js +767 -0
- package/dist/effects/index.cjs +16 -767
- package/dist/effects/index.js +5 -756
- package/dist/image-editor/controller.d.cts +7 -0
- package/dist/image-editor/controller.d.ts +7 -0
- package/dist/image-editor/index.cjs +49 -10
- package/dist/image-editor/index.js +48 -9
- package/dist/image-editor/layers/transform-ui.d.cts +14 -6
- package/dist/image-editor/layers/transform-ui.d.ts +14 -6
- package/dist/paint-editor/index.cjs +2 -2
- package/dist/paint-editor/index.js +1 -1
- package/dist/video-editor/audio.d.cts +9 -0
- package/dist/video-editor/audio.d.ts +9 -0
- package/dist/video-editor/check.d.cts +1 -1
- package/dist/video-editor/check.d.ts +1 -1
- package/dist/video-editor/controller.d.cts +47 -0
- package/dist/video-editor/controller.d.ts +47 -0
- package/dist/video-editor/dialogue-tools.d.cts +19 -0
- package/dist/video-editor/dialogue-tools.d.ts +19 -0
- package/dist/video-editor/effect-authoring.d.cts +54 -0
- package/dist/video-editor/effect-authoring.d.ts +54 -0
- package/dist/video-editor/evaluate.d.cts +11 -0
- package/dist/video-editor/evaluate.d.ts +11 -0
- package/dist/video-editor/export.d.cts +7 -1
- package/dist/video-editor/export.d.ts +7 -1
- package/dist/video-editor/index.cjs +1853 -528
- package/dist/video-editor/index.d.cts +7 -0
- package/dist/video-editor/index.d.ts +7 -0
- package/dist/video-editor/index.js +1512 -187
- package/dist/video-editor/lut.d.cts +12 -2
- package/dist/video-editor/lut.d.ts +12 -2
- package/dist/video-editor/mask.d.cts +48 -0
- package/dist/video-editor/mask.d.ts +48 -0
- package/dist/video-editor/mix.d.cts +107 -0
- package/dist/video-editor/mix.d.ts +107 -0
- package/dist/video-editor/music-tools.d.cts +19 -0
- package/dist/video-editor/music-tools.d.ts +19 -0
- package/dist/video-editor/parse.d.cts +3 -1
- package/dist/video-editor/parse.d.ts +3 -1
- package/dist/video-editor/plan.d.cts +75 -0
- package/dist/video-editor/plan.d.ts +75 -0
- package/dist/video-editor/render.d.cts +1 -1
- package/dist/video-editor/render.d.ts +1 -1
- package/dist/video-editor/timeline.d.cts +2 -0
- package/dist/video-editor/timeline.d.ts +2 -0
- package/dist/video-editor/tools.d.cts +90 -0
- package/dist/video-editor/tools.d.ts +90 -0
- package/dist/video-editor/transcript-controller.d.cts +106 -0
- package/dist/video-editor/transcript-controller.d.ts +106 -0
- package/dist/video-editor/transcript.d.cts +40 -0
- package/dist/video-editor/transcript.d.ts +40 -0
- package/dist/video-editor/types.d.cts +62 -2
- package/dist/video-editor/types.d.ts +62 -2
- package/dist/video-editor/ui.d.cts +21 -1
- package/dist/video-editor/ui.d.ts +21 -1
- package/package.json +1 -1
- package/skills/README.md +2 -0
- package/skills/pixerate-bgm-alignment/SKILL.md +82 -0
- package/skills/pixerate-talking-head-edit/SKILL.md +80 -0
- package/skills/pixerate-video-editor/SKILL.md +40 -2
|
@@ -19,9 +19,11 @@ var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
|
|
|
19
19
|
var videoTransitionAudioCurve = (t) => t.audioCurve ?? (t.type === "dip" ? "linear" : "equal-power");
|
|
20
20
|
var VIDEO_BLEND_MODES = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "add"];
|
|
21
21
|
var VIDEO_CLIP_ANIMATION_EFFECTS = ["fade", "rise", "drop", "slide-left", "slide-right", "zoom", "pop"];
|
|
22
|
+
var VIDEO_MASK_SHAPES = ["rectangle", "ellipse", "linear", "polygon"];
|
|
22
23
|
var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
|
|
24
|
+
var VIDEO_ADJUSTMENT_SCOPES = ["underneath", "all", "track"];
|
|
23
25
|
var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
|
|
24
|
-
var VIDEO_CLIP_TYPES = ["media", "text", "color", "sequence", "effect"];
|
|
26
|
+
var VIDEO_CLIP_TYPES = ["media", "text", "color", "sequence", "effect", "adjustment"];
|
|
25
27
|
|
|
26
28
|
// src/video-editor/keyframes.ts
|
|
27
29
|
var VIDEO_EASE_NAMES = ["linear", "hold", "ease-in", "ease-out", "ease-in-out"];
|
|
@@ -318,9 +320,25 @@ function applyVideoLut(lut, rgb, out = [0, 0, 0]) {
|
|
|
318
320
|
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]);
|
|
319
321
|
return out;
|
|
320
322
|
}
|
|
321
|
-
function
|
|
323
|
+
function videoSkinToneWeight(r, g, b) {
|
|
324
|
+
const R = r * 255;
|
|
325
|
+
const G = g * 255;
|
|
326
|
+
const B = b * 255;
|
|
327
|
+
const y = 0.299 * R + 0.587 * G + 0.114 * B;
|
|
328
|
+
if (y < 20 || y > 250) return 0;
|
|
329
|
+
const cb = 128 - 0.168736 * R - 0.331264 * G + 0.5 * B;
|
|
330
|
+
const cr = 128 + 0.5 * R - 0.418688 * G - 0.081312 * B;
|
|
331
|
+
const d = Math.hypot((cb - 108) / 20, (cr - 154) / 18);
|
|
332
|
+
let w = d <= 1 ? 1 : d >= 1.5 ? 0 : 1 - smooth((d - 1) / 0.5);
|
|
333
|
+
if (y < 40) w *= (y - 20) / 20;
|
|
334
|
+
else if (y > 235) w *= (250 - y) / 15;
|
|
335
|
+
return w;
|
|
336
|
+
}
|
|
337
|
+
var smooth = (t) => t * t * (3 - 2 * t);
|
|
338
|
+
function applyVideoLutToPixels(lut, data, strength = 1, preserveSkinTones = 0) {
|
|
322
339
|
const s = clamp01(strength);
|
|
323
340
|
if (!s) return;
|
|
341
|
+
const skin = clamp01(preserveSkinTones);
|
|
324
342
|
const rgb = [0, 0, 0];
|
|
325
343
|
const out = [0, 0, 0];
|
|
326
344
|
for (let i = 0; i < data.length; i += 4) {
|
|
@@ -329,7 +347,8 @@ function applyVideoLutToPixels(lut, data, strength = 1) {
|
|
|
329
347
|
rgb[1] = data[i + 1] / 255;
|
|
330
348
|
rgb[2] = data[i + 2] / 255;
|
|
331
349
|
applyVideoLut(lut, rgb, out);
|
|
332
|
-
|
|
350
|
+
const k = skin ? s * (1 - skin * videoSkinToneWeight(rgb[0], rgb[1], rgb[2])) : s;
|
|
351
|
+
for (let c = 0; c < 3; c++) data[i + c] = Math.round((rgb[c] + (out[c] - rgb[c]) * k) * 255);
|
|
333
352
|
}
|
|
334
353
|
}
|
|
335
354
|
var approximations = /* @__PURE__ */ new WeakMap();
|
|
@@ -484,7 +503,8 @@ function normalizeVideoGradeLut(lut) {
|
|
|
484
503
|
if (!lut || typeof lut.src !== "string" || !lut.src) return void 0;
|
|
485
504
|
const strength = typeof lut.strength === "number" && Number.isFinite(lut.strength) ? Math.round(Math.min(1, Math.max(0, lut.strength)) * 1e3) / 1e3 : 1;
|
|
486
505
|
if (!strength) return void 0;
|
|
487
|
-
|
|
506
|
+
const skin = typeof lut.preserveSkinTones === "number" && Number.isFinite(lut.preserveSkinTones) ? Math.round(Math.min(1, Math.max(0, lut.preserveSkinTones)) * 1e3) / 1e3 : 0;
|
|
507
|
+
return { src: lut.src, ...strength !== 1 ? { strength } : {}, ...lut.name ? { name: lut.name } : {}, ...skin ? { preserveSkinTones: skin } : {} };
|
|
488
508
|
}
|
|
489
509
|
function normalizeVideoGrade(grade) {
|
|
490
510
|
if (!grade) return void 0;
|
|
@@ -572,7 +592,7 @@ function applyVideoGrade(grade, rgb, lut) {
|
|
|
572
592
|
const setting = normalizeVideoGradeLut(grade?.lut);
|
|
573
593
|
if (setting && lut) {
|
|
574
594
|
const out = applyVideoLut(lut, v);
|
|
575
|
-
const s = setting.strength ?? 1;
|
|
595
|
+
const s = (setting.strength ?? 1) * (1 - (setting.preserveSkinTones ?? 0) * videoSkinToneWeight(v[0], v[1], v[2]));
|
|
576
596
|
v = v.map((x, k) => x + (out[k] - x) * s);
|
|
577
597
|
}
|
|
578
598
|
return v.map((x) => Math.round(x * 255));
|
|
@@ -717,6 +737,7 @@ var videoSlowMotionRamp = (duration) => [
|
|
|
717
737
|
// src/video-editor/timeline.ts
|
|
718
738
|
var VIDEO_TIME_EPSILON = 0.5;
|
|
719
739
|
var videoClipEnd = (clip) => clip.start + clip.duration;
|
|
740
|
+
var videoClipTypeLabel = (clip) => ({ text: "title", color: "colour", effect: "effect clip", adjustment: "adjustment clip", sequence: "compound clip", media: "media clip" })[clip.type];
|
|
720
741
|
var videoTrackSyncLocked = (track) => track.syncLocked !== false;
|
|
721
742
|
var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
|
|
722
743
|
function videoClipSourceRange(clip) {
|
|
@@ -966,6 +987,84 @@ function videoClipAnimated(clip, project) {
|
|
|
966
987
|
return out;
|
|
967
988
|
}
|
|
968
989
|
|
|
990
|
+
// src/video-editor/mask.ts
|
|
991
|
+
var VIDEO_MASK_DEFAULTS = { x: 0.5, y: 0.5, width: 0.5, height: 0.5, rotation: 0, feather: 0, cornerRadius: 0 };
|
|
992
|
+
var ELLIPSE_SEGMENTS = 64;
|
|
993
|
+
var CORNER_SEGMENTS = 8;
|
|
994
|
+
function normalizeVideoClipMask(mask) {
|
|
995
|
+
const d = VIDEO_MASK_DEFAULTS;
|
|
996
|
+
const finite = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
997
|
+
return {
|
|
998
|
+
shape: mask.shape,
|
|
999
|
+
x: finite(mask.x, d.x),
|
|
1000
|
+
y: finite(mask.y, d.y),
|
|
1001
|
+
width: Math.max(0, finite(mask.width, d.width)),
|
|
1002
|
+
height: Math.max(0, finite(mask.height, d.height)),
|
|
1003
|
+
rotation: finite(mask.rotation, d.rotation),
|
|
1004
|
+
feather: Math.max(0, finite(mask.feather, d.feather)),
|
|
1005
|
+
cornerRadius: Math.min(1, Math.max(0, finite(mask.cornerRadius, d.cornerRadius))),
|
|
1006
|
+
inverted: mask.inverted === true,
|
|
1007
|
+
...mask.points ? { points: mask.points } : {}
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
function videoMaskPolygon(mask, rect) {
|
|
1011
|
+
const m = normalizeVideoClipMask(mask);
|
|
1012
|
+
const cx = rect.x + m.x * rect.width;
|
|
1013
|
+
const cy = rect.y + m.y * rect.height;
|
|
1014
|
+
const a = m.rotation * Math.PI / 180;
|
|
1015
|
+
const cos = Math.cos(a);
|
|
1016
|
+
const sin = Math.sin(a);
|
|
1017
|
+
const turn = (dx, dy) => [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
|
1018
|
+
if (m.shape === "linear") {
|
|
1019
|
+
const R = 4 * (Math.hypot(rect.width, rect.height) + Math.hypot(cx - rect.x, cy - rect.y)) + 1;
|
|
1020
|
+
return [turn(-R, 0), turn(R, 0), turn(R, -R), turn(-R, -R)];
|
|
1021
|
+
}
|
|
1022
|
+
if (m.shape === "polygon") {
|
|
1023
|
+
return (m.points ?? []).map((p) => turn((p.x - m.x) * rect.width, (p.y - m.y) * rect.height));
|
|
1024
|
+
}
|
|
1025
|
+
const hw = m.width * rect.width / 2;
|
|
1026
|
+
const hh = m.height * rect.height / 2;
|
|
1027
|
+
if (m.shape === "ellipse") {
|
|
1028
|
+
return Array.from({ length: ELLIPSE_SEGMENTS }, (_, i) => {
|
|
1029
|
+
const t = i / ELLIPSE_SEGMENTS * 2 * Math.PI;
|
|
1030
|
+
return turn(hw * Math.cos(t), hh * Math.sin(t));
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
const r = m.cornerRadius * Math.min(hw, hh);
|
|
1034
|
+
if (r <= 0) return [turn(-hw, -hh), turn(hw, -hh), turn(hw, hh), turn(-hw, hh)];
|
|
1035
|
+
const corners = [
|
|
1036
|
+
[-hw + r, -hh + r, Math.PI],
|
|
1037
|
+
[hw - r, -hh + r, 1.5 * Math.PI],
|
|
1038
|
+
[hw - r, hh - r, 0],
|
|
1039
|
+
[-hw + r, hh - r, 0.5 * Math.PI]
|
|
1040
|
+
];
|
|
1041
|
+
const out = [];
|
|
1042
|
+
for (const [x, y, start] of corners)
|
|
1043
|
+
for (let i = 0; i <= CORNER_SEGMENTS; i++) {
|
|
1044
|
+
const t = start + i / CORNER_SEGMENTS * (Math.PI / 2);
|
|
1045
|
+
out.push(turn(x + r * Math.cos(t), y + r * Math.sin(t)));
|
|
1046
|
+
}
|
|
1047
|
+
return out;
|
|
1048
|
+
}
|
|
1049
|
+
function videoLayerMask(mask, rect) {
|
|
1050
|
+
const m = normalizeVideoClipMask(mask);
|
|
1051
|
+
return { points: videoMaskPolygon(mask, rect), feather: m.feather, inverted: m.inverted };
|
|
1052
|
+
}
|
|
1053
|
+
function videoMaskSvgPath(mask) {
|
|
1054
|
+
const n2 = (v) => String(Math.round(v * 100) / 100);
|
|
1055
|
+
return mask.points.map(([x, y], i) => `${i ? "L" : "M"}${n2(x)} ${n2(y)}`).join("") + "Z";
|
|
1056
|
+
}
|
|
1057
|
+
function videoMaskContains(mask, x, y) {
|
|
1058
|
+
let inside = false;
|
|
1059
|
+
const p = mask.points;
|
|
1060
|
+
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
|
|
1061
|
+
const [xi, yi] = p[i];
|
|
1062
|
+
const [xj, yj] = p[j];
|
|
1063
|
+
if (yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
|
|
1064
|
+
}
|
|
1065
|
+
return mask.inverted ? !inside : inside;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
969
1068
|
// src/video-editor/evaluate.ts
|
|
970
1069
|
function videoEffectValuesAt(values, clipTime) {
|
|
971
1070
|
const out = {};
|
|
@@ -1050,8 +1149,9 @@ function videoFrameAt(project, time) {
|
|
|
1050
1149
|
}
|
|
1051
1150
|
function tracksAt(project, tracks, time, scope) {
|
|
1052
1151
|
const { assets, prefix } = scope;
|
|
1053
|
-
|
|
1152
|
+
let layers = [];
|
|
1054
1153
|
const audio = [];
|
|
1154
|
+
const deferred = [];
|
|
1055
1155
|
for (let ti = tracks.length - 1; ti >= 0; ti--) {
|
|
1056
1156
|
const track = tracks[ti];
|
|
1057
1157
|
for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
|
|
@@ -1081,6 +1181,7 @@ function tracksAt(project, tracks, time, scope) {
|
|
|
1081
1181
|
}
|
|
1082
1182
|
if (track.kind !== "video" || track.hidden) continue;
|
|
1083
1183
|
if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
|
|
1184
|
+
if (clip.type === "adjustment" && !normalizeVideoGrade(clip.grade) && !clip.mask && !(clip.effects ?? []).some((e) => e.enabled !== false)) continue;
|
|
1084
1185
|
if (nested && !nested.layers.length) continue;
|
|
1085
1186
|
const shown = videoClipAnimated(clip, project);
|
|
1086
1187
|
let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
|
|
@@ -1124,6 +1225,22 @@ function tracksAt(project, tracks, time, scope) {
|
|
|
1124
1225
|
if (clip.type === "effect") layer.generator = { effect: clip.effect, ...clip.name ? { name: clip.name } : {}, values: videoEffectValuesAt(clip.values, clipTime), time: Math.max(0, clipTime) };
|
|
1125
1226
|
const effects = (clip.effects ?? []).filter((e) => e.enabled !== false).map((e) => ({ id: e.id, effect: e.effect, ...e.name ? { name: e.name } : {}, opacity: clamp012(videoValueAt(e.opacity, clipTime, 1)), values: videoEffectValuesAt(e.values, clipTime), time: clipTime })).filter((e) => e.opacity > 0);
|
|
1126
1227
|
if (effects.length) layer.effects = effects;
|
|
1228
|
+
if (clip.type === "adjustment") {
|
|
1229
|
+
layer.rect = { x: 0, y: 0, width: project.width, height: project.height };
|
|
1230
|
+
layer.rotation = 0;
|
|
1231
|
+
const adjScope = clip.scope ?? "underneath";
|
|
1232
|
+
layer.adjustment = { scope: adjScope, ...clip.targetTrack ? { track: clip.targetTrack } : {} };
|
|
1233
|
+
if (clip.mask) layer.mask = videoLayerMask(clip.mask, layer.rect);
|
|
1234
|
+
const keepBase = layer.opacity < 1 || !!layer.blend || !!layer.mask || !!layer.wipe || !!layer.iris;
|
|
1235
|
+
if (layer.opacity <= 0) continue;
|
|
1236
|
+
if (adjScope === "underneath") {
|
|
1237
|
+
if (!layers.length) continue;
|
|
1238
|
+
layer.layers = keepBase ? [...layers] : layers.splice(0);
|
|
1239
|
+
layers.push(layer);
|
|
1240
|
+
} else deferred.push({ layer, keepBase });
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (clip.mask) layer.mask = videoLayerMask(clip.mask, layer.rect);
|
|
1127
1244
|
if (effectIn) {
|
|
1128
1245
|
const before = videoClipBefore(track, clip);
|
|
1129
1246
|
const i = before ? layers.findIndex((l) => l.clip === prefix + before.id && l.track === track.id) : -1;
|
|
@@ -1135,6 +1252,22 @@ function tracksAt(project, tracks, time, scope) {
|
|
|
1135
1252
|
if (dip && dip.opacity > 0) layers.push({ clip: `transition:${prefix}${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 });
|
|
1136
1253
|
}
|
|
1137
1254
|
}
|
|
1255
|
+
for (const { layer, keepBase } of deferred) {
|
|
1256
|
+
if (layer.adjustment.scope === "all") {
|
|
1257
|
+
if (!layers.length) continue;
|
|
1258
|
+
layer.layers = [...layers];
|
|
1259
|
+
layers = keepBase ? [...layers, layer] : [layer];
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const target = layer.adjustment.track;
|
|
1263
|
+
const first = layers.findIndex((l) => l.track === target);
|
|
1264
|
+
if (first < 0) continue;
|
|
1265
|
+
let last = first;
|
|
1266
|
+
while (last + 1 < layers.length && layers[last + 1].track === target) last++;
|
|
1267
|
+
const run = layers.slice(first, last + 1);
|
|
1268
|
+
layer.layers = run;
|
|
1269
|
+
layers = [...layers.slice(0, first), ...keepBase ? run : [], layer, ...layers.slice(last + 1)];
|
|
1270
|
+
}
|
|
1138
1271
|
return { layers, audio };
|
|
1139
1272
|
}
|
|
1140
1273
|
function videoLayersWithoutTransitionEffects(layers) {
|
|
@@ -1166,7 +1299,7 @@ function videoClipEffectUses(clip) {
|
|
|
1166
1299
|
}
|
|
1167
1300
|
var videoLayersUseEffects = (layers) => layers.some((l) => !!(l.effects?.length || l.generator || l.transitionEffect || l.layers && videoLayersUseEffects(l.layers)));
|
|
1168
1301
|
var videoEffectFrameTime = (time, fps) => Math.floor(time * fps / 1e3 + 1e-6) * 1e3 / fps;
|
|
1169
|
-
var PLACEMENT = /* @__PURE__ */ new Set(["opacity", "rect", "rotation", "blend", "wipe", "iris", "grade", "track"]);
|
|
1302
|
+
var PLACEMENT = /* @__PURE__ */ new Set(["opacity", "rect", "rotation", "blend", "wipe", "iris", "grade", "track", "mask"]);
|
|
1170
1303
|
var round = (_k, v) => typeof v === "number" ? Math.round(v * 100) / 100 : v;
|
|
1171
1304
|
function videoEffectLayerKey(layer, width, height) {
|
|
1172
1305
|
const content = layer.transitionEffect ? layer : Object.fromEntries(Object.entries(layer).filter(([k]) => !PLACEMENT.has(k)));
|
|
@@ -1459,6 +1592,14 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
|
|
|
1459
1592
|
inner = `<rect ${box} fill="#334155" stroke="#94a3b8" stroke-width="2"/><text x="${n(rect.x + rect.width / 2)}" y="${n(rect.y + rect.height / 2)}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="${n(size)}" fill="#e2e8f0">${esc(label)}</text>`;
|
|
1460
1593
|
}
|
|
1461
1594
|
}
|
|
1595
|
+
if (layer.mask) {
|
|
1596
|
+
const m = layer.mask;
|
|
1597
|
+
const blur = m.feather > 0 ? `<filter id="mf${i}" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${n(m.feather / 2)}"/></filter>` : "";
|
|
1598
|
+
if (blur) defs.push(blur);
|
|
1599
|
+
const shape = `<path d="${videoMaskSvgPath(m)}" fill="${m.inverted ? "black" : "white"}"${blur ? ` filter="url(#mf${i})"` : ""}/>`;
|
|
1600
|
+
defs.push(`<mask id="m${i}" maskUnits="userSpaceOnUse" x="-100000" y="-100000" width="200000" height="200000">${m.inverted ? '<rect x="-100000" y="-100000" width="200000" height="200000" fill="white"/>' : ""}${shape}</mask>`);
|
|
1601
|
+
inner = `<g mask="url(#m${i})">${inner}</g>`;
|
|
1602
|
+
}
|
|
1462
1603
|
let item = attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner;
|
|
1463
1604
|
if (layer.iris) {
|
|
1464
1605
|
defs.push(`<clipPath id="i${i}"><circle cx="${n(frame.width / 2)}" cy="${n(frame.height / 2)}" r="${n(videoIrisRadius(frame, layer.iris.progress))}"/></clipPath>`);
|
|
@@ -1489,6 +1630,18 @@ function videoContactSheetSvg(project, times, options = {}) {
|
|
|
1489
1630
|
});
|
|
1490
1631
|
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>`;
|
|
1491
1632
|
}
|
|
1633
|
+
function applyVideoMask(g, mask, scale) {
|
|
1634
|
+
if (!g.moveTo || !g.lineTo || !g.closePath || !g.fill || mask.points.length < 3) return false;
|
|
1635
|
+
g.globalAlpha = 1;
|
|
1636
|
+
g.globalCompositeOperation = mask.inverted ? "destination-out" : "destination-in";
|
|
1637
|
+
g.filter = mask.feather > 0 ? `blur(${Math.max(0.5, mask.feather * scale / 2)}px)` : "none";
|
|
1638
|
+
g.fillStyle = "#000000";
|
|
1639
|
+
g.beginPath();
|
|
1640
|
+
mask.points.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y));
|
|
1641
|
+
g.closePath();
|
|
1642
|
+
g.fill();
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1492
1645
|
function paintVideoLayer(ctx, frame, layer, sourceFor, options) {
|
|
1493
1646
|
const { rect } = layer;
|
|
1494
1647
|
if (layer.iris) {
|
|
@@ -1696,10 +1849,10 @@ function drawVideoLayers(ctx, frame, layers, sourceFor, scale, options, depth, s
|
|
|
1696
1849
|
}
|
|
1697
1850
|
const setting = layer.grade?.lut;
|
|
1698
1851
|
const lut = setting ? options.lutFor?.(setting.src) : null;
|
|
1699
|
-
const scratch = lut ? options.scratch?.(cw, ch, 2 * depth + 1) : null;
|
|
1852
|
+
const scratch = lut || layer.mask ? options.scratch?.(cw, ch, 2 * depth + 1) : null;
|
|
1700
1853
|
ctx.save();
|
|
1701
1854
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
1702
|
-
if (
|
|
1855
|
+
if (scratch) {
|
|
1703
1856
|
const s = scratch.ctx;
|
|
1704
1857
|
s.save();
|
|
1705
1858
|
s.setTransform(1, 0, 0, 1, 0, 0);
|
|
@@ -1709,9 +1862,23 @@ function drawVideoLayers(ctx, frame, layers, sourceFor, scale, options, depth, s
|
|
|
1709
1862
|
s.globalCompositeOperation = "source-over";
|
|
1710
1863
|
paintVideoLayer(s, frame, layer, source, options);
|
|
1711
1864
|
s.restore();
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1865
|
+
if (lut) {
|
|
1866
|
+
const img = s.getImageData(0, 0, cw, ch);
|
|
1867
|
+
applyVideoLutToPixels(lut, img.data, setting.strength ?? 1, setting.preserveSkinTones ?? 0);
|
|
1868
|
+
s.putImageData(img, 0, 0);
|
|
1869
|
+
}
|
|
1870
|
+
if (layer.mask) {
|
|
1871
|
+
s.save();
|
|
1872
|
+
s.setTransform(scale, 0, 0, scale, 0, 0);
|
|
1873
|
+
const { rect } = layer;
|
|
1874
|
+
if (layer.rotation) {
|
|
1875
|
+
s.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
1876
|
+
s.rotate(layer.rotation * Math.PI / 180);
|
|
1877
|
+
s.translate(-(rect.x + rect.width / 2), -(rect.y + rect.height / 2));
|
|
1878
|
+
}
|
|
1879
|
+
applyVideoMask(s, layer.mask, scale);
|
|
1880
|
+
s.restore();
|
|
1881
|
+
}
|
|
1715
1882
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1716
1883
|
ctx.globalAlpha = layer.opacity;
|
|
1717
1884
|
if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
|
|
@@ -1778,6 +1945,7 @@ function toVideoEdl(project, options = {}) {
|
|
|
1778
1945
|
const sourceAt = (clip, time) => clip.type === "media" ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : time - clip.start;
|
|
1779
1946
|
const row = (n2, reel, kind, srcIn, srcOut, recIn, recOut) => `${n2} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${rtc(recIn)} ${rtc(recOut)}`;
|
|
1780
1947
|
for (const clip of sortedVideoClips(track)) {
|
|
1948
|
+
if (clip.type === "adjustment") continue;
|
|
1781
1949
|
const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
|
|
1782
1950
|
const reel = clip.type === "media" ? reelName(asset) : "BL";
|
|
1783
1951
|
const end = videoClipEnd(clip);
|
|
@@ -1810,6 +1978,27 @@ function toVideoEdl(project, options = {}) {
|
|
|
1810
1978
|
function videoFfmpegUnsupportedEffects(project) {
|
|
1811
1979
|
return allVideoTracks(project).filter(({ track }) => track.kind === "video" && !track.hidden).flatMap(({ track }) => track.clips.flatMap(videoClipEffectUses)).filter((u) => u.enabled);
|
|
1812
1980
|
}
|
|
1981
|
+
function videoFfmpegUnsupportedFeatures(project) {
|
|
1982
|
+
const out = [];
|
|
1983
|
+
for (const { track } of allVideoTracks(project)) {
|
|
1984
|
+
if (track.kind !== "video" || track.hidden) continue;
|
|
1985
|
+
for (const clip of track.clips) {
|
|
1986
|
+
if (clip.mask) out.push(`clip "${clip.id}" has a mask`);
|
|
1987
|
+
if (clip.type !== "adjustment") continue;
|
|
1988
|
+
const why = [];
|
|
1989
|
+
if ((clip.scope ?? "underneath") === "track") why.push("changes one track only");
|
|
1990
|
+
if (clip.opacity !== void 0 && clip.opacity !== 1) why.push("is not at full opacity");
|
|
1991
|
+
if (clip.fadeIn || clip.fadeOut) why.push("fades");
|
|
1992
|
+
if (clip.blend && clip.blend !== "normal") why.push("blends");
|
|
1993
|
+
if (clip.transition) why.push("has a transition");
|
|
1994
|
+
if (clip.animateIn || clip.animateOut) why.push("is animated");
|
|
1995
|
+
const lut = normalizeVideoGradeLut(clip.grade?.lut);
|
|
1996
|
+
if (lut && (lut.strength ?? 1) < 1) why.push("mixes its LUT at part strength");
|
|
1997
|
+
if (why.length) out.push(`adjustment clip "${clip.id}" ${why.join(", ")}`);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return out;
|
|
2001
|
+
}
|
|
1813
2002
|
function loudnormFilter(target = {}, measure = false) {
|
|
1814
2003
|
const I = target.integrated ?? -14;
|
|
1815
2004
|
const TP = target.truePeak ?? -1;
|
|
@@ -1902,6 +2091,13 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1902
2091
|
);
|
|
1903
2092
|
}
|
|
1904
2093
|
}
|
|
2094
|
+
if (!measure && !frames) {
|
|
2095
|
+
const features = videoFfmpegUnsupportedFeatures(project);
|
|
2096
|
+
if (features.length)
|
|
2097
|
+
throw new Error(
|
|
2098
|
+
`ffmpeg's filters cannot draw ${features.slice(0, 5).join("; ")}${features.length > 5 ? "; ..." : ""}. Render the picture frame by frame with renderVideoFrameExact (the preview's drawing) to PNGs or raw RGBA and pass them as frames: { input: "frames/%06d.png" }; ffmpeg still mixes the sound. Or remove the mask, or keep adjustment clips to a full-opacity grade over what is underneath or the whole frame.`
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
1905
2101
|
if (frames) {
|
|
1906
2102
|
if (frames.format === "rawvideo") inputs.push(["-f", "rawvideo", "-pix_fmt", "rgba", "-s", `${W}x${H}`, "-framerate", String(fps), "-i", frames.input]);
|
|
1907
2103
|
else inputs.push(["-framerate", String(fps), "-i", frames.input]);
|
|
@@ -1981,11 +2177,33 @@ function buildFfmpeg(project, options, measure) {
|
|
|
1981
2177
|
};
|
|
1982
2178
|
let ak = 0;
|
|
1983
2179
|
let q = 0;
|
|
2180
|
+
const adjust = (cur2, clip) => {
|
|
2181
|
+
const enable = `enable='between(t,${sec(clip.start)},${sec(videoClipEnd(clip))})'`;
|
|
2182
|
+
const steps = videoGradeFfmpegFilters(clip.grade).map((f) => `${f}:${enable}`);
|
|
2183
|
+
const lut = normalizeVideoGradeLut(clip.grade?.lut);
|
|
2184
|
+
if (lut) {
|
|
2185
|
+
const path = options.resolveLut ? options.resolveLut(lut.src) : lut.src;
|
|
2186
|
+
if (/^(blob|data|https?):/i.test(path)) notes.push(`ffmpeg's lut3d reads only local files, and adjustment clip "${clip.id}"'s LUT is "${path.slice(0, 80)}"; save it and pass resolveLut to give its path.`);
|
|
2187
|
+
steps.push("format=gbrap", `lut3d=file=${ffmpegFilterValue(path)}:interp=tetrahedral:${enable}`);
|
|
2188
|
+
if (lut.preserveSkinTones) notes.push(`Adjustment clip "${clip.id}"'s LUT protects skin tones in the preview, which ffmpeg's lut3d cannot; render frames with renderVideoFrameExact to keep it.`);
|
|
2189
|
+
}
|
|
2190
|
+
if (!steps.length) return cur2;
|
|
2191
|
+
const next = `adj${k++}`;
|
|
2192
|
+
graph.push(`[${cur2}]format=rgba,${steps.join(",")},format=yuva420p[${next}]`);
|
|
2193
|
+
return next;
|
|
2194
|
+
};
|
|
1984
2195
|
const compose = (tracks, base, length, scope) => {
|
|
1985
2196
|
let cur2 = base;
|
|
1986
2197
|
const sounds = [];
|
|
2198
|
+
const adjustAll = [];
|
|
1987
2199
|
for (const track of [...tracks].reverse()) {
|
|
1988
2200
|
for (const raw of sortedVideoClips(track)) {
|
|
2201
|
+
if (raw.type === "adjustment") {
|
|
2202
|
+
if (!scope.picture || track.kind !== "video" || track.hidden) continue;
|
|
2203
|
+
if ((raw.scope ?? "underneath") === "all") adjustAll.push(raw);
|
|
2204
|
+
else if (cur2) cur2 = adjust(cur2, raw);
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
1989
2207
|
const clip = videoClipAnimated(raw, project);
|
|
1990
2208
|
const { lead, tail } = videoClipOverhang(track, clip);
|
|
1991
2209
|
const t0 = clip.start - lead;
|
|
@@ -2116,6 +2334,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2116
2334
|
const into2 = `q${next}`;
|
|
2117
2335
|
graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[${into2}]`);
|
|
2118
2336
|
const lut3d = `lut3d=file=${file}:interp=tetrahedral`;
|
|
2337
|
+
if (lut.preserveSkinTones) notes.push(`Clip "${clip.id}"'s LUT protects skin tones in the preview, which ffmpeg's lut3d cannot; render frames with renderVideoFrameExact to keep it.`);
|
|
2119
2338
|
if ((lut.strength ?? 1) >= 1) graph.push(`[${into2}]${lut3d}[r${next}]`);
|
|
2120
2339
|
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}]`);
|
|
2121
2340
|
source = `[r${next}]`;
|
|
@@ -2196,6 +2415,7 @@ function buildFfmpeg(project, options, measure) {
|
|
|
2196
2415
|
}
|
|
2197
2416
|
}
|
|
2198
2417
|
}
|
|
2418
|
+
for (const clip of adjustAll) if (cur2) cur2 = adjust(cur2, clip);
|
|
2199
2419
|
return { video: cur2, audio: sounds };
|
|
2200
2420
|
};
|
|
2201
2421
|
const top = compose(project.tracks, measure || frames ? null : "base", D, { within: [], picture: !measure && !frames, sound: true });
|
|
@@ -2250,7 +2470,9 @@ export {
|
|
|
2250
2470
|
videoTransitionAudioCurve,
|
|
2251
2471
|
VIDEO_BLEND_MODES,
|
|
2252
2472
|
VIDEO_CLIP_ANIMATION_EFFECTS,
|
|
2473
|
+
VIDEO_MASK_SHAPES,
|
|
2253
2474
|
VIDEO_FADE_CURVES,
|
|
2475
|
+
VIDEO_ADJUSTMENT_SCOPES,
|
|
2254
2476
|
VIDEO_PLACEMENT_MODES,
|
|
2255
2477
|
VIDEO_CLIP_TYPES,
|
|
2256
2478
|
VIDEO_EASE_NAMES,
|
|
@@ -2274,6 +2496,7 @@ export {
|
|
|
2274
2496
|
videoLutFromFunction,
|
|
2275
2497
|
videoLutToCube,
|
|
2276
2498
|
applyVideoLut,
|
|
2499
|
+
videoSkinToneWeight,
|
|
2277
2500
|
applyVideoLutToPixels,
|
|
2278
2501
|
videoLutSvgApproximation,
|
|
2279
2502
|
VideoLutCache,
|
|
@@ -2305,6 +2528,7 @@ export {
|
|
|
2305
2528
|
videoSlowMotionRamp,
|
|
2306
2529
|
VIDEO_TIME_EPSILON,
|
|
2307
2530
|
videoClipEnd,
|
|
2531
|
+
videoClipTypeLabel,
|
|
2308
2532
|
videoTrackSyncLocked,
|
|
2309
2533
|
videoClipSpeed,
|
|
2310
2534
|
videoClipSourceRange,
|
|
@@ -2343,6 +2567,12 @@ export {
|
|
|
2343
2567
|
videoClipAnimationProperties,
|
|
2344
2568
|
videoClipAnimationSpans,
|
|
2345
2569
|
videoClipAnimated,
|
|
2570
|
+
VIDEO_MASK_DEFAULTS,
|
|
2571
|
+
normalizeVideoClipMask,
|
|
2572
|
+
videoMaskPolygon,
|
|
2573
|
+
videoLayerMask,
|
|
2574
|
+
videoMaskSvgPath,
|
|
2575
|
+
videoMaskContains,
|
|
2346
2576
|
videoEffectValuesAt,
|
|
2347
2577
|
videoTransformAt,
|
|
2348
2578
|
placeVideoRect,
|
|
@@ -2374,6 +2604,7 @@ export {
|
|
|
2374
2604
|
renderVideoFrameExact,
|
|
2375
2605
|
toVideoEdl,
|
|
2376
2606
|
videoFfmpegUnsupportedEffects,
|
|
2607
|
+
videoFfmpegUnsupportedFeatures,
|
|
2377
2608
|
parseVideoLoudnessMeasurement,
|
|
2378
2609
|
ffmpegFilterValue,
|
|
2379
2610
|
videoShellQuote,
|