fablecut 1.12.0 → 1.13.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/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 api = factory();
52
- if (typeof module === "object" && module.exports) module.exports = api;
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 MASK_SHAPES = ["ellipse", "rect", "poly"];
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.points.map(([a, b]) => [a * aspect, b]), px, py);
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(MAX_POLY * 2);
761
- if (m && m.points) m.points.forEach(([a, b], i) => { pts[i * 2] = a; pts[i * 2 + 1] = b; });
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 = m && m.points ? m.points.length : 0;
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[${MAX_POLY}];
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 == 3) {
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 < ${MAX_POLY}; 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/edit-ops.js CHANGED
@@ -19,10 +19,11 @@
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 api = factory();
23
- if (typeof module === "object" && module.exports) module.exports = api;
22
+ const node = typeof module === "object" && module.exports;
23
+ const api = factory(node ? require("./mask") : root.FableCutMask, node ? require("./tracker") : root.FableCutTracker);
24
+ if (node) module.exports = api;
24
25
  else root.FableCutEdit = api;
25
- })(typeof self !== "undefined" ? self : this, function () {
26
+ })(typeof self !== "undefined" ? self : this, function (MaskLib, TrackLib) {
26
27
  "use strict";
27
28
 
28
29
  const MIN_DUR = 0.05;
@@ -92,6 +93,20 @@
92
93
  }
93
94
  return Object.keys(out).length ? out : undefined;
94
95
  }
