fablecut 1.12.1 → 1.14.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/CLAUDE.md +657 -11
- package/README.md +45 -10
- package/app.js +3352 -212
- package/color.js +47 -14
- package/comp.js +195 -0
- package/edit-ops.js +70 -17
- package/export-jobs.js +8 -5
- package/index.html +148 -62
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mask.js +467 -0
- package/mcp-server.js +639 -19
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +25 -2
- package/server.js +42 -2
- package/style.css +188 -24
- package/templates.js +576 -0
- package/tracker.js +253 -0
package/color.js
CHANGED
|
@@ -42,16 +42,20 @@
|
|
|
42
42
|
layers: [{ name?, on?, qualifier?, mask?, …grade keys }]
|
|
43
43
|
qualifier: { hue?: [centre°, width°, soft°], sat?: [lo, hi, soft],
|
|
44
44
|
luma?: [lo, hi, soft], invert? } — measured on the layer's input
|
|
45
|
-
mask: { shape: ellipse | rect | poly, x, y (centre, 0…1 of the picture),
|
|
45
|
+
mask: { shape: ellipse | rect | poly | bezier, x, y (centre, 0…1 of the picture),
|
|
46
46
|
w, h (size, 0…1), rotation°, feather (0…0.5 of the height),
|
|
47
47
|
invert?, points?: [[dx, dy], …] (poly, relative to x, y),
|
|
48
|
+
bezier: [[dx, dy, inX?, inY?, outX?, outY?], …] — anchors with
|
|
49
|
+
handles, the outline mask.js draws (a free-hand stroke,
|
|
50
|
+
{ shape: "freehand", stroke: [[u, v], …] }, is fitted into one),
|
|
48
51
|
keys?: [{ t, ease?, x?, y?, w?, h?, rotation?, feather? }] }
|
|
49
52
|
matte = qualifier × mask (each 1 when absent); out = mix(in, graded, matte). */
|
|
50
53
|
(function (root, factory) {
|
|
51
|
-
const
|
|
52
|
-
|
|
54
|
+
const node = typeof module === "object" && module.exports;
|
|
55
|
+
const api = factory(node ? require("./mask") : root.FableCutMask);
|
|
56
|
+
if (node) module.exports = api;
|
|
53
57
|
else root.FableCutColor = api;
|
|
54
|
-
})(typeof self !== "undefined" ? self : this, function () {
|
|
58
|
+
})(typeof self !== "undefined" ? self : this, function (MaskLib) {
|
|
55
59
|
"use strict";
|
|
56
60
|
|
|
57
61
|
const WHEELS = ["lift", "gamma", "gain", "offset"];
|
|
@@ -91,7 +95,9 @@
|
|
|
91
95
|
const HUE_N = 360; // hue-curve lookup-table size (also sat 0…1 for satLuma)
|
|
92
96
|
const MAX_LAYERS = 8;
|
|
93
97
|
const MAX_POLY = 16;
|
|
94
|
-
const
|
|
98
|
+
const MAX_BEZIER = 24; // bezier anchors on a grade mask
|
|
99
|
+
const MAX_OUTLINE = 96; // the flattened outline the shader walks
|
|
100
|
+
const MASK_SHAPES = ["ellipse", "rect", "poly", "bezier"];
|
|
95
101
|
const MASK_KEYED = ["x", "y", "w", "h", "rotation", "feather"];
|
|
96
102
|
const KEY_RANGE = { x: [-1, 2], y: [-1, 2], w: [0.001, 4], h: [0.001, 4], rotation: [-720, 720], feather: [0, 0.5] };
|
|
97
103
|
const WB_STOPS = 1 / 100; // temp / tint ±100 → ±1 stop per channel
|
|
@@ -487,6 +493,12 @@
|
|
|
487
493
|
if (m == null) return null;
|
|
488
494
|
const bad = (msg) => { if (strict) throw new Error(`${where}.mask${msg}`); return null; };
|
|
489
495
|
if (typeof m !== "object" || Array.isArray(m)) return bad(" must be {shape, x, y, w, h, rotation?, feather?, invert?}");
|
|
496
|
+
if (m.shape === "freehand") {
|
|
497
|
+
if (!Array.isArray(m.stroke) || m.stroke.length < 3 || m.stroke.some((p) => !Array.isArray(p) || !isNum(p[0]) || !isNum(p[1])))
|
|
498
|
+
return bad(".stroke must be at least 3 [u, v] points (fractions of the picture)");
|
|
499
|
+
m = { ...m, ...MaskLib.fitStroke(m.stroke, m.aspect, MAX_BEZIER), shape: "bezier" };
|
|
500
|
+
delete m.stroke; delete m.aspect;
|
|
501
|
+
}
|
|
490
502
|
const shape = m.shape == null ? "ellipse" : m.shape;
|
|
491
503
|
if (!MASK_SHAPES.includes(shape)) return bad(`.shape must be ${MASK_SHAPES.join(" | ")}`);
|
|
492
504
|
for (const k of Object.keys(m)) if (![...MASK_KEYED, "shape", "invert", "points", "keys"].includes(k)) bad(`.${k}: unknown key`);
|
|
@@ -509,6 +521,11 @@
|
|
|
509
521
|
if (!Array.isArray(pts) || pts.length < 3 || pts.length > MAX_POLY || pts.some((p) => !Array.isArray(p) || p.length !== 2 || !p.every(isNum)))
|
|
510
522
|
return bad(`.points must be 3…${MAX_POLY} [dx, dy] pairs around x, y (fractions of the picture)`);
|
|
511
523
|
out.points = pts.map(([a, b]) => [+clamp(a, -2, 2).toFixed(4), +clamp(b, -2, 2).toFixed(4)]);
|
|
524
|
+
} else if (shape === "bezier") {
|
|
525
|
+
const pts = m.points;
|
|
526
|
+
if (!Array.isArray(pts) || pts.length < 3 || pts.length > MAX_BEZIER || pts.some((p) => !Array.isArray(p) || (p.length !== 2 && p.length !== 6) || !p.every(isNum)))
|
|
527
|
+
return bad(`.points must be 3…${MAX_BEZIER} anchors [dx, dy] or [dx, dy, inX, inY, outX, outY] around x, y (fractions of the picture)`);
|
|
528
|
+
out.points = pts.map((p) => p.map((v) => +clamp(v, -2, 2).toFixed(4)));
|
|
512
529
|
}
|
|
513
530
|
if (m.keys != null) {
|
|
514
531
|
if (!Array.isArray(m.keys)) return bad(".keys must be a list of {t, x?, y?, w?, h?, rotation?, feather?}");
|
|
@@ -596,10 +613,26 @@
|
|
|
596
613
|
const qx = Math.abs(px) - rx, qy = Math.abs(py) - ry;
|
|
597
614
|
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0);
|
|
598
615
|
}
|
|
599
|
-
if (m.shape === "poly") return polyDist(m
|
|
616
|
+
if (m.shape === "poly" || m.shape === "bezier") return polyDist(outlineAt(m, aspect), px, py);
|
|
600
617
|
const k0 = Math.hypot(px / rx, py / ry), k1 = Math.hypot(px / (rx * rx), py / (ry * ry));
|
|
601
618
|
return k0 < 1e-6 ? -Math.min(rx, ry) : k0 * (k0 - 1) / k1;
|
|
602
619
|
}
|
|
620
|
+
/** A bezier mask's outline as a polygon around its centre (fractions of the
|
|
621
|
+
* picture, unrotated) — at most MAX_OUTLINE corners, the shader's budget. */
|
|
622
|
+
/** The poly / bezier outline in aspect-scaled units, cached per points array — the CPU path asks per pixel. */
|
|
623
|
+
const outlines = new WeakMap();
|
|
624
|
+
function outlineAt(m, aspect) {
|
|
625
|
+
let o = outlines.get(m.points);
|
|
626
|
+
if (!o || o.aspect !== aspect) {
|
|
627
|
+
o = { aspect, pts: (m.shape === "bezier" ? bezierOutline(m) : m.points).map(([a, b]) => [a * aspect, b]) };
|
|
628
|
+
outlines.set(m.points, o);
|
|
629
|
+
}
|
|
630
|
+
return o.pts;
|
|
631
|
+
}
|
|
632
|
+
function bezierOutline(m) {
|
|
633
|
+
const A = MaskLib.anchors({ shape: "bezier", x: 0, y: 0, scale: 1, rotation: 0, points: m.points }, 1, 1);
|
|
634
|
+
return MaskLib.flatten(A, Math.max(1, Math.min(16, Math.floor(MAX_OUTLINE / A.length))));
|
|
635
|
+
}
|
|
603
636
|
function polyDist(pts, px, py) {
|
|
604
637
|
let d = (px - pts[0][0]) ** 2 + (py - pts[0][1]) ** 2, s = 1;
|
|
605
638
|
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i++) {
|
|
@@ -757,10 +790,10 @@
|
|
|
757
790
|
u.uMRot = m ? m.rotation * Math.PI / 180 : 0;
|
|
758
791
|
u.uMFeather = m ? Math.max(0.002, m.feather) : 0.002;
|
|
759
792
|
u.uMInv = m && m.invert ? 1 : 0;
|
|
760
|
-
const pts = new Float32Array(
|
|
761
|
-
|
|
793
|
+
const pts = new Float32Array(MAX_OUTLINE * 2), P = m && m.points ? (m.shape === "bezier" ? bezierOutline(m) : m.points) : [];
|
|
794
|
+
P.forEach(([a, b], i) => { pts[i * 2] = a; pts[i * 2 + 1] = b; });
|
|
762
795
|
u.uMPts = pts;
|
|
763
|
-
u.uMN =
|
|
796
|
+
u.uMN = P.length;
|
|
764
797
|
passes.push(u);
|
|
765
798
|
}
|
|
766
799
|
return passes;
|
|
@@ -800,10 +833,10 @@ uniform int uLayer, uMatte, uPremul; // layer pass: mix by its matte · show t
|
|
|
800
833
|
uniform int uQOn, uQInv;
|
|
801
834
|
uniform ivec3 uQUse; // hue, sat, luma ranges in use
|
|
802
835
|
uniform vec3 uQHue, uQSat, uQLuma; // hue: centre, half width, soft (turns) · sat / luma: low, high, soft
|
|
803
|
-
uniform int uMShape, uMInv, uMN; // 0 none · 1 ellipse · 2 rect · 3 poly
|
|
836
|
+
uniform int uMShape, uMInv, uMN; // 0 none · 1 ellipse · 2 rect · 3 poly · 4 bezier (flattened)
|
|
804
837
|
uniform vec2 uMC, uMW;
|
|
805
838
|
uniform float uMRot, uMFeather, uAspect;
|
|
806
|
-
uniform vec2 uMPts[${
|
|
839
|
+
uniform vec2 uMPts[${MAX_OUTLINE}];
|
|
807
840
|
in vec2 vPos;
|
|
808
841
|
out vec4 outColor;
|
|
809
842
|
vec4 lutAt(sampler2D t, int n, float x) {
|
|
@@ -862,10 +895,10 @@ float maskDist(vec2 uv) {
|
|
|
862
895
|
vec2 q = abs(p) - r;
|
|
863
896
|
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
|
|
864
897
|
}
|
|
865
|
-
if (uMShape
|
|
898
|
+
if (uMShape >= 3) {
|
|
866
899
|
vec2 v0 = uMPts[0] * vec2(uAspect, 1.0);
|
|
867
900
|
float dd = dot(p - v0, p - v0), s = 1.0;
|
|
868
|
-
for (int i = 0; i < ${
|
|
901
|
+
for (int i = 0; i < ${MAX_OUTLINE}; i++) {
|
|
869
902
|
if (i >= uMN) break;
|
|
870
903
|
int j = i == 0 ? uMN - 1 : i - 1;
|
|
871
904
|
vec2 vi = uMPts[i] * vec2(uAspect, 1.0), vj = uMPts[j] * vec2(uAspect, 1.0);
|
|
@@ -1034,7 +1067,7 @@ void main() {
|
|
|
1034
1067
|
GRADE_PARAMS, GRADE_KEYS, WHEELS, GRADE_VERT, GRADE_FRAG, SKIN_ANGLE,
|
|
1035
1068
|
CURVE_CHANNELS, HUE_CURVES, HUE_KEYS, CURVE_N, HUE_N, BAND,
|
|
1036
1069
|
normalizeGrade, fullGrade, isNeutral, mergeGrade, summarizeGrade, withoutCurves,
|
|
1037
|
-
MAX_LAYERS, MAX_POLY, MASK_SHAPES, MASK_KEYED, mergeLayer, layerGrade, maskAt, gradeAt, qualAlpha, maskAlpha, maskDist, gradePasses,
|
|
1070
|
+
MAX_LAYERS, MAX_POLY, MAX_BEZIER, MASK_SHAPES, MASK_KEYED, mergeLayer, layerGrade, maskAt, gradeAt, qualAlpha, maskAlpha, maskDist, gradePasses,
|
|
1038
1071
|
monotone, curveFn, rgbToHsv, hsvToRgb,
|
|
1039
1072
|
wbGains, solveWhiteBalance, srgbToLinear, linearToSrgb, wheelToRgb, rgbToWheel, ycbcr,
|
|
1040
1073
|
prepareGrade, gradePixel, gradeImageData, gradeUniforms,
|
package/comp.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/* FableCut compositions — nested sequences. Zero dependencies beyond edit-ops
|
|
2
|
+
(for re-basing keyframes). Loaded by the editor as a plain script (global
|
|
3
|
+
`FableCutComp`) and required by the MCP server and the tests.
|
|
4
|
+
|
|
5
|
+
A composition lives in `project.comps`:
|
|
6
|
+
{ id, name, width, height, duration, background?, tracks?: [{id, kind}], clips: [ …clips… ] }
|
|
7
|
+
Its clips are ordinary clips (any kind, comp clips too) on the comp's own
|
|
8
|
+
tracks, with times inside the comp. A clip of kind "comp" places one on a
|
|
9
|
+
timeline: { kind: "comp", compId, start, in, duration, props… } — `in` is
|
|
10
|
+
where in the comp it starts. It draws like a picture of the comp's size (so
|
|
11
|
+
it moves, scales, masks, grades and blends like an image), and the media in
|
|
12
|
+
it — video frames, audio — plays and exports as if it were on the timeline.
|
|
13
|
+
|
|
14
|
+
flatten() turns every comp clip's contents into "virtual" clips on the main
|
|
15
|
+
timeline: ids "<compClip>><clip>", start shifted to timeline time, trimmed
|
|
16
|
+
to the comp clip's window, keyframes / masks / shape keys re-based, parents
|
|
17
|
+
and links renamed into the same instance. Playback, export seeking, the
|
|
18
|
+
audio mix and the comp renderer all work from that list. */
|
|
19
|
+
(function (root, factory) {
|
|
20
|
+
const node = typeof module === "object" && module.exports;
|
|
21
|
+
const api = factory(node ? require("./edit-ops") : root.FableCutEdit);
|
|
22
|
+
if (node) module.exports = api;
|
|
23
|
+
else root.FableCutComp = api;
|
|
24
|
+
})(typeof self !== "undefined" ? self : this, function (EditLib) {
|
|
25
|
+
"use strict";
|
|
26
|
+
|
|
27
|
+
const MAX_DEPTH = 4;
|
|
28
|
+
const SEP = ">";
|
|
29
|
+
const DEFAULT_TRACKS = [
|
|
30
|
+
{ id: "V3", kind: "video" }, { id: "V2", kind: "video" }, { id: "V1", kind: "video" },
|
|
31
|
+
{ id: "A1", kind: "audio" }, { id: "A2", kind: "audio" },
|
|
32
|
+
];
|
|
33
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
34
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
35
|
+
const r4 = (v) => +(+v).toFixed(4);
|
|
36
|
+
const compTracks = (comp) => (Array.isArray(comp.tracks) && comp.tracks.length ? comp.tracks : DEFAULT_TRACKS);
|
|
37
|
+
|
|
38
|
+
/** A comp, validated. strict: throw on bad input (agent). doc: for width / height defaults. */
|
|
39
|
+
function normalizeComp(c, strict = false, doc = {}) {
|
|
40
|
+
const bad = (m) => { if (strict) throw new Error(m); return null; };
|
|
41
|
+
if (!isObj(c)) return bad("a comp must be an object like {id, name, duration, clips:[…]}");
|
|
42
|
+
if (typeof c.id !== "string" || !c.id.trim()) return bad("a comp needs an id");
|
|
43
|
+
const out = { ...c, id: c.id.trim() };
|
|
44
|
+
out.name = typeof c.name === "string" && c.name.trim() ? c.name.trim().slice(0, 80) : out.id;
|
|
45
|
+
for (const [k, def] of [["width", doc.width || 1280], ["height", doc.height || 720]]) {
|
|
46
|
+
const v = c[k] == null ? def : c[k];
|
|
47
|
+
if (!Number.isInteger(v) || v < 16 || v > 8192) { bad(`comp ${out.id}: ${k} must be a whole number of px (16…8192)`); out[k] = def; }
|
|
48
|
+
else out[k] = v;
|
|
49
|
+
}
|
|
50
|
+
if (!isNum(c.duration) || c.duration <= 0) return bad(`comp ${out.id}: duration must be a number of seconds > 0`);
|
|
51
|
+
out.duration = r4(c.duration);
|
|
52
|
+
if (c.background != null && typeof c.background !== "string") bad(`comp ${out.id}: background is a colour string (or leave it out for transparent)`);
|
|
53
|
+
if (c.tracks != null) {
|
|
54
|
+
if (!Array.isArray(c.tracks) || c.tracks.some((t) => !isObj(t) || typeof t.id !== "string" || !["video", "audio"].includes(t.kind)))
|
|
55
|
+
bad(`comp ${out.id}: tracks is a list of {id, kind:"video"|"audio"}`);
|
|
56
|
+
}
|
|
57
|
+
if (c.clips != null && !Array.isArray(c.clips)) return bad(`comp ${out.id}: clips must be a list`);
|
|
58
|
+
out.clips = Array.isArray(c.clips) ? c.clips : [];
|
|
59
|
+
for (const x of out.clips) {
|
|
60
|
+
if (!isObj(x) || typeof x.id !== "string" || !isNum(x.start) || !isNum(x.duration))
|
|
61
|
+
return bad(`comp ${out.id}: every clip needs id, numeric start and duration`);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function getComp(doc, id) { return (doc && Array.isArray(doc.comps) ? doc.comps : []).find((c) => c.id === id) || null; }
|
|
66
|
+
/** Comp ids that comp `id` uses, directly or through nested comps. */
|
|
67
|
+
function usedComps(doc, id, out = new Set(), depth = 0) {
|
|
68
|
+
const comp = getComp(doc, id);
|
|
69
|
+
if (!comp || depth > 32) return out;
|
|
70
|
+
for (const c of comp.clips || []) if (c.kind === "comp" && c.compId && !out.has(c.compId)) { out.add(c.compId); usedComps(doc, c.compId, out, depth + 1); }
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** Would placing comp `childId` inside comp `parentId` (null = the main timeline) make a loop? */
|
|
74
|
+
function wouldCycle(doc, parentId, childId) {
|
|
75
|
+
if (!parentId) return false;
|
|
76
|
+
if (parentId === childId) return true;
|
|
77
|
+
return usedComps(doc, childId).has(parentId);
|
|
78
|
+
}
|
|
79
|
+
/** Where comp `id` is used: [{in: compId | null (main), clip}] */
|
|
80
|
+
function usages(doc, id) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const c of doc.clips || []) if (c.kind === "comp" && c.compId === id) out.push({ in: null, clip: c });
|
|
83
|
+
for (const comp of doc.comps || []) for (const c of comp.clips || []) if (c.kind === "comp" && c.compId === id) out.push({ in: comp.id, clip: c });
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ── Flattening ── */
|
|
88
|
+
const clipEnd = (c) => c.start + c.duration;
|
|
89
|
+
const speedOf = (c) => (c.props && +c.props.speed) || 1;
|
|
90
|
+
/** Keyframe times re-based by -offset, every key kept (keys before 0 still shape the curve). */
|
|
91
|
+
function shiftAll(kfs, offset) {
|
|
92
|
+
if (!kfs) return kfs;
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [k, arr] of Object.entries(kfs)) out[k] = Array.isArray(arr) ? arr.map((kf) => ({ ...kf, t: r4(kf.t - offset) })) : arr;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/** A comp's clip as a clip on the timeline: shifted by `offset`, trimmed to `win`, renamed under `prefix`. */
|
|
98
|
+
function virtualize(inner, prefix, offset, win, inst, on) {
|
|
99
|
+
const start = inner.start + offset, end = start + inner.duration;
|
|
100
|
+
const a = Math.max(start, win[0]), b = Math.min(end, win[1]);
|
|
101
|
+
if (b - a <= 1e-6) return null;
|
|
102
|
+
const head = a - start;
|
|
103
|
+
const v = { ...inner, id: prefix + inner.id, start: r4(a), duration: r4(b - a), _comp: inst, _src: inner.id, _on: on && inner.disabled !== true };
|
|
104
|
+
if (inner.props) v.props = { ...inner.props };
|
|
105
|
+
if (head > 1e-6) {
|
|
106
|
+
const shifted = { ...inner, start };
|
|
107
|
+
if (inner.kind === "video" || inner.kind === "audio") v.in = r4(EditLib.mediaTimeAt(shifted, a));
|
|
108
|
+
else if (inner.kind === "comp") v.in = r4((inner.in || 0) + head);
|
|
109
|
+
v.keyframes = shiftAll(inner.keyframes, head);
|
|
110
|
+
v.transitionIn = undefined;
|
|
111
|
+
if (v.props) EditLib.shiftMasks(v, head);
|
|
112
|
+
}
|
|
113
|
+
if (end - b > 1e-6) v.transitionOut = undefined;
|
|
114
|
+
if (inner.parent) v.parent = prefix + inner.parent;
|
|
115
|
+
if (inner.linkGroup) v.linkGroup = prefix + inner.linkGroup;
|
|
116
|
+
if (inner.linkedId) v.linkedId = prefix + inner.linkedId;
|
|
117
|
+
return v;
|
|
118
|
+
}
|
|
119
|
+
/** Every clip the comp clips in `clips` bring onto the timeline, depth first:
|
|
120
|
+
* [{…virtual clip, _comp: the comp clip's id (its instance), _on: renders}]. */
|
|
121
|
+
function flatten(doc, clips = doc.clips, opts = {}) {
|
|
122
|
+
const out = [];
|
|
123
|
+
const walk = (list, depth, seen, renders) => {
|
|
124
|
+
for (const c of list || []) {
|
|
125
|
+
if (c.kind !== "comp") continue;
|
|
126
|
+
const comp = getComp(doc, c.compId);
|
|
127
|
+
if (!comp || seen.has(comp.id) || depth >= MAX_DEPTH) continue;
|
|
128
|
+
const on = renders(c);
|
|
129
|
+
const offset = c.start - (c.in || 0), win = [c.start, Math.min(clipEnd(c), c.start - (c.in || 0) + comp.duration)];
|
|
130
|
+
const disabled = new Set(comp.disabledTracks || []);
|
|
131
|
+
const kids = [];
|
|
132
|
+
for (const inner of comp.clips || []) {
|
|
133
|
+
const v = virtualize(inner, c.id + SEP, offset, win, c.id, on && !disabled.has(inner.track));
|
|
134
|
+
if (v) { kids.push(v); out.push(v); }
|
|
135
|
+
}
|
|
136
|
+
const next = new Set(seen); next.add(comp.id);
|
|
137
|
+
walk(kids, depth + 1, next, (x) => x._on);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
walk(clips, 0, new Set(opts.inComp ? [opts.inComp] : []), opts.renders || ((c) => c.disabled !== true));
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/** The tracks a comp draws in, bottom first (V1 under V2 …), and its audio tracks. */
|
|
144
|
+
function drawOrder(comp) {
|
|
145
|
+
const tr = compTracks(comp);
|
|
146
|
+
return { video: tr.filter((t) => t.kind === "video").map((t) => t.id).reverse(), audio: tr.filter((t) => t.kind === "audio").map((t) => t.id) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ── Precompose ── */
|
|
150
|
+
/** Move `ids` (clips on the main timeline or in comp `from`) into a new comp; put a comp clip
|
|
151
|
+
* in their place. Returns { comp, clip }. Times in the comp start at the earliest clip. */
|
|
152
|
+
function precompose(doc, ids, { id, name, from = null, track } = {}) {
|
|
153
|
+
const host = from ? getComp(doc, from) : doc;
|
|
154
|
+
if (!host) throw new Error(`precompose: no comp ${from}`);
|
|
155
|
+
const set = new Set(ids);
|
|
156
|
+
const moving = (host.clips || []).filter((c) => set.has(c.id));
|
|
157
|
+
if (!moving.length) throw new Error("precompose: none of those clips are there");
|
|
158
|
+
const t0 = Math.min(...moving.map((c) => c.start)), t1 = Math.max(...moving.map(clipEnd));
|
|
159
|
+
const hostTracks = from ? compTracks(host) : (Array.isArray(doc.tracks) && doc.tracks.length ? doc.tracks : DEFAULT_TRACKS);
|
|
160
|
+
const used = [...new Set(moving.map((c) => c.track))];
|
|
161
|
+
const tracks = hostTracks.filter((t) => used.includes(t.id)).map((t) => ({ id: t.id, kind: t.kind }));
|
|
162
|
+
const compId = id || "comp_" + Math.random().toString(36).slice(2, 8);
|
|
163
|
+
if (getComp(doc, compId)) throw new Error(`precompose: comp ${compId} already exists`);
|
|
164
|
+
const comp = {
|
|
165
|
+
id: compId, name: name || "Comp " + ((doc.comps || []).length + 1),
|
|
166
|
+
width: from ? host.width : (doc.width || 1280), height: from ? host.height : (doc.height || 720),
|
|
167
|
+
duration: r4(t1 - t0), tracks,
|
|
168
|
+
clips: moving.map((c) => ({ ...c, start: r4(c.start - t0) })),
|
|
169
|
+
};
|
|
170
|
+
// a hidden track stays hidden inside the comp
|
|
171
|
+
const hidden = (host.disabledTracks || []).filter((t) => used.includes(t));
|
|
172
|
+
if (hidden.length) comp.disabledTracks = hidden;
|
|
173
|
+
// parents outside the moved set can't follow into the comp
|
|
174
|
+
for (const c of comp.clips) if (c.parent && !set.has(c.parent)) delete c.parent;
|
|
175
|
+
const videoTracks = moving.filter((c) => c.kind !== "audio").map((c) => c.track);
|
|
176
|
+
const lane = track || videoTracks.sort((a, b) => (parseInt(a.slice(1), 10) || 0) - (parseInt(b.slice(1), 10) || 0))[0] || "V1";
|
|
177
|
+
const clip = { id: "c_" + Math.random().toString(36).slice(2, 9), kind: "comp", compId, mediaId: null, track: lane, start: r4(t0), in: 0, duration: comp.duration, name: comp.name, props: { fit: "none" } };
|
|
178
|
+
host.clips = host.clips.filter((c) => !set.has(c.id));
|
|
179
|
+
host.clips.push(clip);
|
|
180
|
+
// clips that were parented to a moved clip now follow nothing
|
|
181
|
+
for (const c of host.clips) if (c.parent && set.has(c.parent)) delete c.parent;
|
|
182
|
+
doc.comps = [...(doc.comps || []), comp];
|
|
183
|
+
return { comp, clip };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One line per comp for status readouts. */
|
|
187
|
+
function describe(comp) {
|
|
188
|
+
const kinds = {};
|
|
189
|
+
for (const c of comp.clips || []) kinds[c.kind] = (kinds[c.kind] || 0) + 1;
|
|
190
|
+
return `${comp.id} "${comp.name}" ${comp.width}x${comp.height} ${comp.duration}s, ${(comp.clips || []).length} clips` +
|
|
191
|
+
(Object.keys(kinds).length ? ` (${Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(", ")})` : "");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { MAX_DEPTH, SEP, DEFAULT_TRACKS, normalizeComp, getComp, usedComps, wouldCycle, usages, compTracks, virtualize, flatten, drawOrder, precompose, describe };
|
|
195
|
+
});
|
package/edit-ops.js
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
`forDoc(doc, opts)` builds the env from the document itself (its tracks,
|
|
20
20
|
lockedTracks and untargetedTracks) for headless callers. */
|
|
21
21
|
(function (root, factory) {
|
|
22
|
-
const
|
|
23
|
-
|
|
22
|
+
const node = typeof module === "object" && module.exports;
|
|
23
|
+
const api = factory(node ? require("./mask") : root.FableCutMask, node ? require("./tracker") : root.FableCutTracker,
|
|
24
|
+
node ? require("./motion") : root.FableCutMotion);
|
|
25
|
+
if (node) module.exports = api;
|
|
24
26
|
else root.FableCutEdit = api;
|
|
25
|
-
})(typeof self !== "undefined" ? self : this, function () {
|
|
27
|
+
})(typeof self !== "undefined" ? self : this, function (MaskLib, TrackLib, MotionLib) {
|
|
26
28
|
"use strict";
|
|
27
29
|
|
|
28
30
|
const MIN_DUR = 0.05;
|
|
@@ -40,8 +42,10 @@
|
|
|
40
42
|
/* ── Pure clip helpers ── */
|
|
41
43
|
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
42
44
|
const clipEnd = (c) => c.start + c.duration;
|
|
43
|
-
const clipSpeed = (c) => clamp(+(c.props?.speed) || 1, 0.1, 8);
|
|
45
|
+
const clipSpeed = (c) => (c.kind === "comp" ? 1 : clamp(+(c.props?.speed) || 1, 0.1, 8));
|
|
44
46
|
const isMediaClip = (c) => c.kind === "video" || c.kind === "audio";
|
|
47
|
+
/** Clips with a source window (`in` into something with a length): media, and comp clips (into their comp). */
|
|
48
|
+
const hasSourceTime = (c) => isMediaClip(c) || c.kind === "comp";
|
|
45
49
|
const EASE = {
|
|
46
50
|
linear: (u) => u,
|
|
47
51
|
"ease-in": (u) => u * u,
|
|
@@ -64,7 +68,7 @@
|
|
|
64
68
|
return fallback;
|
|
65
69
|
}
|
|
66
70
|
function hasSpeedRamp(c) {
|
|
67
|
-
return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0;
|
|
71
|
+
return c.kind !== "comp" && Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0; // comps run at 1×
|
|
68
72
|
}
|
|
69
73
|
/** Source time at timeline time t: in + ∫speed (trapezoid, 1/120 s steps). */
|
|
70
74
|
function mediaTimeAt(c, t) {
|
|
@@ -92,6 +96,42 @@
|
|
|
92
96
|
}
|
|
93
97
|
return Object.keys(out).length ? out : undefined;
|
|
94
98
|
}
|
|
99
|
+
/** A clip's head moved by `offset` s: its mask keys, shape keys and track samples follow, like shiftKF does for keyframes. */
|
|
100
|
+
function shiftMasks(c, offset) {
|
|
101
|
+
if (!offset || !c.props) return;
|
|
102
|
+
const ms = c.props.masks, ts = c.props.tracks, ss = c.props.shapes;
|
|
103
|
+
if (MotionLib && Array.isArray(ss)) c.props = { ...c.props, shapes: MotionLib.shiftKeys(ss, offset) };
|
|
104
|
+
if (MaskLib && Array.isArray(ms)) {
|
|
105
|
+
const next = MaskLib.shiftKeys(ms, offset);
|
|
106
|
+
if (next !== ms) c.props = { ...c.props, masks: next };
|
|
107
|
+
}
|
|
108
|
+
if (TrackLib && Array.isArray(ts)) {
|
|
109
|
+
const next = TrackLib.shiftTracks(ts, offset);
|
|
110
|
+
c.props = { ...c.props };
|
|
111
|
+
if (next.length) c.props.tracks = next; else delete c.props.tracks;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Clips are leaving the document: their children stay where they are. Each child is
|
|
115
|
+
* re-parented to the nearest ancestor that stays (or to nothing), with its static
|
|
116
|
+
* position / rotation / scale rewritten to hold its place at its own start. Keyed
|
|
117
|
+
* channels keep their keys. Call it before the clips are removed. */
|
|
118
|
+
function detachChildren(project, goneIds) {
|
|
119
|
+
if (!MotionLib || !goneIds || !goneIds.size) return 0;
|
|
120
|
+
const byId = (id) => project.clips.find((x) => x.id === id) || null;
|
|
121
|
+
let n = 0;
|
|
122
|
+
for (const k of project.clips) {
|
|
123
|
+
if (!k.parent || goneIds.has(k.id) || !goneIds.has(k.parent)) continue;
|
|
124
|
+
let up = byId(k.parent), guard = 0;
|
|
125
|
+
while (up && goneIds.has(up.id) && guard++ < 64) up = up.parent ? byId(up.parent) : null;
|
|
126
|
+
const next = up && !goneIds.has(up.id) ? up.id : null;
|
|
127
|
+
const v = MotionLib.reparentValues(k, next, byId, k.start || 0);
|
|
128
|
+
k.props = { ...(k.props || {}) };
|
|
129
|
+
for (const [p, val] of Object.entries(v)) if (!(k.keyframes && Array.isArray(k.keyframes[p]) && k.keyframes[p].length)) k.props[p] = val;
|
|
130
|
+
if (next) k.parent = next; else delete k.parent;
|
|
131
|
+
n++;
|
|
132
|
+
}
|
|
133
|
+
return n;
|
|
134
|
+
}
|
|
95
135
|
/** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
|
|
96
136
|
function defaultPanForChannel(ch) {
|
|
97
137
|
if (ch === 0) return -1;
|
|
@@ -105,9 +145,16 @@
|
|
|
105
145
|
const timeAt = env.mediaTimeAt || mediaTimeAt;
|
|
106
146
|
const removed = (c) => { if (env.onRemove) env.onRemove(c); };
|
|
107
147
|
const getMedia = (id) => P().media.find((m) => m.id === id);
|
|
148
|
+
/** Length of what c's `in` points into (media or comp), 0 when unknown. */
|
|
149
|
+
const sourceLen = (c) => {
|
|
150
|
+
if (c.kind === "comp") { const k = (P().comps || []).find((x) => x.id === c.compId); return k ? +k.duration || 0 : 0; }
|
|
151
|
+
const m = getMedia(c.mediaId);
|
|
152
|
+
return m ? +m.duration || 0 : 0;
|
|
153
|
+
};
|
|
108
154
|
const getClip = (id) => P().clips.find((c) => c.id === id) || null;
|
|
109
155
|
const dropClips = (pred) => {
|
|
110
156
|
const project = P();
|
|
157
|
+
detachChildren(project, new Set(project.clips.filter(pred).map((x) => x.id)));
|
|
111
158
|
const keep = [];
|
|
112
159
|
for (const x of project.clips) { if (pred(x)) removed(x); else keep.push(x); }
|
|
113
160
|
project.clips = keep;
|
|
@@ -190,6 +237,7 @@
|
|
|
190
237
|
linkedId: undefined,
|
|
191
238
|
linkGroup: undefined,
|
|
192
239
|
};
|
|
240
|
+
shiftMasks(right, cut);
|
|
193
241
|
c.duration = cut;
|
|
194
242
|
c.keyframes = shiftKF(c.keyframes, 0, cut);
|
|
195
243
|
c.transitionOut = undefined;
|
|
@@ -270,10 +318,11 @@
|
|
|
270
318
|
const dIn = t0 - start;
|
|
271
319
|
if (dIn > 1e-6) {
|
|
272
320
|
c.start = t0;
|
|
273
|
-
if (
|
|
321
|
+
if (hasSourceTime(c)) c.in += dIn * clipSpeed(c);
|
|
274
322
|
else c.in = 0;
|
|
275
323
|
c.duration -= dIn;
|
|
276
324
|
c.keyframes = shiftKF(c.keyframes, dIn, c.duration);
|
|
325
|
+
shiftMasks(c, dIn);
|
|
277
326
|
c.transitionIn = undefined;
|
|
278
327
|
changed++;
|
|
279
328
|
}
|
|
@@ -559,6 +608,7 @@
|
|
|
559
608
|
c.start = +t1.toFixed(4);
|
|
560
609
|
c.transitionIn = undefined;
|
|
561
610
|
c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
|
|
611
|
+
shiftMasks(c, t1 - oldStart);
|
|
562
612
|
} else {
|
|
563
613
|
// Stubs on both sides → effectively inside the window.
|
|
564
614
|
drop(c);
|
|
@@ -595,6 +645,7 @@
|
|
|
595
645
|
c.start = +t1.toFixed(4);
|
|
596
646
|
c.transitionIn = undefined;
|
|
597
647
|
c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
|
|
648
|
+
shiftMasks(c, t1 - oldStart);
|
|
598
649
|
}
|
|
599
650
|
}
|
|
600
651
|
}
|
|
@@ -657,10 +708,10 @@
|
|
|
657
708
|
|
|
658
709
|
/** Longest this clip may run before it runs out of source media. */
|
|
659
710
|
function maxClipDur(c) {
|
|
660
|
-
if (!
|
|
661
|
-
const
|
|
662
|
-
if (!
|
|
663
|
-
return Math.max(MIN_DUR, (
|
|
711
|
+
if (!hasSourceTime(c)) return Infinity;
|
|
712
|
+
const len = sourceLen(c);
|
|
713
|
+
if (!(len > 0)) return Infinity;
|
|
714
|
+
return Math.max(MIN_DUR, (len - c.in) / clipSpeed(c));
|
|
664
715
|
}
|
|
665
716
|
/* A linked group shares its timing, but a partner may run at a different
|
|
666
717
|
speed (links are inferred from timing alone), so every source limit is the
|
|
@@ -671,7 +722,7 @@
|
|
|
671
722
|
}
|
|
672
723
|
/** How far c's group can pull its head back before any member reaches source 0. */
|
|
673
724
|
function groupHeadRoom(c) {
|
|
674
|
-
return Math.min(...withLinked([c]).map((x) => (
|
|
725
|
+
return Math.min(...withLinked([c]).map((x) => (hasSourceTime(x) ? x.in / clipSpeed(x) : Infinity)));
|
|
675
726
|
}
|
|
676
727
|
/** The clip butting against c's head ("in") or tail ("out") on its track. */
|
|
677
728
|
function adjacentClip(c, side) {
|
|
@@ -707,9 +758,10 @@
|
|
|
707
758
|
function trimGroupHead(c, d, moveStart) {
|
|
708
759
|
for (const x of withLinked([c])) {
|
|
709
760
|
if (moveStart) x.start = +(x.start + d).toFixed(4);
|
|
710
|
-
if (
|
|
761
|
+
if (hasSourceTime(x)) x.in = +(x.in + d * clipSpeed(x)).toFixed(4);
|
|
711
762
|
x.duration = +(x.duration - d).toFixed(4);
|
|
712
763
|
x.keyframes = shiftKF(x.keyframes, d, x.duration);
|
|
764
|
+
shiftMasks(x, d);
|
|
713
765
|
clampTransitions(x);
|
|
714
766
|
}
|
|
715
767
|
}
|
|
@@ -776,7 +828,7 @@
|
|
|
776
828
|
* delta > 0 drags the picture right, i.e. earlier source. Position and
|
|
777
829
|
* length stay; clip-local keyframes stay put on the timeline. */
|
|
778
830
|
function slipClip(c, delta) {
|
|
779
|
-
if (!
|
|
831
|
+
if (!hasSourceTime(c)) return "Slip needs a video or audio clip, or a comp clip — stills and titles have no source time";
|
|
780
832
|
if (isGroupLocked(c)) return TRIM_LOCKED;
|
|
781
833
|
const sp = clipSpeed(c);
|
|
782
834
|
const group = withLinked([c]);
|
|
@@ -784,9 +836,9 @@
|
|
|
784
836
|
// consumes its own span of source (speed can differ across the group).
|
|
785
837
|
let maxIn = Infinity;
|
|
786
838
|
for (const x of group) {
|
|
787
|
-
if (!
|
|
788
|
-
const
|
|
789
|
-
if (
|
|
839
|
+
if (!hasSourceTime(x)) continue;
|
|
840
|
+
const len = sourceLen(x);
|
|
841
|
+
if (len > 0) maxIn = Math.min(maxIn, Math.max(0, len - (timeAt(x, clipEnd(x)) - x.in)));
|
|
790
842
|
}
|
|
791
843
|
const newIn = clampD(c.in - delta * sp, 0, maxIn);
|
|
792
844
|
const d = (c.in - newIn) / sp;
|
|
@@ -885,6 +937,7 @@
|
|
|
885
937
|
x.in = +Math.max(0, x.in - e2 * sp).toFixed(4);
|
|
886
938
|
x.duration = +(x.duration + e2).toFixed(4);
|
|
887
939
|
x.keyframes = shiftKF(x.keyframes, -e2, x.duration);
|
|
940
|
+
shiftMasks(x, -e2);
|
|
888
941
|
}
|
|
889
942
|
}
|
|
890
943
|
d = +Math.min(d, a.duration, b.duration).toFixed(3);
|
|
@@ -956,7 +1009,7 @@
|
|
|
956
1009
|
|
|
957
1010
|
return {
|
|
958
1011
|
MIN_DUR, MIN_TRANS_DUR, CROSSFADE_DUR, GAP_EPS, TRIM_LOCKED, DEFAULT_TRACKS,
|
|
959
|
-
clipEnd, clipSpeed, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, defaultPanForChannel,
|
|
1012
|
+
clipEnd, clipSpeed, isMediaClip, hasSourceTime, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, shiftMasks, detachChildren, defaultPanForChannel,
|
|
960
1013
|
create, forDoc,
|
|
961
1014
|
};
|
|
962
1015
|
});
|
package/export-jobs.js
CHANGED
|
@@ -103,14 +103,15 @@ function createExportJobs(opts) {
|
|
|
103
103
|
|
|
104
104
|
return {
|
|
105
105
|
/** Queue an export. where: auto (open tab, else headless) · tab · headless.
|
|
106
|
-
* kind "scopes" measures one still at `time` instead (fablecut_scopes)
|
|
107
|
-
|
|
106
|
+
* kind "scopes" measures one still at `time` instead (fablecut_scopes);
|
|
107
|
+
* kind "track" follows a region of a clip and saves it (fablecut_track). */
|
|
108
|
+
request({ kind = "export", range, profile, time, revision, where = "auto", matte = null, mask = null, track = null }) {
|
|
108
109
|
if (!["auto", "tab", "headless"].includes(where)) throw Object.assign(new Error("where must be auto, tab or headless"), { code: 400 });
|
|
109
|
-
if (
|
|
110
|
+
if (!["export", "scopes", "track"].includes(kind)) throw Object.assign(new Error("kind must be export, scopes or track"), { code: 400 });
|
|
110
111
|
if (time != null && !(Number.isFinite(time) && time >= 0)) throw Object.assign(new Error("time must be seconds ≥ 0"), { code: 400 });
|
|
111
112
|
if (range != null && range !== "entire" && range !== "in-out") throw Object.assign(new Error("range must be \"entire\" or \"in-out\""), { code: 400 });
|
|
112
113
|
const busy = [...jobs.values()].find((j) => !isEnded(j));
|
|
113
|
-
if (busy) throw Object.assign(new Error(`${busy.kind === "scopes" ? "scope reading" : "export"} ${busy.id} is still ${busy.status} — wait for it, or cancel it`), { code: 409 });
|
|
114
|
+
if (busy) throw Object.assign(new Error(`${busy.kind === "scopes" ? "scope reading" : busy.kind === "track" ? "tracking job" : "export"} ${busy.id} is still ${busy.status} — wait for it, or cancel it`), { code: 409 });
|
|
114
115
|
const tabs = opts.editors();
|
|
115
116
|
if (where === "tab" && !tabs) throw Object.assign(new Error("no editor tab is open — open the editor, or use where:\"headless\""), { code: 409 });
|
|
116
117
|
const now = Date.now();
|
|
@@ -118,6 +119,8 @@ function createExportJobs(opts) {
|
|
|
118
119
|
id: "x_" + Math.random().toString(36).slice(2, 10), kind, status: "pending", progress: 0,
|
|
119
120
|
range: range || null, profile: profile || null, time: time ?? null, revision: Number.isFinite(revision) ? revision : null,
|
|
120
121
|
matte: kind === "scopes" && matte ? { clip: String(matte.clip), layer: matte.layer } : null,
|
|
122
|
+
mask: kind === "scopes" && mask ? { clip: String(mask.clip) } : null,
|
|
123
|
+
track: kind === "track" && track ? track : null,
|
|
121
124
|
via: where === "headless" || (where === "auto" && !tabs) ? "headless" : "tab",
|
|
122
125
|
created: now, updated: now,
|
|
123
126
|
};
|
|
@@ -129,7 +132,7 @@ function createExportJobs(opts) {
|
|
|
129
132
|
},
|
|
130
133
|
get(id) { const j = jobs.get(id); return j ? view(j) : null; },
|
|
131
134
|
/** What a tab needs to run the job (headless pages fetch it by id). */
|
|
132
|
-
ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, time: j.time, revision: j.revision, matte: j.matte || null, status: j.status } : null; },
|
|
135
|
+
ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, time: j.time, revision: j.revision, matte: j.matte || null, mask: j.mask || null, track: j.track || null, status: j.status } : null; },
|
|
133
136
|
/** First tab to claim a pending job runs it. */
|
|
134
137
|
claim(id) {
|
|
135
138
|
const j = jobs.get(id);
|