96
+ /** A clip's head moved by `offset` s: its mask keys and track samples follow, like shiftKF does for keyframes. */
97
+ function shiftMasks(c, offset) {
98
+ if (!offset || !c.props) return;
99
+ const ms = c.props.masks, ts = c.props.tracks;
100
+ if (MaskLib && Array.isArray(ms)) {
101
+ const next = MaskLib.shiftKeys(ms, offset);
102
+ if (next !== ms) c.props = { ...c.props, masks: next };
103
+ }
104
+ if (TrackLib && Array.isArray(ts)) {
105
+ const next = TrackLib.shiftTracks(ts, offset);
106
+ c.props = { ...c.props };
107
+ if (next.length) c.props.tracks = next; else delete c.props.tracks;
108
+ }
109
+ }
95
110
  /** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
96
111
  function defaultPanForChannel(ch) {
97
112
  if (ch === 0) return -1;
@@ -190,6 +205,7 @@
190
205
  linkedId: undefined,
191
206
  linkGroup: undefined,
192
207
  };
208
+ shiftMasks(right, cut);
193
209
  c.duration = cut;
194
210
  c.keyframes = shiftKF(c.keyframes, 0, cut);
195
211
  c.transitionOut = undefined;
@@ -274,6 +290,7 @@
274
290
  else c.in = 0;
275
291
  c.duration -= dIn;
276
292
  c.keyframes = shiftKF(c.keyframes, dIn, c.duration);
293
+ shiftMasks(c, dIn);
277
294
  c.transitionIn = undefined;
278
295
  changed++;
279
296
  }
@@ -559,6 +576,7 @@
559
576
  c.start = +t1.toFixed(4);
560
577
  c.transitionIn = undefined;
561
578
  c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
579
+ shiftMasks(c, t1 - oldStart);
562
580
  } else {
563
581
  // Stubs on both sides → effectively inside the window.
564
582
  drop(c);
@@ -595,6 +613,7 @@
595
613
  c.start = +t1.toFixed(4);
596
614
  c.transitionIn = undefined;
597
615
  c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
616
+ shiftMasks(c, t1 - oldStart);
598
617
  }
599
618
  }
600
619
  }
@@ -710,6 +729,7 @@
710
729
  if (isMediaClip(x)) x.in = +(x.in + d * clipSpeed(x)).toFixed(4);
711
730
  x.duration = +(x.duration - d).toFixed(4);
712
731
  x.keyframes = shiftKF(x.keyframes, d, x.duration);
732
+ shiftMasks(x, d);
713
733
  clampTransitions(x);
714
734
  }
715
735
  }
@@ -885,6 +905,7 @@
885
905
  x.in = +Math.max(0, x.in - e2 * sp).toFixed(4);
886
906
  x.duration = +(x.duration + e2).toFixed(4);
887
907
  x.keyframes = shiftKF(x.keyframes, -e2, x.duration);
908
+ shiftMasks(x, -e2);
888
909
  }
889
910
  }
890
911
  d = +Math.min(d, a.duration, b.duration).toFixed(3);
@@ -956,7 +977,7 @@
956
977
 
957
978
  return {
958
979
  MIN_DUR, MIN_TRANS_DUR, CROSSFADE_DUR, GAP_EPS, TRIM_LOCKED, DEFAULT_TRACKS,
959
- clipEnd, clipSpeed, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, defaultPanForChannel,
980
+ clipEnd, clipSpeed, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, shiftMasks, defaultPanForChannel,
960
981
  create, forDoc,
961
982
  };
962
983
  });
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
- request({ kind = "export", range, profile, time, revision, where = "auto", matte = null }) {
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 (kind !== "export" && kind !== "scopes") throw Object.assign(new Error("kind must be export or scopes"), { code: 400 });
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);
package/index.html CHANGED
@@ -394,14 +394,20 @@
394
394
  <tr><td><kbd>Z</kbd></td><td>Zoom to selected clip(s)</td></tr>
395
395
  <tr><td><kbd>Alt</kbd>+<kbd>Z</kbd></td><td>Zoom to IN–OUT work area</td></tr>
396
396
  <tr><td><kbd>⇧</kbd>+<kbd>Z</kbd></td><td>Zoom timeline to fit</td></tr>
397
- <tr><td><kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>⇧</kbd>+<kbd>Z</kbd></td><td>Undo / redo</td></tr>
397
+ <tr><td><kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>⇧</kbd>+<kbd>Z</kbd></td><td>Undo / redo (also <kbd>Ctrl</kbd>+<kbd>Y</kbd>)</td></tr>
398
398
  <tr><td><kbd>Ctrl</kbd>+wheel</td><td>Zoom timeline at cursor</td></tr>
399
399
  <tr><td>Audio volume line</td><td>Drag to change the level · <kbd>Ctrl/Cmd</kbd>+click adds a keyframe · <kbd>Alt</kbd>+click or double-click removes one · corner grips drag fades</td></tr>
400
400
  <tr class="keys-head"><td colspan="2">Color workspace</td></tr>
401
401
  <tr><td>Colour wheel</td><td>Drag to tint (<kbd>⇧</kbd> = fine) · double-click resets the colour · double-click a label or slider name to reset it</td></tr>
402
402
  <tr><td>WB pick / Pick</td><td>Click the monitor to balance, to mark a colour on the curves, or to select it in a layer's qualifier · <kbd>Esc</kbd> cancels</td></tr>
403
403
  <tr><td>Curves</td><td>Click adds a point on the curve · drag moves it · double-click removes it</td></tr>
404
- <tr><td>Layer mask on the monitor</td><td>Drag inside to move · handles resize · the knob rotates (<kbd>⇧</kbd> snaps to 15°) · poly: <kbd>Ctrl/Cmd</kbd>+click adds a point, double-click removes one</td></tr>
404
+ <tr><td>Layer mask on the monitor</td><td>Drag inside to move · handles resize · the knob rotates (<kbd>⇧</kbd> snaps to 15°) · poly / bezier: <kbd>Ctrl/Cmd</kbd>+click adds a point, double-click removes one · <b>Draw</b>: drag a free-hand outline</td></tr>
405
+ <tr class="keys-head"><td colspan="2">Masks (Inspector → Masks)</td></tr>
406
+ <tr><td>Pen</td><td>Click to place points, drag while placing to pull curve handles · click the first point or <kbd>Enter</kbd> closes · <kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Backspace</kbd> removes the last point, <kbd>Ctrl</kbd>+<kbd>⇧</kbd>+<kbd>Z</kbd> puts it back · <kbd>Esc</kbd> cancels</td></tr>
407
+ <tr><td>Draw</td><td>Drag a free-hand outline — it becomes an editable bezier</td></tr>
408
+ <tr><td>Mask on the monitor</td><td>Drag inside to move · edge handles resize · the knob rotates (<kbd>⇧</kbd> snaps to 15°) · drag a point or its handles (<kbd>Alt</kbd> breaks the pair) · <kbd>Alt</kbd>+drag a point pulls new handles · <kbd>Ctrl/Cmd</kbd>+click the outline adds a point · double-click a point removes it · <kbd>Esc</kbd> deselects</td></tr>
409
+ <tr class="keys-head"><td colspan="2">Tracking (Inspector → Tracking, or a mask's Track buttons)</td></tr>
410
+ <tr><td>Track point</td><td>Click the spot to follow on the monitor — it tracks from the playhead to the clip's end · <kbd>Esc</kbd> cancels the pick, or stops a track that is running (what was tracked so far is kept)</td></tr>
405
411
  </table>
406
412
  </div>
407
413
  <div class="dialog-actions"><button class="btn primary" id="btnCloseHelp">Close</button></div>
@@ -413,6 +419,8 @@
413
419
  <script src="loudness.js"></script>
414
420
  <script src="ducking.js"></script>
415
421
  <script src="audio-fx.js"></script>
422
+ <script src="mask.js"></script>
423
+ <script src="tracker.js"></script>
416
424
  <script src="color.js"></script>
417
425
  <script src="edit-ops.js"></script>
418
426
  <script src="app.js"></script>