fablecut 1.12.1 → 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/CLAUDE.md +228 -6
- package/README.md +22 -8
- package/app.js +1303 -25
- package/color.js +47 -14
- package/edit-ops.js +25 -4
- package/export-jobs.js +8 -5
- package/index.html +10 -2
- package/mask.js +467 -0
- package/mcp-server.js +207 -7
- package/package.json +1 -1
- package/server.js +22 -1
- package/style.css +11 -0
- package/tracker.js +253 -0
package/app.js
CHANGED
|
@@ -84,8 +84,7 @@ const TRANSITIONS = ["none", "fade", "slide-left", "slide-right", "slide-up", "s
|
|
|
84
84
|
const TEXT_ANIMS = ["none", "typewriter", "word-pop", "word-slide", "karaoke",
|
|
85
85
|
"letter-pop", "wave", "bounce", "shake",
|
|
86
86
|
"clip-reveal", "zoom-in", "font-cut", "rise-mask"];
|
|
87
|
-
const BLEND_MODES =
|
|
88
|
-
"hard-light", "color-dodge", "darken", "lighten", "difference"];
|
|
87
|
+
const BLEND_MODES = self.FableCutMask.BLENDS; // the canvas composite set, shared with the agent (mask.js)
|
|
89
88
|
/* Named looks. % props multiply against the clip's own value, additive props add. */
|
|
90
89
|
const FILTER_PRESETS = {
|
|
91
90
|
none: {},
|
|
@@ -779,7 +778,7 @@ const els = {
|
|
|
779
778
|
importUrlOverlay: $("importUrlOverlay"), importUrlInput: $("importUrlInput"),
|
|
780
779
|
importUrlStatus: $("importUrlStatus"), importUrlProgress: $("importUrlProgress"),
|
|
781
780
|
};
|
|
782
|
-
|
|
781
|
+
let ctx2d = els.preview.getContext("2d"); // drawMaskedClip points it at an offscreen layer while a masked clip draws
|
|
783
782
|
|
|
784
783
|
/* ── Utils ─────────────────────────────────────────────────────────────── */
|
|
785
784
|
const uid = () => Math.random().toString(36).slice(2, 9);
|
|
@@ -3273,6 +3272,9 @@ function rebuildClips() {
|
|
|
3273
3272
|
els.tracksContent.style.width = w + "px";
|
|
3274
3273
|
els.ruler.style.width = els.timelineScroll.clientWidth + "px";
|
|
3275
3274
|
for (const row of els.tracks.children) row.innerHTML = "";
|
|
3275
|
+
const matted = project.clips.map((x) => [x, clipMatte(x)]).filter(([, m]) => m);
|
|
3276
|
+
const isMatte = (c) => matted.some(([x, m]) => m.track === c.track && clipRenders(x) &&
|
|
3277
|
+
x.start < c.start + c.duration && c.start < x.start + x.duration); // hidden while it cuts that clip
|
|
3276
3278
|
for (const c of project.clips) {
|
|
3277
3279
|
const tr = trackOf(c); if (!tr) continue;
|
|
3278
3280
|
const row = els.tracks.querySelector(`[data-track="${c.track}"]`);
|
|
@@ -3280,7 +3282,7 @@ function rebuildClips() {
|
|
|
3280
3282
|
div.className = `clip c-${c.kind}` +
|
|
3281
3283
|
(state.selIds.has(c.id) ? " selected" : "") + (c.id === state.selId ? " primary" : "") +
|
|
3282
3284
|
(isClipLocked(c) ? " locked" : "") + (c.disabled === true ? " disabled" : "") +
|
|
3283
|
-
(c.unlinked === true ? " unlinked" : "");
|
|
3285
|
+
(c.unlinked === true ? " unlinked" : "") + (c.kind !== "audio" && isMatte(c) ? " matte-src" : "");
|
|
3284
3286
|
div.dataset.id = c.id;
|
|
3285
3287
|
div.style.left = c.start * state.pps + "px";
|
|
3286
3288
|
div.style.width = Math.max(8, c.duration * state.pps) + "px";
|
|
@@ -3292,7 +3294,8 @@ function rebuildClips() {
|
|
|
3292
3294
|
const hasWave = c.kind === "audio" && !!wavePeaksFor(c);
|
|
3293
3295
|
if (hasWave) body += `<canvas class="wave"></canvas>`;
|
|
3294
3296
|
const badge = (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") +
|
|
3295
|
-
(c.transitionIn || c.transitionOut ? "⇄ " : "")
|
|
3297
|
+
(c.transitionIn || c.transitionOut ? "⇄ " : "") +
|
|
3298
|
+
(clipMatte(c) ? "◐ " : "");
|
|
3296
3299
|
const chTag = (() => {
|
|
3297
3300
|
const s = audioChannelShort(c.props?.audioChannel);
|
|
3298
3301
|
return s ? s + " · " : "";
|
|
@@ -3904,6 +3907,8 @@ function startTrimToolGesture(e, c, mode) {
|
|
|
3904
3907
|
const base = new Map(project.clips.map((x) => [x.id, {
|
|
3905
3908
|
start: x.start, in: x.in, duration: x.duration, keyframes: x.keyframes,
|
|
3906
3909
|
transitionIn: cloneTr(x.transitionIn), transitionOut: cloneTr(x.transitionOut),
|
|
3910
|
+
masks: x.props && x.props.masks, // head trims re-base mask keys: each step starts from the originals
|
|
3911
|
+
tracks: x.props && x.props.tracks,
|
|
3907
3912
|
}]));
|
|
3908
3913
|
const restore = () => {
|
|
3909
3914
|
for (const x of project.clips) {
|
|
@@ -3911,6 +3916,10 @@ function startTrimToolGesture(e, c, mode) {
|
|
|
3911
3916
|
if (!b) continue;
|
|
3912
3917
|
x.start = b.start; x.in = b.in; x.duration = b.duration; x.keyframes = b.keyframes;
|
|
3913
3918
|
x.transitionIn = cloneTr(b.transitionIn); x.transitionOut = cloneTr(b.transitionOut);
|
|
3919
|
+
if (x.props) {
|
|
3920
|
+
if (b.masks) x.props.masks = b.masks; else delete x.props.masks;
|
|
3921
|
+
if (b.tracks) x.props.tracks = b.tracks; else delete x.props.tracks;
|
|
3922
|
+
}
|
|
3914
3923
|
}
|
|
3915
3924
|
};
|
|
3916
3925
|
// Snap targets ignore everything this edit moves.
|
|
@@ -4092,6 +4101,8 @@ function startClipGesture(e, c, mode, collapseOnClick) {
|
|
|
4092
4101
|
const orig = {
|
|
4093
4102
|
start: c.start, in: c.in, duration: c.duration, track: c.track,
|
|
4094
4103
|
keyframes: c.keyframes ? JSON.parse(JSON.stringify(c.keyframes)) : undefined,
|
|
4104
|
+
masks: c.props.masks,
|
|
4105
|
+
tracks: c.props.tracks,
|
|
4095
4106
|
};
|
|
4096
4107
|
// moving a clip that belongs to a multi-selection drags the whole group;
|
|
4097
4108
|
// AV-linked partners (video+audio from one file) always move together
|
|
@@ -4146,6 +4157,8 @@ function startClipGesture(e, c, mode, collapseOnClick) {
|
|
|
4146
4157
|
c.in = (c.kind === "video" || c.kind === "audio") ? orig.in + d * sp : 0;
|
|
4147
4158
|
c.duration = orig.duration - d;
|
|
4148
4159
|
c.keyframes = shiftKF(orig.keyframes, d, c.duration);
|
|
4160
|
+
if (orig.masks) c.props.masks = Mask.shiftKeys(orig.masks, d);
|
|
4161
|
+
if (orig.tracks) { const tr = Tracker.shiftTracks(orig.tracks, d); if (tr.length) c.props.tracks = tr; else delete c.props.tracks; }
|
|
4149
4162
|
syncLinkedTiming(c);
|
|
4150
4163
|
} else { // trim-r
|
|
4151
4164
|
let ne = snapTime(orig.start + orig.duration + dt, groupIds);
|
|
@@ -4856,9 +4869,18 @@ function renderInspector(lite) {
|
|
|
4856
4869
|
const sel = (label, k, opts, cur) => row(label,
|
|
4857
4870
|
`<select data-k="${k}">${opts.map((o) => `<option value="${o}" ${String(o) === String(cur) ? "selected" : ""}>${o}</option>`).join("")}</select>`, k);
|
|
4858
4871
|
const check = (label, k, on) => row(label, `<input type="checkbox" data-k="${k}" ${on ? "checked" : ""}>`, k);
|
|
4872
|
+
// track matte: the mode, and the track it comes from (auto = the video track above)
|
|
4873
|
+
const matteRows = () => {
|
|
4874
|
+
const others = videoTrackIds().filter((id) => id !== c.track), auto = Mask.matteTrackFor({ ...c, props: { matte: "alpha" } }, videoTrackIds());
|
|
4875
|
+
const opts = (cur) => [`<option value="" ${!cur ? "selected" : ""}>Above (${auto || "none"})</option>`, ...others.map((id) => `<option value="${id}" ${cur === id ? "selected" : ""}>${id}</option>`)].join("");
|
|
4876
|
+
return row("Track matte", `<select data-k="matte">${["none", ...Mask.MATTES].map((o) => `<option value="${o}" ${(p.matte || "none") === o ? "selected" : ""}>${o.replace("-", " ")}</option>`).join("")}</select>`, "matte")
|
|
4877
|
+
+ (p.matte ? row("Matte track", `<select data-k="matteTrack">${opts(p.matteTrack)}</select>`, "matteTrack")
|
|
4878
|
+
+ (clipMatte(c) ? "" : `<div class="insp-note">No video track above to take the matte from — pick one.</div>`) : "");
|
|
4879
|
+
};
|
|
4859
4880
|
if (c.kind === "adjust") {
|
|
4860
4881
|
html += `<div class="insp-section"><h3>Adjustment layer</h3>
|
|
4861
4882
|
${slider("opacity", 0, 1, 0.01, p.opacity)}
|
|
4883
|
+
${matteRows()}
|
|
4862
4884
|
</div>`;
|
|
4863
4885
|
} else if (c.kind !== "audio") {
|
|
4864
4886
|
html += `<div class="insp-section"><h3>Transform</h3>
|
|
@@ -4868,6 +4890,7 @@ function renderInspector(lite) {
|
|
|
4868
4890
|
${slider("rotation", -180, 180, 1, p.rotation, "°")}
|
|
4869
4891
|
${slider("opacity", 0, 1, 0.01, p.opacity)}
|
|
4870
4892
|
${sel("Blend", "blend", BLEND_MODES, p.blend)}
|
|
4893
|
+
${matteRows()}
|
|
4871
4894
|
</div>`;
|
|
4872
4895
|
}
|
|
4873
4896
|
if (c.kind === "video" || c.kind === "image" || c.kind === "svg") {
|
|
@@ -4958,6 +4981,8 @@ function renderInspector(lite) {
|
|
|
4958
4981
|
const curveSel = (label, k, tr) => tr?.type === "fade" && c.kind === "audio"
|
|
4959
4982
|
? row(label, `<select data-k="${k}" title="Fade shape (audio)">${[["", "Smooth (eased)"], ...Object.entries(AUDIO_FADE_CURVES)].map(([v, l]) =>
|
|
4960
4983
|
`<option value="${v}" ${(tr.curve || "") === v ? "selected" : ""}>${l}</option>`).join("")}</select>`) : "";
|
|
4984
|
+
if (c.kind !== "audio") html += maskSectionHtml(c);
|
|
4985
|
+
if (c.kind === "video") html += trackSectionHtml(c);
|
|
4961
4986
|
html += `<div class="insp-section"><h3>Transition</h3>
|
|
4962
4987
|
${tsel("In", "transIn", c.transitionIn)}
|
|
4963
4988
|
${curveSel("In curve", "curveIn", c.transitionIn)}
|
|
@@ -5075,6 +5100,12 @@ function renderInspector(lite) {
|
|
|
5075
5100
|
if (!setAnimProp(c, k, v))
|
|
5076
5101
|
toast("Move the playhead over the clip to edit its keyframes");
|
|
5077
5102
|
}
|
|
5103
|
+
else if (k === "matte" || k === "matteTrack") { // "none" / "" (= the track above) clear the prop
|
|
5104
|
+
if (v === "none" || v === "") delete c.props[k]; else c.props[k] = String(v);
|
|
5105
|
+
if (!c.props.matte) delete c.props.matteTrack;
|
|
5106
|
+
state.dirtyTimeline = true; inspPropGen++;
|
|
5107
|
+
if (k === "matte") renderInspector();
|
|
5108
|
+
}
|
|
5078
5109
|
else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
|
|
5079
5110
|
const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
|
|
5080
5111
|
if (valEl) valEl.textContent = input.value + (valEl.dataset.unit || "");
|
|
@@ -5208,6 +5239,8 @@ function renderInspector(lite) {
|
|
|
5208
5239
|
toggleKfGraph(lab.dataset.kfgraph);
|
|
5209
5240
|
});
|
|
5210
5241
|
});
|
|
5242
|
+
bindMaskControls(c);
|
|
5243
|
+
bindTrackControls(c);
|
|
5211
5244
|
syncInspectorOffClip(c);
|
|
5212
5245
|
renderKfGraphsPanel();
|
|
5213
5246
|
}
|
|
@@ -5294,6 +5327,7 @@ function syncInspectorPlayhead() {
|
|
|
5294
5327
|
const label = "◆" + (n || "");
|
|
5295
5328
|
if (btn.textContent !== label) btn.textContent = label;
|
|
5296
5329
|
}
|
|
5330
|
+
syncMaskInspector(c);
|
|
5297
5331
|
syncInspectorOffClip(c); // after the class pass, so ◆ titles read the fresh "on" state
|
|
5298
5332
|
}
|
|
5299
5333
|
|
|
@@ -8517,6 +8551,7 @@ function drawAdjust(c, W, H, t) {
|
|
|
8517
8551
|
}
|
|
8518
8552
|
if (p.grain > 0) drawGrain(p.grain, 0, 0, W, H, t);
|
|
8519
8553
|
ctx2d.restore();
|
|
8554
|
+
return p;
|
|
8520
8555
|
}
|
|
8521
8556
|
function needsPixelPass(p, c) {
|
|
8522
8557
|
return !!(p.chromaKey || p.temperature || p.tint || p.rgbSplit > 0 ||
|
|
@@ -8817,7 +8852,10 @@ function drawFrame(t = state.time) {
|
|
|
8817
8852
|
ctx2d.filter = "none"; ctx2d.globalAlpha = 1;
|
|
8818
8853
|
ctx2d.fillStyle = project.background || "#000"; ctx2d.fillRect(0, 0, W, H);
|
|
8819
8854
|
// render video tracks bottom-up (V1 under V2)
|
|
8820
|
-
|
|
8855
|
+
if (runtime.maskView) runtime.maskView.ready = false;
|
|
8856
|
+
const clips = visibleClipsAt(t), mattes = matteTracksAt(clips);
|
|
8857
|
+
for (const c of clips) if (!mattes.has(c.track)) drawClip(c, W, H, t); // a matte track only cuts
|
|
8858
|
+
if (!state.exporting && !runtime.sampling) drawMaskView(W, H);
|
|
8821
8859
|
if (!runtime.sampling) updateScopes(); // before the handles, so they never reach the scopes
|
|
8822
8860
|
// on-canvas selection handles (never during export or playback)
|
|
8823
8861
|
if (!state.exporting && !state.playing && !runtime.sampling) drawSelectionOverlay(W, H, t);
|
|
@@ -8861,7 +8899,10 @@ function overlayHandles(b, W, H) {
|
|
|
8861
8899
|
}
|
|
8862
8900
|
function drawSelectionOverlay(W, H, t) {
|
|
8863
8901
|
const c = getClip(state.selId);
|
|
8902
|
+
if (drawingLayerMask(c)) { drawLayerStroke(c, W); if (!maskEditing(c)) return; }
|
|
8864
8903
|
if (maskEditing(c) && drawMaskOverlay(c, W)) return; // a layer's mask replaces the box
|
|
8904
|
+
if (maskEditActive(c) && drawClipMaskOverlay(c, W)) return; // so does a clip mask being edited
|
|
8905
|
+
drawTrackOverlay(c, W);
|
|
8865
8906
|
if (!isVisualClip(c) || !activeAt(c, t) || !clipRenders(c)) return;
|
|
8866
8907
|
const b = clipBounds(c, evalProps(c, t), W, H);
|
|
8867
8908
|
const lw = Math.max(2, W / 640);
|
|
@@ -8896,7 +8937,8 @@ function toLocal(pt, b) {
|
|
|
8896
8937
|
return { x: dx * cs - dy * sn, y: dx * sn + dy * cs };
|
|
8897
8938
|
}
|
|
8898
8939
|
function pickClipAt(pt, W, H) {
|
|
8899
|
-
const
|
|
8940
|
+
const all = visibleClipsAt(state.time), mattes = matteTracksAt(all);
|
|
8941
|
+
const seq = all.filter((c) => isVisualClip(c) && !mattes.has(c.track));
|
|
8900
8942
|
for (let i = seq.length - 1; i >= 0; i--) {
|
|
8901
8943
|
const c = seq[i], b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
|
|
8902
8944
|
if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) return c;
|
|
@@ -9093,9 +9135,21 @@ function endCanvasDrag(e) {
|
|
|
9093
9135
|
els.preview.addEventListener("pointerup", endCanvasDrag);
|
|
9094
9136
|
els.preview.addEventListener("pointercancel", endCanvasDrag);
|
|
9095
9137
|
|
|
9096
|
-
function drawClip(c, W, H, t) {
|
|
9097
|
-
|
|
9138
|
+
function drawClip(c, W, H, t, asMatte = false) {
|
|
9139
|
+
const masks = clipMasksAt(c, t), matte = asMatte ? null : clipMatte(c);
|
|
9140
|
+
if (masks || matte) drawMaskedClip(c, W, H, t, masks, matte);
|
|
9141
|
+
else drawClipPicture(c, W, H, t, false);
|
|
9142
|
+
}
|
|
9143
|
+
/** Draw one clip. `layered`: into a mask layer — opacity and blend wait for
|
|
9144
|
+
* the composite. Returns { p, geom } — geom = where the clip's picture landed
|
|
9145
|
+
* (canvas transform + its box), the space its masks live in. */
|
|
9146
|
+
function drawClipPicture(c, W, H, t, layered) {
|
|
9147
|
+
if (c.kind === "adjust") {
|
|
9148
|
+
const p = drawAdjust(c, W, H, t);
|
|
9149
|
+
return { p, geom: noteMaskGeom(c, new DOMMatrix(), 0, 0, W, H) };
|
|
9150
|
+
}
|
|
9098
9151
|
const p = evalProps(c, t);
|
|
9152
|
+
let geom = null;
|
|
9099
9153
|
ctx2d.save();
|
|
9100
9154
|
if (p._wipe) {
|
|
9101
9155
|
ctx2d.beginPath();
|
|
@@ -9111,9 +9165,10 @@ function drawClip(c, W, H, t) {
|
|
|
9111
9165
|
ctx2d.arc(W / 2, H / 2, Math.max(0.01, (1 - p._iris)) * Math.hypot(W, H) * 0.55, 0, Math.PI * 2);
|
|
9112
9166
|
ctx2d.clip();
|
|
9113
9167
|
}
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9168
|
+
if (!layered) {
|
|
9169
|
+
ctx2d.globalAlpha = clamp(p.opacity, 0, 1);
|
|
9170
|
+
if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) ctx2d.globalCompositeOperation = p.blend;
|
|
9171
|
+
}
|
|
9117
9172
|
ctx2d.translate(W / 2 + (+p.x || 0), H / 2 + (+p.y || 0));
|
|
9118
9173
|
ctx2d.rotate((p.rotation || 0) * Math.PI / 180);
|
|
9119
9174
|
if (p.shake > 0) { // deterministic multi-sine handheld/impact shake
|
|
@@ -9124,9 +9179,11 @@ function drawClip(c, W, H, t) {
|
|
|
9124
9179
|
ctx2d.rotate(Math.sin(tt * 0.9) * a * 0.0022);
|
|
9125
9180
|
}
|
|
9126
9181
|
if (c.kind === "text") {
|
|
9182
|
+
const b = clipBounds(c, p, W, H);
|
|
9183
|
+
geom = noteMaskGeom(c, ctx2d.getTransform(), -b.hw, -b.hh, b.hw * 2, b.hh * 2);
|
|
9127
9184
|
drawText(c, p, t - c.start);
|
|
9128
9185
|
ctx2d.restore();
|
|
9129
|
-
return;
|
|
9186
|
+
return { p, geom };
|
|
9130
9187
|
}
|
|
9131
9188
|
let src = null, sw = 0, sh = 0;
|
|
9132
9189
|
if (c.kind === "image") {
|
|
@@ -9164,6 +9221,7 @@ function drawClip(c, W, H, t) {
|
|
|
9164
9221
|
const grade = activeGrade(c, p, t), matte = gradeMatte(c);
|
|
9165
9222
|
let graded = null;
|
|
9166
9223
|
noteGradeGeom(c, ctx2d.getTransform(), dw, dh, -dw / 2, -dh / 2);
|
|
9224
|
+
geom = noteMaskGeom(c, ctx2d.getTransform(), -dw / 2, -dh / 2, dw, dh);
|
|
9167
9225
|
if (needsPixelPass(p, c)) { // key / cut-out first, so the grade never shifts the key colour
|
|
9168
9226
|
let processed = pixelPass(c, p, src, sx, sy, cw, ch, dw, dh);
|
|
9169
9227
|
if (runtime.gradeCapture) { // pixelPass reuses its canvas — keep a copy
|
|
@@ -9189,8 +9247,1047 @@ function drawClip(c, W, H, t) {
|
|
|
9189
9247
|
if (p.grain > 0) drawGrain(p.grain, -dw / 2, -dh / 2, dw, dh, t);
|
|
9190
9248
|
}
|
|
9191
9249
|
ctx2d.restore();
|
|
9250
|
+
return { p, geom };
|
|
9251
|
+
}
|
|
9252
|
+
|
|
9253
|
+
/* ═══════════ MASKS (props.masks — math in mask.js) ═══════════
|
|
9254
|
+
A masked clip draws into an offscreen layer instead of the program canvas;
|
|
9255
|
+
the layer is cut by the masks' matte (destination-in), then composited with
|
|
9256
|
+
the clip's opacity and blend. Preview and export share this path. */
|
|
9257
|
+
const Mask = self.FableCutMask;
|
|
9258
|
+
const maskCv = {};
|
|
9259
|
+
function maskCtx(key, W, H) {
|
|
9260
|
+
let cv = maskCv[key];
|
|
9261
|
+
if (!cv) cv = maskCv[key] = document.createElement("canvas");
|
|
9262
|
+
if (cv.width !== W) cv.width = W;
|
|
9263
|
+
if (cv.height !== H) cv.height = H;
|
|
9264
|
+
return cv.getContext("2d", { willReadFrequently: false });
|
|
9265
|
+
}
|
|
9266
|
+
const maskNorm = new WeakMap(); // stored list → validated list (hand-written JSON gets defaults once)
|
|
9267
|
+
function clipMasks(c) {
|
|
9268
|
+
const list = c.props && c.props.masks;
|
|
9269
|
+
if (!Array.isArray(list) || !list.length) return null;
|
|
9270
|
+
let n = maskNorm.get(list);
|
|
9271
|
+
if (n === undefined) { n = Mask.normalizeMasks(list); maskNorm.set(list, n); }
|
|
9272
|
+
return n;
|
|
9273
|
+
}
|
|
9274
|
+
/** The masks cutting clip c at timeline time t (keys resolved), or null. */
|
|
9275
|
+
function clipMasksAt(c, t) {
|
|
9276
|
+
if (c.kind === "audio") return null;
|
|
9277
|
+
const list = clipMasks(c);
|
|
9278
|
+
return list ? Mask.masksAt(list, t - c.start) : null;
|
|
9279
|
+
}
|
|
9280
|
+
/** Remember where the clip's picture landed (the monitor's mask editor maps through it). */
|
|
9281
|
+
function noteMaskGeom(c, m, x0, y0, w, h) {
|
|
9282
|
+
const g = { m, x0, y0, w, h };
|
|
9283
|
+
if (!state.exporting && !runtime.sampling) (runtime.maskGeom ||= new Map()).set(c.id, g);
|
|
9284
|
+
return g;
|
|
9285
|
+
}
|
|
9286
|
+
/* ── Track mattes ──
|
|
9287
|
+
A clip with props.matte is cut by the picture on its matte track (the video
|
|
9288
|
+
track above unless props.matteTrack names one): by that picture's alpha, or
|
|
9289
|
+
by its luma (white = shows), each optionally inverted. The matte track does
|
|
9290
|
+
not draw into the frame while a clip on screen uses it. */
|
|
9291
|
+
const videoTrackIds = () => TRACKS.filter((tr) => tr.kind === "video").map((tr) => tr.id);
|
|
9292
|
+
/** { mode, track } when the clip has a track matte with a track to take it from. */
|
|
9293
|
+
function clipMatte(c) {
|
|
9294
|
+
if (c.kind === "audio" || !c.props || !c.props.matte) return null;
|
|
9295
|
+
const track = Mask.matteTrackFor(c, videoTrackIds());
|
|
9296
|
+
return track ? { mode: c.props.matte, track } : null;
|
|
9297
|
+
}
|
|
9298
|
+
/** The tracks that are mattes for clips in `clips` (the frame's clips): they only cut. */
|
|
9299
|
+
function matteTracksAt(clips) {
|
|
9300
|
+
const out = new Set();
|
|
9301
|
+
for (const c of clips) { const m = clipMatte(c); if (m) out.add(m.track); }
|
|
9302
|
+
return out;
|
|
9303
|
+
}
|
|
9304
|
+
/** The matte track's picture at t as an alpha matte: { canvas, empty }. A luma matte
|
|
9305
|
+
* turns brightness into alpha (over black, so transparent = black). */
|
|
9306
|
+
function trackMatteAt(matte, W, H, t) {
|
|
9307
|
+
const src = maskCtx("tmSrc", W, H), luma = matte.mode.startsWith("luma");
|
|
9308
|
+
src.setTransform(1, 0, 0, 1, 0, 0);
|
|
9309
|
+
src.globalAlpha = 1; src.globalCompositeOperation = "source-over"; src.filter = "none";
|
|
9310
|
+
src.clearRect(0, 0, W, H);
|
|
9311
|
+
const clips = project.clips.filter((x) => x.track === matte.track && x.disabled !== true && x.kind !== "audio" &&
|
|
9312
|
+
x.kind !== "adjust" && activeAt(x, t)).sort((a, b) => a.start - b.start);
|
|
9313
|
+
if (!clips.length) return { canvas: src.canvas, empty: true };
|
|
9314
|
+
if (luma) { src.fillStyle = "#000"; src.fillRect(0, 0, W, H); }
|
|
9315
|
+
const main = ctx2d;
|
|
9316
|
+
ctx2d = src;
|
|
9317
|
+
try { for (const x of clips) drawClip(x, W, H, t, true); } finally { ctx2d = main; }
|
|
9318
|
+
if (!luma) return { canvas: src.canvas, empty: false };
|
|
9319
|
+
return { canvas: lumaToAlpha(src.canvas, maskCtx("tmOut", W, H), W, H), empty: false };
|
|
9320
|
+
}
|
|
9321
|
+
/** Brightness → alpha (Rec. 709 weights): an SVG luminanceToAlpha filter on the
|
|
9322
|
+
* canvas, or a pixel loop where canvas filters are missing. */
|
|
9323
|
+
let lumaFilter = null;
|
|
9324
|
+
function lumaToAlpha(srcCanvas, out, W, H) {
|
|
9325
|
+
out.setTransform(1, 0, 0, 1, 0, 0);
|
|
9326
|
+
out.globalAlpha = 1; out.globalCompositeOperation = "source-over";
|
|
9327
|
+
out.clearRect(0, 0, W, H);
|
|
9328
|
+
if (lumaFilter == null) lumaFilter = makeLumaFilter();
|
|
9329
|
+
if (lumaFilter) {
|
|
9330
|
+
out.filter = lumaFilter; out.drawImage(srcCanvas, 0, 0); out.filter = "none";
|
|
9331
|
+
return out.canvas;
|
|
9332
|
+
}
|
|
9333
|
+
out.drawImage(srcCanvas, 0, 0);
|
|
9334
|
+
const img = out.getImageData(0, 0, W, H), d = img.data;
|
|
9335
|
+
for (let i = 0; i < d.length; i += 4) {
|
|
9336
|
+
d[i + 3] = Math.round(0.2125 * d[i] + 0.7154 * d[i + 1] + 0.0721 * d[i + 2]);
|
|
9337
|
+
d[i] = d[i + 1] = d[i + 2] = 0;
|
|
9338
|
+
}
|
|
9339
|
+
out.putImageData(img, 0, 0);
|
|
9340
|
+
return out.canvas;
|
|
9341
|
+
}
|
|
9342
|
+
/** "url(#…)" for the luma filter, or "" when the canvas can't apply it (checked once on a pixel). */
|
|
9343
|
+
function makeLumaFilter() {
|
|
9344
|
+
try {
|
|
9345
|
+
const ns = "http://www.w3.org/2000/svg", svg = document.createElementNS(ns, "svg");
|
|
9346
|
+
svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); svg.setAttribute("aria-hidden", "true");
|
|
9347
|
+
svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden";
|
|
9348
|
+
svg.innerHTML = '<filter id="fcLumaAlpha" color-interpolation-filters="sRGB"><feColorMatrix type="luminanceToAlpha"/></filter>';
|
|
9349
|
+
document.body.appendChild(svg);
|
|
9350
|
+
const a = document.createElement("canvas"), b = document.createElement("canvas");
|
|
9351
|
+
a.width = b.width = a.height = b.height = 1;
|
|
9352
|
+
const x = a.getContext("2d"); x.fillStyle = "#fff"; x.fillRect(0, 0, 1, 1);
|
|
9353
|
+
const y = b.getContext("2d", { willReadFrequently: true });
|
|
9354
|
+
y.filter = "url(#fcLumaAlpha)"; y.drawImage(a, 0, 0);
|
|
9355
|
+
const px = y.getImageData(0, 0, 1, 1).data;
|
|
9356
|
+
return px[3] > 250 && px[0] < 5 ? "url(#fcLumaAlpha)" : "";
|
|
9357
|
+
} catch { return ""; }
|
|
9358
|
+
}
|
|
9359
|
+
/** A clip that is cut before it composites: by its masks, by a track matte, or both. */
|
|
9360
|
+
function drawMaskedClip(c, W, H, t, masks, matte = null) {
|
|
9361
|
+
const tm = matte ? trackMatteAt(matte, W, H, t) : null; // first: the matte's clips use the layer buffers too
|
|
9362
|
+
const layer = maskCtx("layer", W, H);
|
|
9363
|
+
layer.setTransform(1, 0, 0, 1, 0, 0);
|
|
9364
|
+
layer.globalAlpha = 1; layer.globalCompositeOperation = "source-over"; layer.filter = "none";
|
|
9365
|
+
layer.clearRect(0, 0, W, H);
|
|
9366
|
+
const main = ctx2d;
|
|
9367
|
+
let r;
|
|
9368
|
+
ctx2d = layer;
|
|
9369
|
+
try { r = drawClipPicture(c, W, H, t, true); } finally { ctx2d = main; }
|
|
9370
|
+
if (!r || !r.geom) return;
|
|
9371
|
+
layer.setTransform(1, 0, 0, 1, 0, 0);
|
|
9372
|
+
if (tm) {
|
|
9373
|
+
layer.globalCompositeOperation = matte.mode.endsWith("-inverted") ? "destination-out" : "destination-in";
|
|
9374
|
+
if (tm.empty) { if (!matte.mode.endsWith("-inverted")) layer.clearRect(0, 0, W, H); } // nothing on the matte track: alpha hides all
|
|
9375
|
+
else layer.drawImage(tm.canvas, 0, 0);
|
|
9376
|
+
layer.globalCompositeOperation = "source-over";
|
|
9377
|
+
}
|
|
9378
|
+
if (masks) cutByMasks(c, layer, r.geom, masks, W, H);
|
|
9379
|
+
main.save();
|
|
9380
|
+
main.setTransform(1, 0, 0, 1, 0, 0);
|
|
9381
|
+
const p = r.p;
|
|
9382
|
+
if (c.kind !== "adjust") { // an adjustment layer already drew at its own opacity
|
|
9383
|
+
main.globalAlpha = clamp(p.opacity, 0, 1);
|
|
9384
|
+
if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) main.globalCompositeOperation = p.blend;
|
|
9385
|
+
}
|
|
9386
|
+
main.drawImage(layer.canvas, 0, 0);
|
|
9387
|
+
main.restore();
|
|
9388
|
+
}
|
|
9389
|
+
function cutByMasks(c, layer, g, masks, W, H) {
|
|
9390
|
+
const matte = maskCtx("matte", W, H);
|
|
9391
|
+
Mask.rasterize(matte, maskCtx("tmp", W, H), maskCtx("tmp2", W, H), masks,
|
|
9392
|
+
g.m.translate(g.x0, g.y0), g.w, g.h, W / Math.max(1, project.width));
|
|
9393
|
+
layer.setTransform(1, 0, 0, 1, 0, 0);
|
|
9394
|
+
layer.globalCompositeOperation = "destination-in";
|
|
9395
|
+
layer.drawImage(matte.canvas, 0, 0);
|
|
9396
|
+
layer.globalCompositeOperation = "source-over";
|
|
9397
|
+
const cap = runtime.maskCapture;
|
|
9398
|
+
if (cap && cap.id === c.id && cap.coverage == null) measureMaskCoverage(cap, masks, g, layer.canvas, W, H);
|
|
9399
|
+
const view = runtime.maskView;
|
|
9400
|
+
if (view && view.id === c.id && state.selId === c.id && !state.exporting) { // the monitor shows the selected clip's matte
|
|
9401
|
+
const v = maskCtx("view", W, H);
|
|
9402
|
+
v.setTransform(1, 0, 0, 1, 0, 0);
|
|
9403
|
+
v.globalCompositeOperation = "source-over"; v.clearRect(0, 0, W, H);
|
|
9404
|
+
v.fillStyle = "#fff"; v.fillRect(0, 0, W, H);
|
|
9405
|
+
v.globalCompositeOperation = "destination-in"; v.drawImage(matte.canvas, 0, 0);
|
|
9406
|
+
v.globalCompositeOperation = "source-over";
|
|
9407
|
+
view.ready = true;
|
|
9408
|
+
}
|
|
9409
|
+
}
|
|
9410
|
+
/** fablecut_scopes mask: how much of the clip's own picture its masks keep
|
|
9411
|
+
* (rasterized alone, box-aligned) and how much of the frame the cut clip covers. */
|
|
9412
|
+
function measureMaskCoverage(cap, masks, g, layerCanvas, W, H) {
|
|
9413
|
+
const mean = (cv, w, h) => {
|
|
9414
|
+
const s = document.createElement("canvas"); s.width = w; s.height = h;
|
|
9415
|
+
const x = s.getContext("2d", { willReadFrequently: true });
|
|
9416
|
+
x.drawImage(cv, 0, 0, w, h);
|
|
9417
|
+
const d = x.getImageData(0, 0, w, h).data;
|
|
9418
|
+
let sum = 0;
|
|
9419
|
+
for (let i = 3; i < d.length; i += 4) sum += d[i];
|
|
9420
|
+
return sum / 255 / (w * h);
|
|
9421
|
+
};
|
|
9422
|
+
const bw = Math.max(1, g.w), bh = Math.max(1, g.h), k = Math.min(1, 480 / Math.max(bw, bh));
|
|
9423
|
+
const mw = Math.max(2, Math.round(bw * k)), mh = Math.max(2, Math.round(bh * k));
|
|
9424
|
+
const out = maskCtx("cap", mw, mh);
|
|
9425
|
+
Mask.rasterize(out, maskCtx("cap1", mw, mh), maskCtx("cap2", mw, mh), masks, new DOMMatrix([mw / bw, 0, 0, mh / bh, 0, 0]), bw, bh,
|
|
9426
|
+
W / Math.max(1, project.width) * mw / bw);
|
|
9427
|
+
cap.coverage = +(100 * mean(out.canvas, mw, mh)).toFixed(2);
|
|
9428
|
+
const fw = Math.max(2, Math.min(480, W)), fh = Math.max(2, Math.round(fw * H / W));
|
|
9429
|
+
cap.frameCoverage = +(100 * mean(layerCanvas, fw, fh)).toFixed(2);
|
|
9430
|
+
}
|
|
9431
|
+
/** Matte view: the selected clip's mask matte, white on black, over the frame. */
|
|
9432
|
+
function drawMaskView(W, H) {
|
|
9433
|
+
const view = runtime.maskView;
|
|
9434
|
+
if (!view || !view.ready || state.selId !== view.id) return; // off once the clip is deselected
|
|
9435
|
+
ctx2d.save();
|
|
9436
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
9437
|
+
ctx2d.globalAlpha = 1; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
|
|
9438
|
+
ctx2d.fillStyle = "#000"; ctx2d.fillRect(0, 0, W, H);
|
|
9439
|
+
ctx2d.drawImage(maskCv.view, 0, 0);
|
|
9440
|
+
ctx2d.restore();
|
|
9192
9441
|
}
|
|
9193
9442
|
|
|
9443
|
+
/* ── Masks in the Inspector and on the monitor ──
|
|
9444
|
+
The Masks section lists the clip's masks; picking one shows its outline and
|
|
9445
|
+
handles on the monitor. Pen: click to place points (drag pulls handles),
|
|
9446
|
+
click the first point or press Enter to close. Draw: drag a free-hand
|
|
9447
|
+
outline, fitted into an editable bezier. */
|
|
9448
|
+
const maskUi = { clip: null, sel: -1, pt: -1, tool: null, pen: null, stroke: null, drag: null };
|
|
9449
|
+
const MASK_ROWS_UI = {
|
|
9450
|
+
opacity: ["Opacity", 0, 1, 0.01], feather: ["Feather", 0, 300, 1], expand: ["Expand", -200, 200, 1],
|
|
9451
|
+
x: ["X", -0.5, 1.5, 0.001], y: ["Y", -0.5, 1.5, 0.001], w: ["Width", 0.01, 2, 0.001], h: ["Height", 0.01, 2, 0.001],
|
|
9452
|
+
scale: ["Scale", 0.05, 4, 0.01], rotation: ["Rotation", -180, 180, 0.5],
|
|
9453
|
+
};
|
|
9454
|
+
const MASK_MODE_LABEL = { add: "Add", subtract: "Subtract", intersect: "Intersect", difference: "Difference" };
|
|
9455
|
+
function maskList(c) { return clipMasks(c) || []; }
|
|
9456
|
+
function maskUiFor(c) {
|
|
9457
|
+
if (!c || maskUi.clip !== c.id) { Object.assign(maskUi, { clip: c ? c.id : null, sel: -1, pt: -1, tool: null, pen: null, stroke: null, drag: null }); if (runtime.maskView) runtime.maskView = null; }
|
|
9458
|
+
const n = maskList(c).length;
|
|
9459
|
+
if (maskUi.sel >= n) maskUi.sel = n - 1;
|
|
9460
|
+
return maskUi;
|
|
9461
|
+
}
|
|
9462
|
+
function writeMasks(c, list) {
|
|
9463
|
+
const n = list.length ? Mask.normalizeMasks(list) : null;
|
|
9464
|
+
if (n) c.props.masks = n; else delete c.props.masks;
|
|
9465
|
+
state.dirtyTimeline = true;
|
|
9466
|
+
}
|
|
9467
|
+
const maskLocalT = (c) => state.time - c.start;
|
|
9468
|
+
/** Mask i as it is at the playhead. */
|
|
9469
|
+
function maskNowAt(c, i) { const m = maskList(c)[i]; return m ? Mask.maskAt(m, maskLocalT(c)) : null; }
|
|
9470
|
+
function maskKeyIndex(m, t) {
|
|
9471
|
+
const tol = 0.5 / (projectFps() || 30);
|
|
9472
|
+
return m && m.keys ? m.keys.findIndex((k) => Math.abs(k.t - t) < tol) : -1;
|
|
9473
|
+
}
|
|
9474
|
+
function maskSnapshotKey(now, t) {
|
|
9475
|
+
const k = { t: +Math.max(0, t).toFixed(4) };
|
|
9476
|
+
for (const p of Mask.KEYED) if (now[p] != null) k[p] = p === "points" ? now.points.map((q) => q.slice()) : now[p];
|
|
9477
|
+
return k;
|
|
9478
|
+
}
|
|
9479
|
+
/** Change mask i; once it has keys, keyed params land in a key at the playhead. */
|
|
9480
|
+
function writeMaskSet(c, i, set) {
|
|
9481
|
+
const list = maskList(c).map((m) => m), m = list[i];
|
|
9482
|
+
if (!m) return;
|
|
9483
|
+
if (!m.keys || !m.keys.length || !Mask.KEYED.some((p) => p in set)) { list[i] = Mask.mergeMask(m, set) || m; writeMasks(c, list); return; }
|
|
9484
|
+
const t = maskLocalT(c), keys = m.keys.map((k) => ({ ...k })), now = Mask.maskAt(m, t), statics = {};
|
|
9485
|
+
for (const [p, v] of Object.entries(set)) if (!Mask.KEYED.includes(p)) statics[p] = v;
|
|
9486
|
+
let j = maskKeyIndex(m, t);
|
|
9487
|
+
if (j < 0) { const k = maskSnapshotKey(now, t); keys.push(k); keys.sort((a, b) => a.t - b.t); j = keys.indexOf(k); }
|
|
9488
|
+
for (const p of Mask.KEYED) if (p in set) keys[j][p] = set[p];
|
|
9489
|
+
list[i] = Mask.mergeMask(m, { ...statics, keys }) || m;
|
|
9490
|
+
writeMasks(c, list);
|
|
9491
|
+
}
|
|
9492
|
+
/* ── Tracking ──
|
|
9493
|
+
tracker.js follows a region of the clip's own video frame by frame. The
|
|
9494
|
+
frames come from a private <video> (the monitor never moves), drawn small
|
|
9495
|
+
enough that the region is ~56 px across. A track is stored on its clip as
|
|
9496
|
+
picture fractions (props.tracks); masks and other clips follow it by having
|
|
9497
|
+
it baked into their keys, so rendering never depends on it. */
|
|
9498
|
+
const Tracker = self.FableCutTracker;
|
|
9499
|
+
const trackUi = { clip: null, sel: -1, tool: null, size: 0.08, scale: false, rotation: false, run: null };
|
|
9500
|
+
const trackNorm = new WeakMap();
|
|
9501
|
+
function clipTracks(c) {
|
|
9502
|
+
const list = c && c.props && c.props.tracks;
|
|
9503
|
+
if (!Array.isArray(list) || !list.length) return [];
|
|
9504
|
+
let n = trackNorm.get(list);
|
|
9505
|
+
if (n === undefined) { n = Tracker.normalizeTracks(list) || []; trackNorm.set(list, n); }
|
|
9506
|
+
return n;
|
|
9507
|
+
}
|
|
9508
|
+
function writeTracks(c, list) {
|
|
9509
|
+
const n = list.length ? Tracker.normalizeTracks(list) : null;
|
|
9510
|
+
if (n) c.props.tracks = n; else delete c.props.tracks;
|
|
9511
|
+
state.dirtyTimeline = true;
|
|
9512
|
+
}
|
|
9513
|
+
/** Crop as fractions; picture (u, v) ↔ media (x, y), both 0…1. */
|
|
9514
|
+
function cropFrac(p) {
|
|
9515
|
+
const k = (v) => clamp(+v || 0, 0, 95) / 100;
|
|
9516
|
+
return { l: k(p.cropL), t: k(p.cropT), r: k(p.cropR), b: k(p.cropB) };
|
|
9517
|
+
}
|
|
9518
|
+
function picToMedia(p, u, v) { const k = cropFrac(p); return { x: k.l + u * (1 - k.l - k.r), y: k.t + v * (1 - k.t - k.b) }; }
|
|
9519
|
+
function mediaToPic(p, x, y) { const k = cropFrac(p); return { u: (x - k.l) / Math.max(1e-6, 1 - k.l - k.r), v: (y - k.t) / Math.max(1e-6, 1 - k.t - k.b) }; }
|
|
9520
|
+
function seekFrame(el, t) {
|
|
9521
|
+
return new Promise((res, rej) => {
|
|
9522
|
+
const done = () => { el.removeEventListener("seeked", done); clearTimeout(tm); res(); };
|
|
9523
|
+
const tm = setTimeout(() => { el.removeEventListener("seeked", done); rej(new Error(`the video did not seek to ${t.toFixed(2)} s`)); }, 4000);
|
|
9524
|
+
el.addEventListener("seeked", done);
|
|
9525
|
+
el.currentTime = t;
|
|
9526
|
+
});
|
|
9527
|
+
}
|
|
9528
|
+
/**
|
|
9529
|
+
* Follow `box` (picture fractions {x, y, w, h}) through clip c from clip-local
|
|
9530
|
+
* `from` towards `to` (dir ±1), one sample per project frame. Returns
|
|
9531
|
+
* { samples: [[t, x, y, s, r, q], …] in time order, lostAt, cancelled }.
|
|
9532
|
+
*/
|
|
9533
|
+
async function runTrack(c, { box, from, to, dir, scale = false, rotation = false, onProgress, run = {} }) {
|
|
9534
|
+
const m = getMedia(c.mediaId);
|
|
9535
|
+
if (c.kind !== "video" || !m) throw new Error("tracking needs a video clip");
|
|
9536
|
+
const el = document.createElement("video");
|
|
9537
|
+
el.muted = true; el.preload = "auto"; el.playsInline = true; el.src = m.src;
|
|
9538
|
+
try {
|
|
9539
|
+
await new Promise((res, rej) => {
|
|
9540
|
+
const tm = setTimeout(() => rej(new Error("the video did not load")), 15000);
|
|
9541
|
+
el.addEventListener("loadeddata", () => { clearTimeout(tm); res(); }, { once: true });
|
|
9542
|
+
el.addEventListener("error", () => { clearTimeout(tm); rej(new Error("the video could not be read")); }, { once: true });
|
|
9543
|
+
});
|
|
9544
|
+
const vw = el.videoWidth, vh = el.videoHeight, p0 = evalProps(c, c.start + from), k = cropFrac(p0);
|
|
9545
|
+
const bw = Math.max(4, box.w * (1 - k.l - k.r) * vw), bh = Math.max(4, box.h * (1 - k.t - k.b) * vh);
|
|
9546
|
+
const sc = clamp(56 / Math.max(bw, bh), 160 / vw, Math.min(1, 1280 / vw));
|
|
9547
|
+
const aw = Math.max(16, Math.round(vw * sc)), ah = Math.max(16, Math.round(vh * sc));
|
|
9548
|
+
const cv = document.createElement("canvas"); cv.width = aw; cv.height = ah;
|
|
9549
|
+
const x = cv.getContext("2d", { willReadFrequently: true });
|
|
9550
|
+
const grab = async (local) => {
|
|
9551
|
+
await seekFrame(el, Math.max(0, mediaTimeAt(c, c.start + local)) + 0.001);
|
|
9552
|
+
x.drawImage(el, 0, 0, aw, ah);
|
|
9553
|
+
return Tracker.toGray(x.getImageData(0, 0, aw, ah).data, aw, ah);
|
|
9554
|
+
};
|
|
9555
|
+
const mc = picToMedia(p0, box.x, box.y);
|
|
9556
|
+
const tr = Tracker.createTracker(await grab(from), aw, ah, { cx: mc.x * aw, cy: mc.y * ah, w: bw * sc, h: bh * sc }, { scale, rotation });
|
|
9557
|
+
if (tr.flat) throw new Error("that region has no detail to follow — pick a spot with texture or edges");
|
|
9558
|
+
const dt = 1 / (projectFps() || 30), end = clamp(to, 0, c.duration);
|
|
9559
|
+
const n = Math.floor(Math.abs(end - from) / dt + 1e-6);
|
|
9560
|
+
const samples = [[+from.toFixed(4), box.x, box.y, 1, 0, 1]];
|
|
9561
|
+
let lostAt = null;
|
|
9562
|
+
for (let i = 1; i <= n; i++) {
|
|
9563
|
+
if (run.cancel) break;
|
|
9564
|
+
const t = from + dir * i * dt, r = tr.step(await grab(t));
|
|
9565
|
+
if (r.lost) { lostAt = t; break; }
|
|
9566
|
+
const pic = mediaToPic(p0, r.cx / aw, r.cy / ah);
|
|
9567
|
+
samples.push([+t.toFixed(4), pic.u, pic.v, r.s, r.r, r.q]);
|
|
9568
|
+
if (onProgress) onProgress(i / n, t);
|
|
9569
|
+
}
|
|
9570
|
+
if (dir < 0) samples.reverse();
|
|
9571
|
+
return { samples, lostAt, cancelled: !!run.cancel };
|
|
9572
|
+
} finally { el.removeAttribute("src"); el.load(); }
|
|
9573
|
+
}
|
|
9574
|
+
/** What a tracking result was measured against: the clip object and its timing. */
|
|
9575
|
+
function trackStamp(c) {
|
|
9576
|
+
if (!c) return "";
|
|
9577
|
+
const p = c.props || {}, k = c.keyframes || {};
|
|
9578
|
+
return JSON.stringify([c.mediaId, c.start, c.in, c.duration, clipSpeed(c), k.speed || null,
|
|
9579
|
+
p.cropL, p.cropR, p.cropT, p.cropB, k.cropL || null, k.cropR || null, k.cropT || null, k.cropB || null]);
|
|
9580
|
+
}
|
|
9581
|
+
/** Merge new samples into track `name` (replacing the old ones in their time range). */
|
|
9582
|
+
function storeTrack(c, name, kind, w, h, samples) {
|
|
9583
|
+
const list = clipTracks(c).map((x) => ({ ...x })), t0 = samples[0][0], t1 = samples[samples.length - 1][0];
|
|
9584
|
+
const i = list.findIndex((x) => x.name === name);
|
|
9585
|
+
const keep = i >= 0 ? list[i].samples.filter((s) => s[0] < t0 - 1e-4 || s[0] > t1 + 1e-4) : [];
|
|
9586
|
+
const fresh = kind === "box" ? Tracker.smooth(samples) : samples; // scale / rotation steadied once, for every use
|
|
9587
|
+
const tr = { name, kind, w: i >= 0 ? list[i].w : w, h: i >= 0 ? list[i].h : h, samples: [...keep, ...fresh].sort((a, b) => a[0] - b[0]) };
|
|
9588
|
+
if (i >= 0) list[i] = tr; else list.push(tr);
|
|
9589
|
+
writeTracks(c, list.slice(-Tracker.MAX_TRACKS));
|
|
9590
|
+
return clipTracks(c).find((x) => x.name === name);
|
|
9591
|
+
}
|
|
9592
|
+
/** Bake samples into mask i's keys: x / y (and scale / rotation) move by what the samples moved. */
|
|
9593
|
+
function bakeTrackIntoMask(c, i, samples, { scale, rotation }, from = samples[0][0]) {
|
|
9594
|
+
const list = maskList(c).slice(), raw = list[i];
|
|
9595
|
+
if (!raw || samples.length < 2) return 0;
|
|
9596
|
+
const g = pictureRect(c, evalProps(c, c.start + samples[0][0]));
|
|
9597
|
+
// the mask is where it was drawn at `from` (where tracking started): motion is measured from there, on the
|
|
9598
|
+
// smoothed path, and a key stays at `from` — so a backward or two-way track keeps it exactly at the playhead
|
|
9599
|
+
const sm3 = Tracker.smooth(samples, 3, 1);
|
|
9600
|
+
const anchor = sm3.find((sm) => Math.abs(sm[0] - from) < 1e-4) || (() => { const q = Tracker.trackAt({ samples: sm3 }, from); return [from, q.x, q.y, q.s, q.r, q.q]; })();
|
|
9601
|
+
const S = Tracker.simplify(sm3, 1.5 / Math.max(1, g ? g.dw : 1000)); // keys within ~1.5 px
|
|
9602
|
+
if (from >= samples[0][0] && from <= samples[samples.length - 1][0] && !S.some((sm) => Math.abs(sm[0] - from) < 1e-4)) {
|
|
9603
|
+
S.push(anchor); S.sort((a, b) => a[0] - b[0]);
|
|
9604
|
+
}
|
|
9605
|
+
const first = samples[0], base = Mask.maskAt(raw, from);
|
|
9606
|
+
const motion = ["x", "y", ...(scale ? ["scale"] : []), ...(rotation ? ["rotation"] : [])];
|
|
9607
|
+
const t0 = first[0] - 1e-4, t1 = samples[samples.length - 1][0] + 1e-4;
|
|
9608
|
+
let keys = (raw.keys || []).map((k) => ({ ...k }));
|
|
9609
|
+
for (const k of keys) if (k.t >= t0 && k.t <= t1) for (const p of motion) delete k[p];
|
|
9610
|
+
keys = keys.filter((k) => Object.keys(k).some((p) => p !== "t" && p !== "ease"));
|
|
9611
|
+
for (const sm of S) {
|
|
9612
|
+
const v = { x: +(base.x + sm[1] - anchor[1]).toFixed(4), y: +(base.y + sm[2] - anchor[2]).toFixed(4) };
|
|
9613
|
+
if (scale) v.scale = +(base.scale * sm[3] / (anchor[3] || 1)).toFixed(4);
|
|
9614
|
+
if (rotation) v.rotation = +(base.rotation + sm[4] - anchor[4]).toFixed(3);
|
|
9615
|
+
const at = keys.find((k) => Math.abs(k.t - sm[0]) < 1e-4);
|
|
9616
|
+
if (at) Object.assign(at, v, { ease: "linear" }); else keys.push({ t: sm[0], ease: "linear", ...v });
|
|
9617
|
+
}
|
|
9618
|
+
keys.sort((a, b) => a.t - b.t);
|
|
9619
|
+
list[i] = Mask.mergeMask(raw, { keys }) || raw;
|
|
9620
|
+
writeMasks(c, list);
|
|
9621
|
+
return S.length;
|
|
9622
|
+
}
|
|
9623
|
+
/** Where the clip's video is drawn at props p: centre, size, rotation, flips (project px). */
|
|
9624
|
+
function pictureRect(c, p) {
|
|
9625
|
+
const el = c.kind === "video" ? getClipEl(c) : null, W = project.width, H = project.height;
|
|
9626
|
+
const sw = el && el.videoWidth, sh = el && el.videoHeight;
|
|
9627
|
+
if (!sw || !sh) return null;
|
|
9628
|
+
const k = cropFrac(p), cw = Math.max(1, sw * (1 - k.l - k.r)), ch = Math.max(1, sh * (1 - k.t - k.b)), sc = p.scale || 1;
|
|
9629
|
+
let dw, dh;
|
|
9630
|
+
if (p.fit === "cover") { const f = Math.max(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
|
|
9631
|
+
else if (p.fit === "stretch") { dw = W * sc; dh = H * sc; }
|
|
9632
|
+
else if (p.fit === "none") { dw = cw * sc; dh = ch * sc; }
|
|
9633
|
+
else { const f = Math.min(W / cw, H / ch) * sc; dw = cw * f; dh = ch * f; }
|
|
9634
|
+
return { cx: W / 2 + (+p.x || 0), cy: H / 2 + (+p.y || 0), dw, dh, rot: (p.rotation || 0) * Math.PI / 180, fx: p.flipH ? -1 : 1, fy: p.flipV ? -1 : 1 };
|
|
9635
|
+
}
|
|
9636
|
+
/** A track's point on the frame at timeline time T (project px), with its scale and rotation there. */
|
|
9637
|
+
function trackOnFrame(c, tr, T) {
|
|
9638
|
+
const s = Tracker.trackAt(tr, T - c.start), p = evalProps(c, T), g = pictureRect(c, p);
|
|
9639
|
+
if (!g) return null;
|
|
9640
|
+
const lx = (s.x - 0.5) * g.dw * g.fx, ly = (s.y - 0.5) * g.dh * g.fy, cs = Math.cos(g.rot), sn = Math.sin(g.rot);
|
|
9641
|
+
return { x: g.cx + lx * cs - ly * sn, y: g.cy + lx * sn + ly * cs, s: s.s * (p.scale || 1), r: (g.fx * g.fy < 0 ? -s.r : s.r) + (p.rotation || 0) };
|
|
9642
|
+
}
|
|
9643
|
+
/** Bake clip c's track into clip b's x / y (and scale / rotation) keyframes so b rides on it. */
|
|
9644
|
+
function attachToTrack(c, track, b, { scale, rotation, at = state.time }) {
|
|
9645
|
+
const tr = { ...track, samples: Tracker.smooth(track.samples, 3, 1) }, S = tr.samples, a = Math.max(c.start + S[0][0], b.start), z = Math.min(c.start + S[S.length - 1][0], b.start + b.duration);
|
|
9646
|
+
if (z - a < 1e-3) return 0;
|
|
9647
|
+
const tRef = clamp(at, a, z), ref = trackOnFrame(c, tr, tRef);
|
|
9648
|
+
if (!ref) return 0;
|
|
9649
|
+
const pb = evalProps(b, tRef), W = project.width, H = project.height;
|
|
9650
|
+
const off = { x: (+pb.x || 0) - (ref.x - W / 2), y: (+pb.y || 0) - (ref.y - H / 2) };
|
|
9651
|
+
const g = pictureRect(c, evalProps(c, tRef));
|
|
9652
|
+
// the samples inside the overlap, plus the track at its edges and at the reference (between samples, too)
|
|
9653
|
+
const edge = (T) => { const q = Tracker.trackAt(tr, T - c.start); return [+(T - c.start).toFixed(4), q.x, q.y, q.s, q.r, q.q]; };
|
|
9654
|
+
const inRange = S.filter((sm) => c.start + sm[0] > a + 1e-4 && c.start + sm[0] < z - 1e-4);
|
|
9655
|
+
for (const T of [a, z, tRef]) if (!inRange.some((sm) => Math.abs(c.start + sm[0] - T) < 1e-4)) inRange.push(edge(T));
|
|
9656
|
+
inRange.sort((p, q) => p[0] - q[0]);
|
|
9657
|
+
const pts = Tracker.simplify(inRange, 1.5 / Math.max(1, g ? g.dw : 1000));
|
|
9658
|
+
const props = ["x", "y", ...(scale ? ["scale"] : []), ...(rotation ? ["rotation"] : [])];
|
|
9659
|
+
b.keyframes = b.keyframes || {};
|
|
9660
|
+
const l0 = a - b.start - 1e-4, l1 = z - b.start + 1e-4;
|
|
9661
|
+
for (const k of props) b.keyframes[k] = (b.keyframes[k] || []).filter((kf) => kf.t < l0 || kf.t > l1);
|
|
9662
|
+
for (const sm of pts) {
|
|
9663
|
+
const T = c.start + sm[0], v = trackOnFrame(c, tr, T), t = +(T - b.start).toFixed(4);
|
|
9664
|
+
if (!v) continue;
|
|
9665
|
+
const vals = { x: v.x - W / 2 + off.x, y: v.y - H / 2 + off.y, scale: (+pb.scale || 1) * v.s / ref.s, rotation: (+pb.rotation || 0) + v.r - ref.r };
|
|
9666
|
+
for (const k of props) b.keyframes[k].push({ t, v: +vals[k].toFixed(k === "scale" ? 4 : 2), ease: "linear" });
|
|
9667
|
+
}
|
|
9668
|
+
for (const k of props) { b.keyframes[k].sort((p, q) => p.t - q.t); if (!b.keyframes[k].length) delete b.keyframes[k]; }
|
|
9669
|
+
if (!Object.keys(b.keyframes).length) delete b.keyframes;
|
|
9670
|
+
state.dirtyTimeline = true; inspPropGen++;
|
|
9671
|
+
return pts.length;
|
|
9672
|
+
}
|
|
9673
|
+
/** The mask's outline bounds at clip-local t, as picture fractions (clamped into the picture). */
|
|
9674
|
+
function maskBoundsAt(c, raw, t) {
|
|
9675
|
+
const g = pictureRect(c, evalProps(c, c.start + t)), m = Mask.maskAt(raw, t);
|
|
9676
|
+
if (!g) return null;
|
|
9677
|
+
const B = { bw: g.dw, bh: g.dh };
|
|
9678
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
9679
|
+
for (const a of Mask.anchors(m, B.bw, B.bh)) { x0 = Math.min(x0, a.x); y0 = Math.min(y0, a.y); x1 = Math.max(x1, a.x); y1 = Math.max(y1, a.y); }
|
|
9680
|
+
x0 = clamp(x0 / B.bw, 0, 1); x1 = clamp(x1 / B.bw, 0, 1); y0 = clamp(y0 / B.bh, 0, 1); y1 = clamp(y1 / B.bh, 0, 1);
|
|
9681
|
+
if (x1 - x0 < 0.01 || y1 - y0 < 0.01) return null;
|
|
9682
|
+
return { x: (x0 + x1) / 2, y: (y0 + y1) / 2, w: x1 - x0, h: y1 - y0 };
|
|
9683
|
+
}
|
|
9684
|
+
/** Run a track from the playhead with progress in the Inspector; Esc stops it. */
|
|
9685
|
+
async function trackWithUi(c, label, job, after) {
|
|
9686
|
+
if (trackUi.run) { toast("Already tracking — wait, or press Esc to stop"); return null; }
|
|
9687
|
+
if (state.playing) pause();
|
|
9688
|
+
const run = trackUi.run = { cancel: false, clip: c.id, label, pct: 0 };
|
|
9689
|
+
const stamp = trackStamp(c), maskStamp = job.mask != null ? JSON.stringify(maskList(c)[job.mask] ?? null) : null;
|
|
9690
|
+
renderInspector();
|
|
9691
|
+
try {
|
|
9692
|
+
const r = await runTrack(c, { ...job, run, onProgress: (f) => {
|
|
9693
|
+
run.pct = f;
|
|
9694
|
+
for (const el of els.inspector.querySelectorAll("[data-track-progress]")) el.textContent = `${label}: ${Math.round(f * 100)}% — Esc stops`;
|
|
9695
|
+
} });
|
|
9696
|
+
if (r.samples.length < 2) { toast(r.lostAt != null ? "Lost the region on the next frame — try a spot with more detail, or a bigger region" : "Nothing to track there"); return r; }
|
|
9697
|
+
if (getClip(c.id) !== c || trackStamp(c) !== stamp || (maskStamp != null && JSON.stringify(maskList(c)[job.mask] ?? null) !== maskStamp)) {
|
|
9698
|
+
toast("The clip changed while tracking — nothing was applied; track it again");
|
|
9699
|
+
return null;
|
|
9700
|
+
}
|
|
9701
|
+
pushUndo();
|
|
9702
|
+
after(r);
|
|
9703
|
+
scheduleSave();
|
|
9704
|
+
const span = Math.abs(r.samples[r.samples.length - 1][0] - r.samples[0][0]);
|
|
9705
|
+
toast(r.cancelled ? `Stopped — kept ${span.toFixed(2)} s` : r.lostAt != null ? `Lost the region at ${fmt(c.start + r.lostAt)} — kept ${span.toFixed(2)} s up to there` : `Tracked ${span.toFixed(2)} s (${r.samples.length} frames)`);
|
|
9706
|
+
return r;
|
|
9707
|
+
} catch (err) {
|
|
9708
|
+
toast("Tracking failed: " + (err && err.message || err));
|
|
9709
|
+
return null;
|
|
9710
|
+
} finally {
|
|
9711
|
+
trackUi.run = null;
|
|
9712
|
+
state.dirtyTimeline = true;
|
|
9713
|
+
renderInspector(); drawFrame();
|
|
9714
|
+
}
|
|
9715
|
+
}
|
|
9716
|
+
function trackMaskFromPlayhead(c, i, dir) {
|
|
9717
|
+
const raw = maskList(c)[i], t = maskLocalT(c);
|
|
9718
|
+
if (!raw || !activeAt(c, state.time)) { toast("Move the playhead over the clip first"); return; }
|
|
9719
|
+
const box = maskBoundsAt(c, raw, t);
|
|
9720
|
+
if (!box) { toast("The mask is outside the picture here"); return; }
|
|
9721
|
+
const name = raw.name || `Mask ${i + 1}`, opts = { scale: trackUi.scale, rotation: trackUi.rotation };
|
|
9722
|
+
trackWithUi(c, `Tracking ${name}`, { box, from: t, to: dir > 0 ? c.duration : 0, dir, mask: i, ...opts }, (r) => {
|
|
9723
|
+
storeTrack(c, name, "box", box.w, box.h, r.samples);
|
|
9724
|
+
bakeTrackIntoMask(c, i, r.samples, opts, t);
|
|
9725
|
+
});
|
|
9726
|
+
}
|
|
9727
|
+
function trackPointFromPlayhead(c, name, box, dir) {
|
|
9728
|
+
const t = clamp(state.time - c.start, 0, c.duration);
|
|
9729
|
+
trackWithUi(c, `Tracking ${name}`, { box, from: t, to: dir > 0 ? c.duration : 0, dir, scale: trackUi.scale, rotation: trackUi.rotation }, (r) => {
|
|
9730
|
+
storeTrack(c, name, trackUi.scale || trackUi.rotation ? "box" : "point", box.w, box.h, r.samples);
|
|
9731
|
+
trackUi.sel = clipTracks(c).findIndex((x) => x.name === name);
|
|
9732
|
+
});
|
|
9733
|
+
}
|
|
9734
|
+
/** The region a track continues from at the playhead: its position there, its size. */
|
|
9735
|
+
function trackBoxAt(c, tr) {
|
|
9736
|
+
const s = Tracker.trackAt(tr, state.time - c.start);
|
|
9737
|
+
return { x: s.x, y: s.y, w: tr.w * s.s, h: tr.h * s.s };
|
|
9738
|
+
}
|
|
9739
|
+
function trackSectionHtml(c) {
|
|
9740
|
+
if (trackUi.clip !== c.id) Object.assign(trackUi, { clip: c.id, sel: -1, tool: null });
|
|
9741
|
+
const list = clipTracks(c), tr = list[trackUi.sel], run = trackUi.run && trackUi.run.clip === c.id ? trackUi.run : null;
|
|
9742
|
+
const others = project.clips.filter((x) => x.id !== c.id && isVisualClip(x) && x.start < c.start + c.duration && c.start < x.start + x.duration);
|
|
9743
|
+
const chips = list.map((x, j) => `<button type="button" class="btn tiny cl-chip${j === trackUi.sel ? " toggle on" : ""}" data-tsel="${j}" title="${escapeHtml(Tracker.describe([x]))}">${escapeHtml(x.name)}</button>`).join("");
|
|
9744
|
+
let html = `<div class="insp-section" data-track-section><h3>Tracking</h3>
|
|
9745
|
+
<div class="cl-row"><button type="button" class="btn tiny${trackUi.tool === "point" ? " toggle on" : ""}" data-tact="point" ${run ? "disabled" : ""} title="Click a spot on the monitor to follow it from the playhead to the clip's end">+ Track point</button>
|
|
9746
|
+
<button type="button" class="btn tiny${trackUi.scale ? " toggle on" : ""}" data-tact="scale" title="Also follow the region's size (tracking, and attaching)">Scale</button>
|
|
9747
|
+
<button type="button" class="btn tiny${trackUi.rotation ? " toggle on" : ""}" data-tact="rotation" title="Also follow the region's rotation (tracking, and attaching)">Rotate</button></div>
|
|
9748
|
+
<div class="insp-row cp-row"><label class="cp-label" title="The area matched around a picked point, % of the picture's width">Region %</label>
|
|
9749
|
+
<input type="number" class="cp-num" data-tsize min="2" max="40" step="1" value="${Math.round(trackUi.size * 100)}"></div>
|
|
9750
|
+
${run ? `<p class="cl-note" data-track-progress>${escapeHtml(run.label)}: ${Math.round(run.pct * 100)}% — Esc stops</p>` : ""}
|
|
9751
|
+
${trackUi.tool === "point" ? `<p class="cl-note">Click the spot to follow on the monitor — something with detail: a corner, a logo, an eye. Esc cancels.</p>` : ""}
|
|
9752
|
+
${list.length ? `<div class="cl-row">${chips}</div>` : trackUi.tool || run ? "" : `<p class="cl-note">Follow a spot in the video, then pin a title, sticker or another clip to it. A mask tracks from the Masks section.</p>`}`;
|
|
9753
|
+
if (tr) {
|
|
9754
|
+
const S = tr.samples;
|
|
9755
|
+
html += `<div class="cl-row"><button type="button" class="btn tiny" data-tact="back" ${run ? "disabled" : ""} title="Track from the playhead back to the clip's start">◀ Track back</button>
|
|
9756
|
+
<button type="button" class="btn tiny" data-tact="fwd" ${run ? "disabled" : ""} title="Track from the playhead to the clip's end">Track on ▶</button>
|
|
9757
|
+
<button type="button" class="btn tiny" data-tact="del" title="Delete this track (what follows it keeps its keys)">Delete</button></div>
|
|
9758
|
+
<div class="insp-row"><label title="A clip to pin to this track">Attach</label><select data-tattach title="A clip to pin to this track"><option value="">Pick a clip…</option>${others.map((x) => `<option value="${x.id}">${escapeHtml(x.track + " · " + (x.kind === "text" ? (x.props.text || "").split("\n")[0] : x.name || x.kind))}</option>`).join("")}</select></div>
|
|
9759
|
+
<div class="cl-row"><button type="button" class="btn tiny" data-tact="attach" ${others.length ? "" : "disabled"} title="Key the clip's position (and scale / rotation when those are on) so it rides on the track, keeping where it is at the playhead">Attach to the track</button></div>
|
|
9760
|
+
<p class="cl-note">${S.length} samples, ${S[0][0].toFixed(2)}–${S[S.length - 1][0].toFixed(2)} s of the clip. Attaching writes keyframes — edit them like any others.</p>`;
|
|
9761
|
+
}
|
|
9762
|
+
return html + `</div>`;
|
|
9763
|
+
}
|
|
9764
|
+
function bindTrackControls(c) {
|
|
9765
|
+
const root = els.inspector.querySelector("[data-track-section]");
|
|
9766
|
+
if (!root) return;
|
|
9767
|
+
const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
|
|
9768
|
+
const size = root.querySelector("[data-tsize]");
|
|
9769
|
+
if (size) size.addEventListener("change", () => { trackUi.size = clamp((+size.value || 8) / 100, 0.02, 0.4); size.blur(); });
|
|
9770
|
+
for (const b of root.querySelectorAll("[data-tsel]")) b.addEventListener("click", () => {
|
|
9771
|
+
const j = +b.dataset.tsel;
|
|
9772
|
+
trackUi.sel = trackUi.sel === j ? -1 : j; trackUi.tool = null;
|
|
9773
|
+
renderInspector(); drawFrame();
|
|
9774
|
+
});
|
|
9775
|
+
for (const b of root.querySelectorAll("[data-tact]")) b.addEventListener("click", () => {
|
|
9776
|
+
const a = b.dataset.tact, tr = clipTracks(c)[trackUi.sel];
|
|
9777
|
+
if (a === "scale" || a === "rotation") { trackUi[a] = !trackUi[a]; renderInspector(); return; }
|
|
9778
|
+
if (!guard()) return;
|
|
9779
|
+
if (a === "point") {
|
|
9780
|
+
trackUi.tool = trackUi.tool === "point" ? null : "point";
|
|
9781
|
+
if (trackUi.tool && !activeAt(c, state.time)) toast("Move the playhead over the clip, then click the spot to follow");
|
|
9782
|
+
renderInspector(); return;
|
|
9783
|
+
}
|
|
9784
|
+
if (!tr) return;
|
|
9785
|
+
if (a === "del") { pushUndo(); writeTracks(c, clipTracks(c).filter((x) => x !== tr)); trackUi.sel = -1; scheduleSave(); renderInspector(); drawFrame(); return; }
|
|
9786
|
+
if (a === "back" || a === "fwd") {
|
|
9787
|
+
if (!activeAt(c, state.time)) { toast("Move the playhead over the clip first"); return; }
|
|
9788
|
+
trackPointFromPlayhead(c, tr.name, trackBoxAt(c, tr), a === "fwd" ? 1 : -1);
|
|
9789
|
+
return;
|
|
9790
|
+
}
|
|
9791
|
+
if (a === "attach") {
|
|
9792
|
+
const other = getClip(root.querySelector("[data-tattach]").value);
|
|
9793
|
+
if (!other) { toast("Pick a clip to attach first"); return; }
|
|
9794
|
+
if (isGroupLocked(other)) { toastLocked(); return; }
|
|
9795
|
+
pushUndo();
|
|
9796
|
+
const n = attachToTrack(c, tr, other, { scale: trackUi.scale, rotation: trackUi.rotation });
|
|
9797
|
+
scheduleSave(); renderInspector(); drawFrame();
|
|
9798
|
+
toast(n ? `${other.name || other.kind} follows ${tr.name} (${n} keys)` : "That clip and the track don't overlap in time");
|
|
9799
|
+
}
|
|
9800
|
+
});
|
|
9801
|
+
}
|
|
9802
|
+
/** Monitor: the selected track's path, and its region at the playhead. */
|
|
9803
|
+
function drawTrackOverlay(c, W) {
|
|
9804
|
+
if (!c || trackUi.clip !== c.id || !els.inspector.querySelector("[data-track-section]")) return;
|
|
9805
|
+
const tr = clipTracks(c)[trackUi.sel], B = maskBox(c);
|
|
9806
|
+
if (!tr || !B || !activeAt(c, state.time)) return;
|
|
9807
|
+
const lw = Math.max(1.5, W / 900), S = tr.samples, step = Math.max(1, Math.floor(S.length / 400));
|
|
9808
|
+
ctx2d.save(); ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
9809
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(255, 196, 64, .8)";
|
|
9810
|
+
ctx2d.beginPath();
|
|
9811
|
+
for (let i = 0; i < S.length; i += step) { const p = B.toCanvas(S[i][1] * B.bw, S[i][2] * B.bh); if (i) ctx2d.lineTo(p.x, p.y); else ctx2d.moveTo(p.x, p.y); }
|
|
9812
|
+
ctx2d.stroke();
|
|
9813
|
+
const s = Tracker.trackAt(tr, state.time - c.start), p = B.toCanvas(s.x * B.bw, s.y * B.bh);
|
|
9814
|
+
const q = B.toCanvas((s.x + tr.w * s.s / 2) * B.bw, s.y * B.bh), r = Math.max(6, Math.hypot(q.x - p.x, q.y - p.y));
|
|
9815
|
+
ctx2d.strokeStyle = s.inside ? "#ffc440" : "rgba(255, 196, 64, .45)";
|
|
9816
|
+
ctx2d.strokeRect(p.x - r, p.y - r, r * 2, r * 2);
|
|
9817
|
+
ctx2d.beginPath(); ctx2d.moveTo(p.x - r * 0.5, p.y); ctx2d.lineTo(p.x + r * 0.5, p.y); ctx2d.moveTo(p.x, p.y - r * 0.5); ctx2d.lineTo(p.x, p.y + r * 0.5); ctx2d.stroke();
|
|
9818
|
+
ctx2d.restore();
|
|
9819
|
+
}
|
|
9820
|
+
els.preview.addEventListener("pointerdown", (e) => {
|
|
9821
|
+
if (e.button !== 0 || trackUi.tool !== "point" || isSourceMode()) return;
|
|
9822
|
+
const c = getClip(state.selId);
|
|
9823
|
+
if (!c || c.id !== trackUi.clip || !activeAt(c, state.time)) return;
|
|
9824
|
+
const B = maskBox(c);
|
|
9825
|
+
if (!B) return;
|
|
9826
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
9827
|
+
const bp = B.toBox(canvasPt(e)), u = bp.x / B.bw, v = bp.y / B.bh;
|
|
9828
|
+
if (u < 0 || u > 1 || v < 0 || v > 1) { toast("Click inside the clip's picture"); return; }
|
|
9829
|
+
const used = new Set(clipTracks(c).map((x) => x.name));
|
|
9830
|
+
let n = 1;
|
|
9831
|
+
while (used.has(`Track ${n}`)) n++;
|
|
9832
|
+
trackUi.tool = null;
|
|
9833
|
+
trackPointFromPlayhead(c, `Track ${n}`, { x: u, y: v, w: trackUi.size, h: trackUi.size * B.bw / B.bh }, 1);
|
|
9834
|
+
}, true);
|
|
9835
|
+
window.addEventListener("keydown", (e) => {
|
|
9836
|
+
if (e.key !== "Escape" || (!trackUi.run && trackUi.tool !== "point")) return;
|
|
9837
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
9838
|
+
if (trackUi.run) trackUi.run.cancel = true;
|
|
9839
|
+
else { trackUi.tool = null; renderInspector(); }
|
|
9840
|
+
}, true);
|
|
9841
|
+
function maskSectionHtml(c) {
|
|
9842
|
+
const ui = maskUiFor(c), list = maskList(c), i = ui.sel, m = i >= 0 ? maskNowAt(c, i) : null, raw = list[i];
|
|
9843
|
+
const tool = (k, label, title) => `<button type="button" class="btn tiny${ui.tool === k ? " toggle on" : ""}" data-mtool="${k}" title="${title}" ${list.length >= Mask.MAX_MASKS ? "disabled" : ""}>${label}</button>`;
|
|
9844
|
+
const chips = list.map((mk, j) => `<button type="button" class="btn tiny cl-chip${j === i ? " toggle on" : ""}${mk.on === false ? " off" : ""}" data-msel="${j}"
|
|
9845
|
+
title="${escapeHtml(mk.shape + (mk.mode !== "add" ? " · " + mk.mode : "") + (mk.invert ? " · inverted" : ""))} — click to edit on the monitor">${escapeHtml(mk.name || `Mask ${j + 1}`)}</button>`).join("");
|
|
9846
|
+
let html = `<div class="insp-section" data-mask-section><h3>Masks</h3>
|
|
9847
|
+
<div class="cl-row">${tool("rect", "+ Rect", "Add a rectangle mask")}${tool("ellipse", "+ Ellipse", "Add an ellipse mask")}${tool("pen", "Pen", "Click points on the monitor (drag to pull curve handles); click the first point or press Enter to close")}${tool("draw", "Draw", "Drag a free-hand outline on the monitor — it becomes an editable bezier")}
|
|
9848
|
+
${list.length ? `<button type="button" class="btn tiny${runtime.maskView ? " toggle on" : ""}" data-mact="view" title="Show the matte in the monitor (white = visible)">Matte</button>` : ""}</div>
|
|
9849
|
+
${ui.tool === "pen" ? `<p class="cl-note">Pen: click to add points, drag to curve. Click the first point or press Enter to close. Ctrl+Z / Backspace removes the last point, Ctrl+Shift+Z puts it back, Esc cancels.</p>` : ""}
|
|
9850
|
+
${ui.tool === "draw" ? `<p class="cl-note">Draw: drag around what to keep. Esc cancels.</p>` : ""}
|
|
9851
|
+
${list.length ? `<div class="cl-row">${chips}</div>` : ui.tool ? "" : `<p class="cl-note">No masks: the whole clip shows. Masks keep (add), cut (subtract) or intersect parts of the picture and move with the clip.</p>`}`;
|
|
9852
|
+
if (m) {
|
|
9853
|
+
const keyed = raw.keys ? raw.keys.length : 0, onKey = maskKeyIndex(raw, maskLocalT(c)) >= 0;
|
|
9854
|
+
const mrow = (p) => {
|
|
9855
|
+
const [lab, min, max, step] = MASK_ROWS_UI[p];
|
|
9856
|
+
return `<div class="insp-row cp-row"><label class="cp-label">${lab}</label>
|
|
9857
|
+
<input type="range" data-mk="${p}" min="${min}" max="${max}" step="${step}" value="${m[p]}">
|
|
9858
|
+
<input type="number" class="cp-num" data-mknum="${p}" step="${step}" value="${fmtG(m[p], step)}"></div>`;
|
|
9859
|
+
};
|
|
9860
|
+
html += `<div class="cl-row"><input type="text" class="cl-name" data-mname value="${escapeHtml(raw.name || "")}" placeholder="Mask ${i + 1}" maxlength="40" title="Mask name">
|
|
9861
|
+
<button type="button" class="btn tiny${raw.on === false ? "" : " toggle on"}" data-mact="on" title="Mask on / off">${raw.on === false ? "Off" : "On"}</button>
|
|
9862
|
+
<button type="button" class="btn tiny" data-mact="up" ${i === 0 ? "disabled" : ""} title="Move down the stack (combined earlier)">↑</button>
|
|
9863
|
+
<button type="button" class="btn tiny" data-mact="down" ${i >= list.length - 1 ? "disabled" : ""} title="Move up the stack (combined later)">↓</button>
|
|
9864
|
+
<button type="button" class="btn tiny" data-mact="dup" ${list.length >= Mask.MAX_MASKS ? "disabled" : ""} title="Duplicate">Copy</button>
|
|
9865
|
+
<button type="button" class="btn tiny" data-mact="del" title="Delete this mask">Delete</button></div>
|
|
9866
|
+
<div class="cl-row"><select data-mmode title="How this mask combines with the masks before it">${Mask.MODES.map((md) => `<option value="${md}" ${raw.mode === md ? "selected" : ""}>${MASK_MODE_LABEL[md]}</option>`).join("")}</select>
|
|
9867
|
+
<button type="button" class="btn tiny${raw.invert ? " toggle on" : ""}" data-mact="invert" title="Use everything outside the shape">Invert</button>
|
|
9868
|
+
<button type="button" class="btn tiny${onKey ? " toggle on" : ""}" data-mact="key" title="${onKey ? "Remove the key at the playhead" : "Key the mask at the playhead — with keys, every change keys"}">◆ Key</button>
|
|
9869
|
+
<span class="cl-note">${keyed ? `${keyed} key${keyed > 1 ? "s" : ""}` : ""}</span>
|
|
9870
|
+
${keyed ? `<button type="button" class="btn tiny" data-mact="clearkeys" title="Remove every key (keeps the mask as it is at the playhead)">Clear keys</button>` : ""}</div>
|
|
9871
|
+
${c.kind === "video" ? `<div class="cl-row"><span class="cl-note">Track</span>
|
|
9872
|
+
<button type="button" class="btn tiny" data-mtrack="-1" ${trackUi.run ? "disabled" : ""} title="Follow what the mask covers from the playhead back to the clip's start — keys its position">◀ Back</button>
|
|
9873
|
+
<button type="button" class="btn tiny" data-mtrack="1" ${trackUi.run ? "disabled" : ""} title="Follow what the mask covers from the playhead to the clip's end — keys its position">On ▶</button>
|
|
9874
|
+
<button type="button" class="btn tiny${trackUi.scale ? " toggle on" : ""}" data-mtrackopt="scale" title="Also key its scale">Scale</button>
|
|
9875
|
+
<button type="button" class="btn tiny${trackUi.rotation ? " toggle on" : ""}" data-mtrackopt="rotation" title="Also key its rotation">Rotate</button></div>
|
|
9876
|
+
${trackUi.run && trackUi.run.clip === c.id ? `<p class="cl-note" data-track-progress>${escapeHtml(trackUi.run.label)}: ${Math.round(trackUi.run.pct * 100)}% — Esc stops</p>` : ""}` : ""}
|
|
9877
|
+
${mrow("opacity")}${mrow("feather")}${mrow("expand")}${mrow("x")}${mrow("y")}${m.shape !== "bezier" ? mrow("w") + mrow("h") : ""}${mrow("scale")}${mrow("rotation")}
|
|
9878
|
+
<p class="cl-note">On the monitor: drag inside to move, the knob rotates (Shift: 15° steps)${m.shape === "bezier"
|
|
9879
|
+
? "; drag a point to move it, its handles to curve it (Alt: break the pair), Alt-drag a point to pull new handles, Ctrl-click the outline to add a point, double-click a point to remove it"
|
|
9880
|
+
: ", the edge handles resize"}. Esc deselects. Every change undoes with Ctrl+Z (redo: Ctrl+Shift+Z or Ctrl+Y).</p>`;
|
|
9881
|
+
}
|
|
9882
|
+
return html + `</div>`;
|
|
9883
|
+
}
|
|
9884
|
+
function bindMaskControls(c) {
|
|
9885
|
+
const root = els.inspector.querySelector("[data-mask-section]");
|
|
9886
|
+
if (!root) return;
|
|
9887
|
+
const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
|
|
9888
|
+
const rerender = () => { state.dirtyTimeline = true; renderInspector(); };
|
|
9889
|
+
for (const b of root.querySelectorAll("[data-mtool]")) b.addEventListener("click", () => {
|
|
9890
|
+
const k = b.dataset.mtool;
|
|
9891
|
+
if (!guard()) return;
|
|
9892
|
+
if (k === "rect" || k === "ellipse") {
|
|
9893
|
+
pushUndo();
|
|
9894
|
+
const list = maskList(c).slice();
|
|
9895
|
+
list.push({ shape: k, x: 0.5, y: 0.5, w: k === "rect" ? 0.5 : 0.45, h: k === "rect" ? 0.5 : 0.6 });
|
|
9896
|
+
writeMasks(c, list); maskUi.sel = list.length - 1; maskUi.tool = null; maskUi.pt = -1;
|
|
9897
|
+
scheduleSave(); rerender();
|
|
9898
|
+
if (!activeAt(c, state.time)) toast("Move the playhead over the clip to see the mask on the monitor");
|
|
9899
|
+
return;
|
|
9900
|
+
}
|
|
9901
|
+
maskUi.tool = maskUi.tool === k ? null : k; maskUi.pen = null; maskUi.stroke = null;
|
|
9902
|
+
if (maskUi.tool) { maskUi.sel = -1; if (state.playing) pause(); if (!activeAt(c, state.time)) toast("Move the playhead over the clip, then draw on the monitor"); }
|
|
9903
|
+
rerender();
|
|
9904
|
+
});
|
|
9905
|
+
for (const b of root.querySelectorAll("[data-mtrackopt]")) b.addEventListener("click", () => { trackUi[b.dataset.mtrackopt] = !trackUi[b.dataset.mtrackopt]; rerender(); });
|
|
9906
|
+
for (const b of root.querySelectorAll("[data-mtrack]")) b.addEventListener("click", () => {
|
|
9907
|
+
if (!guard() || maskUi.sel < 0) return;
|
|
9908
|
+
trackMaskFromPlayhead(c, maskUi.sel, +b.dataset.mtrack);
|
|
9909
|
+
});
|
|
9910
|
+
for (const b of root.querySelectorAll("[data-msel]")) b.addEventListener("click", () => {
|
|
9911
|
+
const j = +b.dataset.msel;
|
|
9912
|
+
maskUi.sel = maskUi.sel === j ? -1 : j; maskUi.pt = -1; maskUi.tool = null;
|
|
9913
|
+
rerender();
|
|
9914
|
+
});
|
|
9915
|
+
for (const b of root.querySelectorAll("[data-mact]")) b.addEventListener("click", () => {
|
|
9916
|
+
const a = b.dataset.mact, i = maskUi.sel;
|
|
9917
|
+
if (a === "view") { runtime.maskView = runtime.maskView ? null : { id: c.id }; rerender(); return; }
|
|
9918
|
+
const list = maskList(c).slice(), m = list[i];
|
|
9919
|
+
if (!m || !guard()) return;
|
|
9920
|
+
pushUndo();
|
|
9921
|
+
if (a === "on") list[i] = Mask.mergeMask(m, { on: m.on === false ? null : false });
|
|
9922
|
+
else if (a === "invert") list[i] = Mask.mergeMask(m, { invert: m.invert ? null : true });
|
|
9923
|
+
else if (a === "del") { list.splice(i, 1); maskUi.sel = Math.min(i, list.length - 1); maskUi.pt = -1; }
|
|
9924
|
+
else if (a === "dup") { list.splice(i + 1, 0, JSON.parse(JSON.stringify({ ...m, name: m.name ? `${m.name} copy`.slice(0, 40) : undefined }))); maskUi.sel = i + 1; }
|
|
9925
|
+
else if (a === "up" || a === "down") { const j = a === "up" ? i - 1 : i + 1; [list[i], list[j]] = [list[j], list[i]]; maskUi.sel = j; }
|
|
9926
|
+
else if (a === "clearkeys") { const now = Mask.maskAt(m, maskLocalT(c)); list[i] = Mask.mergeMask(m, { ...maskSnapshotKey(now, 0), t: null, keys: null }); }
|
|
9927
|
+
else if (a === "key") {
|
|
9928
|
+
const t = maskLocalT(c), j = maskKeyIndex(m, t), now = Mask.maskAt(m, t);
|
|
9929
|
+
const keys = (m.keys || []).map((k) => ({ ...k }));
|
|
9930
|
+
if (j >= 0) keys.splice(j, 1); else keys.push(maskSnapshotKey(now, t));
|
|
9931
|
+
const set = { keys: keys.length ? keys : null };
|
|
9932
|
+
if (!keys.length) Object.assign(set, maskSnapshotKey(now, 0), { t: null });
|
|
9933
|
+
list[i] = Mask.mergeMask(m, set);
|
|
9934
|
+
}
|
|
9935
|
+
writeMasks(c, list); scheduleSave(); rerender();
|
|
9936
|
+
});
|
|
9937
|
+
const mode = root.querySelector("[data-mmode]");
|
|
9938
|
+
if (mode) mode.addEventListener("change", () => {
|
|
9939
|
+
if (!guard()) return;
|
|
9940
|
+
pushUndo(); writeMaskSet(c, maskUi.sel, { mode: mode.value }); scheduleSave(); rerender();
|
|
9941
|
+
document.activeElement?.blur?.(); // hand Ctrl+Z back to the editor
|
|
9942
|
+
});
|
|
9943
|
+
const name = root.querySelector("[data-mname]");
|
|
9944
|
+
if (name) name.addEventListener("change", () => {
|
|
9945
|
+
if (!guard()) return;
|
|
9946
|
+
pushUndo(); writeMaskSet(c, maskUi.sel, { name: name.value.trim() || null }); scheduleSave(); rerender();
|
|
9947
|
+
});
|
|
9948
|
+
for (const r of root.querySelectorAll("[data-mk]")) {
|
|
9949
|
+
const p = r.dataset.mk;
|
|
9950
|
+
r.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
|
|
9951
|
+
r.addEventListener("input", () => {
|
|
9952
|
+
if (isGroupLocked(c)) return;
|
|
9953
|
+
writeMaskSet(c, maskUi.sel, { [p]: +r.value }); scheduleSave();
|
|
9954
|
+
const n = root.querySelector(`[data-mknum="${p}"]`); if (n) n.value = fmtG(+r.value, MASK_ROWS_UI[p][3]);
|
|
9955
|
+
});
|
|
9956
|
+
}
|
|
9957
|
+
for (const n of root.querySelectorAll("[data-mknum]")) {
|
|
9958
|
+
const p = n.dataset.mknum;
|
|
9959
|
+
n.addEventListener("change", () => {
|
|
9960
|
+
const x = parseFloat(n.value);
|
|
9961
|
+
if (!Number.isFinite(x) || !guard()) return;
|
|
9962
|
+
pushUndo(); writeMaskSet(c, maskUi.sel, { [p]: x }); scheduleSave(); rerender();
|
|
9963
|
+
document.activeElement?.blur?.();
|
|
9964
|
+
});
|
|
9965
|
+
}
|
|
9966
|
+
}
|
|
9967
|
+
/** Keyed masks: the sliders and ◆ follow the playhead (called from the inspector sync). */
|
|
9968
|
+
function syncMaskInspector(c) {
|
|
9969
|
+
const root = els.inspector.querySelector("[data-mask-section]");
|
|
9970
|
+
if (!root || maskUi.clip !== c.id || maskUi.sel < 0 || maskUi.drag) return;
|
|
9971
|
+
const raw = maskList(c)[maskUi.sel], m = maskNowAt(c, maskUi.sel);
|
|
9972
|
+
if (!m) return;
|
|
9973
|
+
const active = document.activeElement;
|
|
9974
|
+
for (const p of Object.keys(MASK_ROWS_UI)) {
|
|
9975
|
+
const r = root.querySelector(`[data-mk="${p}"]`), n = root.querySelector(`[data-mknum="${p}"]`);
|
|
9976
|
+
if (r && r !== active) r.value = m[p];
|
|
9977
|
+
if (n && n !== active) n.value = fmtG(m[p], MASK_ROWS_UI[p][3]);
|
|
9978
|
+
}
|
|
9979
|
+
const k = root.querySelector("[data-mact=key]"), on = maskKeyIndex(raw, maskLocalT(c)) >= 0;
|
|
9980
|
+
if (k) { k.classList.toggle("on", on); k.classList.toggle("toggle", on); }
|
|
9981
|
+
}
|
|
9982
|
+
|
|
9983
|
+
/* Monitor geometry: the picture box (bw × bh px, origin top-left) ↔ canvas px. */
|
|
9984
|
+
function maskBox(c) {
|
|
9985
|
+
const g = runtime.maskGeom && runtime.maskGeom.get(c.id);
|
|
9986
|
+
if (!g) return null;
|
|
9987
|
+
const M = g.m.translate(g.x0, g.y0), inv = M.inverse();
|
|
9988
|
+
return {
|
|
9989
|
+
bw: g.w, bh: g.h,
|
|
9990
|
+
toCanvas: (x, y) => { const p = M.transformPoint(new DOMPoint(x, y)); return { x: p.x, y: p.y }; },
|
|
9991
|
+
toBox: (pt) => { const p = inv.transformPoint(new DOMPoint(pt.x, pt.y)); return { x: p.x, y: p.y }; },
|
|
9992
|
+
};
|
|
9993
|
+
}
|
|
9994
|
+
/** Box px ↔ a mask's own frame (fractions of the picture, before its scale / rotation). */
|
|
9995
|
+
function maskBoxToLocal(m, B, bx, by, vector = false) {
|
|
9996
|
+
const th = (m.rotation || 0) * Math.PI / 180, s = m.scale || 1, cs = Math.cos(th), sn = Math.sin(th);
|
|
9997
|
+
const dx = bx - (vector ? 0 : m.x * B.bw), dy = by - (vector ? 0 : m.y * B.bh);
|
|
9998
|
+
return { x: (dx * cs + dy * sn) / s / B.bw, y: (-dx * sn + dy * cs) / s / B.bh };
|
|
9999
|
+
}
|
|
10000
|
+
function maskLocalToBox(m, B, lx, ly) {
|
|
10001
|
+
const th = (m.rotation || 0) * Math.PI / 180, s = m.scale || 1, cs = Math.cos(th), sn = Math.sin(th);
|
|
10002
|
+
const px = lx * B.bw * s, py = ly * B.bh * s;
|
|
10003
|
+
return { x: m.x * B.bw + px * cs - py * sn, y: m.y * B.bh + px * sn + py * cs };
|
|
10004
|
+
}
|
|
10005
|
+
function maskEditActive(c) {
|
|
10006
|
+
return !!c && maskUi.clip === c.id && state.selId === c.id && (maskUi.sel >= 0 || !!maskUi.tool) &&
|
|
10007
|
+
activeAt(c, state.time) && !!maskBox(c) && !state.playing;
|
|
10008
|
+
}
|
|
10009
|
+
/** Handles for the selected mask, in canvas px. */
|
|
10010
|
+
function clipMaskHandles(c) {
|
|
10011
|
+
const m = maskNowAt(c, maskUi.sel), B = m && maskBox(c);
|
|
10012
|
+
if (!B) return null;
|
|
10013
|
+
const at = (lx, ly) => { const q = maskLocalToBox(m, B, lx, ly); return B.toCanvas(q.x, q.y); };
|
|
10014
|
+
const A = Mask.anchors(m, B.bw, B.bh).map((a) => ({ a: B.toCanvas(a.x, a.y), i: B.toCanvas(a.ix, a.iy), o: B.toCanvas(a.ox, a.oy) }));
|
|
10015
|
+
const top = m.shape === "bezier" ? Math.min(...m.points.map((p) => p[1])) : -m.h / 2;
|
|
10016
|
+
const base = at(0, top), up = at(0, top - 0.01), d = Math.hypot(up.x - base.x, up.y - base.y) || 1;
|
|
10017
|
+
const W = els.preview.width, H = els.preview.height, gap = Math.max(24, W / 34), inset = Math.max(8, W / 110);
|
|
10018
|
+
const knob = { x: clamp(base.x + (up.x - base.x) / d * gap, inset, W - inset), y: clamp(base.y + (up.y - base.y) / d * gap, inset, H - inset) };
|
|
10019
|
+
const edges = m.shape === "bezier" ? [] : [["w", m.w / 2, 0], ["w", -m.w / 2, 0], ["h", 0, m.h / 2], ["h", 0, -m.h / 2]].map(([k, x, y]) => ({ k, ...at(x, y) }));
|
|
10020
|
+
return { m, B, A, centre: at(0, 0), knob, knobBase: base, edges };
|
|
10021
|
+
}
|
|
10022
|
+
function strokeOutline(ctx, pts, closed) {
|
|
10023
|
+
ctx.beginPath();
|
|
10024
|
+
pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])));
|
|
10025
|
+
if (closed) ctx.closePath();
|
|
10026
|
+
ctx.stroke();
|
|
10027
|
+
}
|
|
10028
|
+
/** Drawn instead of the selection box while a mask is picked or a mask tool is on. */
|
|
10029
|
+
function drawClipMaskOverlay(c, W) {
|
|
10030
|
+
const B = maskBox(c);
|
|
10031
|
+
if (!B) return false;
|
|
10032
|
+
const lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
|
|
10033
|
+
ctx2d.save();
|
|
10034
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
10035
|
+
ctx2d.lineJoin = "round";
|
|
10036
|
+
const outline = (m) => Mask.flatten(Mask.anchors(m, B.bw, B.bh), 24).map(([x, y]) => { const q = B.toCanvas(x, y); return [q.x, q.y]; });
|
|
10037
|
+
maskList(c).forEach((raw, j) => { // the other masks, faint
|
|
10038
|
+
if (j === maskUi.sel) return;
|
|
10039
|
+
ctx2d.setLineDash([lw * 3, lw * 3]); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(255,210,90,0.45)";
|
|
10040
|
+
strokeOutline(ctx2d, outline(Mask.maskAt(raw, maskLocalT(c))), true);
|
|
10041
|
+
});
|
|
10042
|
+
ctx2d.setLineDash([]);
|
|
10043
|
+
const dot = (p, r, fill, square) => {
|
|
10044
|
+
ctx2d.beginPath();
|
|
10045
|
+
if (square) ctx2d.rect(p.x - r, p.y - r, r * 2, r * 2); else ctx2d.arc(p.x, p.y, r, 0, Math.PI * 2);
|
|
10046
|
+
ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.7)"; ctx2d.stroke();
|
|
10047
|
+
};
|
|
10048
|
+
const h = maskUi.sel >= 0 ? clipMaskHandles(c) : null;
|
|
10049
|
+
if (h) {
|
|
10050
|
+
const pts = outline(h.m);
|
|
10051
|
+
ctx2d.lineWidth = lw * 2.2; ctx2d.strokeStyle = "rgba(0,0,0,0.55)"; strokeOutline(ctx2d, pts, true);
|
|
10052
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = h.m.invert ? "#ff9f5a" : "#ffd25a"; strokeOutline(ctx2d, pts, true);
|
|
10053
|
+
ctx2d.beginPath(); ctx2d.moveTo(h.knobBase.x, h.knobBase.y); ctx2d.lineTo(h.knob.x, h.knob.y); ctx2d.stroke();
|
|
10054
|
+
for (const e of h.edges) dot(e, hs * 0.8, "#fff");
|
|
10055
|
+
if (h.m.shape === "bezier") {
|
|
10056
|
+
const sp = h.A[maskUi.pt];
|
|
10057
|
+
if (sp) {
|
|
10058
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#9fd0ff";
|
|
10059
|
+
for (const q of [sp.i, sp.o]) { ctx2d.beginPath(); ctx2d.moveTo(sp.a.x, sp.a.y); ctx2d.lineTo(q.x, q.y); ctx2d.stroke(); }
|
|
10060
|
+
dot(sp.i, hs * 0.6, "#9fd0ff"); dot(sp.o, hs * 0.6, "#9fd0ff");
|
|
10061
|
+
}
|
|
10062
|
+
h.A.forEach((p, j) => dot(p.a, hs * 0.62, j === maskUi.pt ? "#ffd25a" : "#fff", true));
|
|
10063
|
+
}
|
|
10064
|
+
dot(h.centre, hs * 0.5, "#ffd25a");
|
|
10065
|
+
dot(h.knob, hs * 0.9, "#ffce5c");
|
|
10066
|
+
}
|
|
10067
|
+
if (maskUi.tool === "pen" && maskUi.pen && maskUi.pen.pts.length) {
|
|
10068
|
+
const P = maskUi.pen.pts, A = P.map((p) => ({ x: p[0], y: p[1], ix: p[0] + p[2], iy: p[1] + p[3], ox: p[0] + p[4], oy: p[1] + p[5] }));
|
|
10069
|
+
const open = Mask.flatten(A, 24).slice(0, (A.length - 1) * 24 + 1).map(([x, y]) => { const q = B.toCanvas(x, y); return [q.x, q.y]; });
|
|
10070
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#ffd25a";
|
|
10071
|
+
if (A.length > 1) strokeOutline(ctx2d, open, false);
|
|
10072
|
+
A.forEach((a, j) => dot(B.toCanvas(a.x, a.y), hs * (j === 0 && A.length >= 3 ? 0.9 : 0.62), j === 0 ? "#ffd25a" : "#fff", true));
|
|
10073
|
+
}
|
|
10074
|
+
if (maskUi.tool === "draw" && maskUi.stroke && maskUi.stroke.length > 1) {
|
|
10075
|
+
ctx2d.lineWidth = lw * 1.5; ctx2d.strokeStyle = "#ffd25a";
|
|
10076
|
+
strokeOutline(ctx2d, maskUi.stroke.map(([u, v]) => { const q = B.toCanvas(u * B.bw, v * B.bh); return [q.x, q.y]; }), false);
|
|
10077
|
+
}
|
|
10078
|
+
ctx2d.restore();
|
|
10079
|
+
return true;
|
|
10080
|
+
}
|
|
10081
|
+
function addMaskFrom(c, mask) {
|
|
10082
|
+
const list = maskList(c).slice();
|
|
10083
|
+
if (list.length >= Mask.MAX_MASKS) { toast(`A clip has at most ${Mask.MAX_MASKS} masks`); return; }
|
|
10084
|
+
const m = Mask.normalizeMask(mask);
|
|
10085
|
+
if (!m) return;
|
|
10086
|
+
pushUndo();
|
|
10087
|
+
list.push(m); writeMasks(c, list);
|
|
10088
|
+
maskUi.sel = list.length - 1; maskUi.pt = -1; maskUi.tool = null; maskUi.pen = null; maskUi.stroke = null;
|
|
10089
|
+
scheduleSave(); renderInspector();
|
|
10090
|
+
}
|
|
10091
|
+
function finishPen(c) {
|
|
10092
|
+
const B = maskBox(c), P = maskUi.pen && maskUi.pen.pts;
|
|
10093
|
+
if (!B || !P || P.length < 3) { toast("A pen mask needs at least 3 points"); return; }
|
|
10094
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
10095
|
+
for (const [x, y] of P) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
|
10096
|
+
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
10097
|
+
addMaskFrom(c, {
|
|
10098
|
+
shape: "bezier", x: cx / B.bw, y: cy / B.bh,
|
|
10099
|
+
points: P.slice(0, Mask.MAX_POINTS).map(([x, y, ix, iy, ox, oy]) => [(x - cx) / B.bw, (y - cy) / B.bh, ix / B.bw, iy / B.bh, ox / B.bw, oy / B.bh]),
|
|
10100
|
+
});
|
|
10101
|
+
}
|
|
10102
|
+
/** The segment and parameter of the selected bezier's outline nearest `bp` (box px). */
|
|
10103
|
+
function nearestMaskSegment(m, B, bp) {
|
|
10104
|
+
const A = Mask.anchors(m, B.bw, B.bh);
|
|
10105
|
+
let best = null;
|
|
10106
|
+
for (let i = 0; i < A.length; i++) {
|
|
10107
|
+
const a = A[i], b = A[(i + 1) % A.length];
|
|
10108
|
+
for (let k = 1; k < 24; k++) {
|
|
10109
|
+
const t = k / 24, u = 1 - t;
|
|
10110
|
+
const x = u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x;
|
|
10111
|
+
const y = u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y;
|
|
10112
|
+
const d = Math.hypot(x - bp.x, y - bp.y);
|
|
10113
|
+
if (!best || d < best.d) best = { d, i, t };
|
|
10114
|
+
}
|
|
10115
|
+
}
|
|
10116
|
+
return best;
|
|
10117
|
+
}
|
|
10118
|
+
/** Add or remove bezier points on mask i: the base outline and every keyed outline change together. */
|
|
10119
|
+
function writeMaskTopology(c, i, edit) {
|
|
10120
|
+
const list = maskList(c).slice();
|
|
10121
|
+
if (!list[i]) return;
|
|
10122
|
+
list[i] = Mask.mergeMask(list[i], Mask.editTopology(list[i], edit)) || list[i];
|
|
10123
|
+
writeMasks(c, list);
|
|
10124
|
+
}
|
|
10125
|
+
els.preview.addEventListener("pointerdown", (e) => {
|
|
10126
|
+
if (e.button !== 0 || isSourceMode()) return;
|
|
10127
|
+
const c = getClip(state.selId);
|
|
10128
|
+
if (!maskEditActive(c)) return;
|
|
10129
|
+
const B = maskBox(c), pt = canvasPt(e), bp = B.toBox(pt), W = els.preview.width, grab = Math.max(8, W / 110);
|
|
10130
|
+
const near = (p) => Math.hypot(p.x - pt.x, p.y - pt.y) <= grab;
|
|
10131
|
+
if (maskUi.tool) {
|
|
10132
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10133
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
10134
|
+
if (maskUi.tool === "draw") maskUi.stroke = [[bp.x / B.bw, bp.y / B.bh]];
|
|
10135
|
+
else if (maskUi.tool === "pen") {
|
|
10136
|
+
const P = (maskUi.pen ||= { pts: [] }).pts;
|
|
10137
|
+
if (P.length >= 3 && near(B.toCanvas(P[0][0], P[0][1]))) { finishPen(c); return; }
|
|
10138
|
+
if (P.length >= Mask.MAX_POINTS) { toast(`A mask has at most ${Mask.MAX_POINTS} points`); return; }
|
|
10139
|
+
P.push([bp.x, bp.y, 0, 0, 0, 0]);
|
|
10140
|
+
maskUi.pen.redo = [];
|
|
10141
|
+
maskUi.pen.dragging = true;
|
|
10142
|
+
} else return;
|
|
10143
|
+
maskUi.drag = { tool: true };
|
|
10144
|
+
els.preview.setPointerCapture(e.pointerId);
|
|
10145
|
+
state.dirtyTimeline = true;
|
|
10146
|
+
return;
|
|
10147
|
+
}
|
|
10148
|
+
const h = clipMaskHandles(c);
|
|
10149
|
+
if (!h) return;
|
|
10150
|
+
const { m } = h;
|
|
10151
|
+
let drag = null;
|
|
10152
|
+
const sp = m.shape === "bezier" ? h.A[maskUi.pt] : null;
|
|
10153
|
+
if (near(h.knob)) drag = { mode: "rotate" };
|
|
10154
|
+
else if (sp && near(sp.o)) drag = { mode: "handle", side: "o" };
|
|
10155
|
+
else if (sp && near(sp.i)) drag = { mode: "handle", side: "i" };
|
|
10156
|
+
else if (m.shape === "bezier" && h.A.some((p) => near(p.a))) {
|
|
10157
|
+
maskUi.pt = h.A.findIndex((p) => near(p.a));
|
|
10158
|
+
drag = { mode: e.altKey ? "pull" : "point" };
|
|
10159
|
+
} else if (h.edges.some(near)) drag = { mode: h.edges.find(near).k };
|
|
10160
|
+
else if (m.shape === "bezier" && (e.ctrlKey || e.metaKey)) {
|
|
10161
|
+
if (m.points.length >= Mask.MAX_POINTS) { toast(`A mask has at most ${Mask.MAX_POINTS} points`); e.stopImmediatePropagation(); return; }
|
|
10162
|
+
if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
|
|
10163
|
+
const r = nearestMaskSegment(m, h.B, bp);
|
|
10164
|
+
if (!r) return;
|
|
10165
|
+
pushUndo(); writeMaskTopology(c, maskUi.sel, (pts) => Mask.splitPoints(pts, r.i, r.t)); scheduleSave();
|
|
10166
|
+
maskUi.pt = r.i + 1;
|
|
10167
|
+
drag = { mode: "point", pushed: true };
|
|
10168
|
+
} else if (Mask.matteAt([{ ...m, mode: "add", invert: false, feather: 0, opacity: 1 }], bp.x, bp.y, h.B.bw, h.B.bh, 0) > 0.5) drag = { mode: "move" };
|
|
10169
|
+
if (!drag) return; // outside the mask: the monitor's usual clicks
|
|
10170
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10171
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
10172
|
+
if (!drag.pushed) pushUndo();
|
|
10173
|
+
maskUi.drag = { ...drag, id: c.id, start: bp, m0: JSON.parse(JSON.stringify(maskNowAt(c, maskUi.sel))) };
|
|
10174
|
+
els.preview.setPointerCapture(e.pointerId);
|
|
10175
|
+
state.dirtyTimeline = true;
|
|
10176
|
+
}, true);
|
|
10177
|
+
els.preview.addEventListener("pointermove", (e) => {
|
|
10178
|
+
const d = maskUi.drag;
|
|
10179
|
+
if (!d) return;
|
|
10180
|
+
e.stopImmediatePropagation();
|
|
10181
|
+
const c = getClip(state.selId), B = c && maskBox(c);
|
|
10182
|
+
if (!B) return;
|
|
10183
|
+
const bp = B.toBox(canvasPt(e));
|
|
10184
|
+
if (d.tool) {
|
|
10185
|
+
if (maskUi.tool === "draw" && maskUi.stroke) {
|
|
10186
|
+
const last = maskUi.stroke[maskUi.stroke.length - 1], u = bp.x / B.bw, v = bp.y / B.bh;
|
|
10187
|
+
if (Math.hypot((u - last[0]) * B.bw, (v - last[1]) * B.bh) > 2) maskUi.stroke.push([u, v]);
|
|
10188
|
+
} else if (maskUi.tool === "pen" && maskUi.pen && maskUi.pen.dragging) {
|
|
10189
|
+
const p = maskUi.pen.pts[maskUi.pen.pts.length - 1], ox = bp.x - p[0], oy = bp.y - p[1];
|
|
10190
|
+
if (Math.hypot(ox, oy) > 3) { p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
|
|
10191
|
+
}
|
|
10192
|
+
state.dirtyTimeline = true;
|
|
10193
|
+
return;
|
|
10194
|
+
}
|
|
10195
|
+
const m0 = d.m0;
|
|
10196
|
+
let set;
|
|
10197
|
+
if (d.mode === "move") set = { x: +(m0.x + (bp.x - d.start.x) / B.bw).toFixed(4), y: +(m0.y + (bp.y - d.start.y) / B.bh).toFixed(4) };
|
|
10198
|
+
else if (d.mode === "w" || d.mode === "h") {
|
|
10199
|
+
const l = maskBoxToLocal(m0, B, bp.x, bp.y);
|
|
10200
|
+
set = d.mode === "w" ? { w: +clamp(Math.abs(l.x) * 2, 0.01, 8).toFixed(4) } : { h: +clamp(Math.abs(l.y) * 2, 0.01, 8).toFixed(4) };
|
|
10201
|
+
} else if (d.mode === "rotate") {
|
|
10202
|
+
let r = Math.atan2(bp.x - m0.x * B.bw, -(bp.y - m0.y * B.bh)) * 180 / Math.PI;
|
|
10203
|
+
if (e.shiftKey) r = Math.round(r / 15) * 15;
|
|
10204
|
+
set = { rotation: +r.toFixed(1) };
|
|
10205
|
+
} else if (m0.shape === "bezier") {
|
|
10206
|
+
const pts = m0.points.map((p) => [p[0], p[1], p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0]), p = pts[maskUi.pt];
|
|
10207
|
+
if (!p) return;
|
|
10208
|
+
const l = maskBoxToLocal(m0, B, bp.x, bp.y);
|
|
10209
|
+
if (d.mode === "point") { p[0] = l.x; p[1] = l.y; }
|
|
10210
|
+
else if (d.mode === "pull") { const ox = l.x - p[0], oy = l.y - p[1]; p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
|
|
10211
|
+
else if (d.mode === "handle") {
|
|
10212
|
+
const hx = l.x - p[0], hy = l.y - p[1], o = d.side === "o" ? 4 : 2, q = d.side === "o" ? 2 : 4;
|
|
10213
|
+
p[o] = hx; p[o + 1] = hy;
|
|
10214
|
+
if (!e.altKey) { // keep the pair smooth: the other handle mirrors the direction, keeps its length
|
|
10215
|
+
const len = Math.hypot(p[q], p[q + 1]) || Math.hypot(hx, hy), k = Math.hypot(hx, hy) || 1;
|
|
10216
|
+
p[q] = -hx / k * len; p[q + 1] = -hy / k * len;
|
|
10217
|
+
}
|
|
10218
|
+
}
|
|
10219
|
+
set = { points: pts.map((r) => r.map((v) => +v.toFixed(4))) };
|
|
10220
|
+
}
|
|
10221
|
+
if (set) { writeMaskSet(c, maskUi.sel, set); syncMaskInspector(c); }
|
|
10222
|
+
}, true);
|
|
10223
|
+
const endClipMaskDrag = (e) => {
|
|
10224
|
+
const d = maskUi.drag;
|
|
10225
|
+
if (!d) return;
|
|
10226
|
+
e.stopImmediatePropagation();
|
|
10227
|
+
maskUi.drag = null;
|
|
10228
|
+
const c = getClip(state.selId);
|
|
10229
|
+
if (d.tool) {
|
|
10230
|
+
if (maskUi.tool === "draw" && maskUi.stroke) {
|
|
10231
|
+
const B = c && maskBox(c), st = maskUi.stroke;
|
|
10232
|
+
maskUi.stroke = null;
|
|
10233
|
+
if (c && B && st.length >= 6) addMaskFrom(c, { shape: "freehand", stroke: st, aspect: B.bw / B.bh });
|
|
10234
|
+
else toast("Drag around what the mask should keep");
|
|
10235
|
+
} else if (maskUi.pen) maskUi.pen.dragging = false;
|
|
10236
|
+
state.dirtyTimeline = true;
|
|
10237
|
+
return;
|
|
10238
|
+
}
|
|
10239
|
+
scheduleSave(); renderInspector();
|
|
10240
|
+
};
|
|
10241
|
+
els.preview.addEventListener("pointerup", endClipMaskDrag, true);
|
|
10242
|
+
els.preview.addEventListener("pointercancel", endClipMaskDrag, true);
|
|
10243
|
+
els.preview.addEventListener("dblclick", (e) => {
|
|
10244
|
+
const c = getClip(state.selId);
|
|
10245
|
+
if (!maskEditActive(c) || maskUi.tool) return;
|
|
10246
|
+
const h = clipMaskHandles(c), pt = canvasPt(e), grab = Math.max(8, els.preview.width / 110);
|
|
10247
|
+
if (!h || h.m.shape !== "bezier") return;
|
|
10248
|
+
const i = h.A.findIndex((p) => Math.hypot(p.a.x - pt.x, p.a.y - pt.y) <= grab);
|
|
10249
|
+
if (i < 0) return;
|
|
10250
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10251
|
+
if (h.m.points.length <= 3) { toast("A mask needs at least 3 points"); return; }
|
|
10252
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
10253
|
+
pushUndo();
|
|
10254
|
+
writeMaskTopology(c, maskUi.sel, (pts) => pts.filter((_, j) => j !== i));
|
|
10255
|
+
maskUi.pt = -1; scheduleSave(); renderInspector();
|
|
10256
|
+
}, true);
|
|
10257
|
+
window.addEventListener("keydown", (e) => {
|
|
10258
|
+
if (e.key === "Escape" && colorState.maskDraw) { // the Color tab's Draw
|
|
10259
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10260
|
+
colorState.maskDraw = false; colorState.maskStroke = null; state.dirtyTimeline = true; renderColorPage(true);
|
|
10261
|
+
return;
|
|
10262
|
+
}
|
|
10263
|
+
if (!maskUi.clip || (maskUi.sel < 0 && !maskUi.tool)) return;
|
|
10264
|
+
const tg = e.target;
|
|
10265
|
+
if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
|
|
10266
|
+
const c = getClip(state.selId);
|
|
10267
|
+
if (!c || c.id !== maskUi.clip) return;
|
|
10268
|
+
if (e.key === "Escape") {
|
|
10269
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10270
|
+
if (maskUi.tool) { maskUi.tool = null; maskUi.pen = null; maskUi.stroke = null; }
|
|
10271
|
+
else if (maskUi.pt >= 0) maskUi.pt = -1;
|
|
10272
|
+
else maskUi.sel = -1;
|
|
10273
|
+
state.dirtyTimeline = true; renderInspector();
|
|
10274
|
+
} else if (e.key === "Enter" && maskUi.tool === "pen") {
|
|
10275
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10276
|
+
finishPen(c);
|
|
10277
|
+
} else if (maskUi.tool === "pen" && maskUi.pen && !maskUi.drag) {
|
|
10278
|
+
// while placing points the outline is not in the project yet: undo / redo step through the points
|
|
10279
|
+
const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
|
|
10280
|
+
const back = (mod && k === "z" && !e.shiftKey) || ((e.key === "Backspace" || e.key === "Delete") && !mod);
|
|
10281
|
+
const fwd = mod && ((k === "z" && e.shiftKey) || k === "y");
|
|
10282
|
+
if (!back && !fwd) return;
|
|
10283
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
10284
|
+
const P = maskUi.pen.pts, R = (maskUi.pen.redo ||= []);
|
|
10285
|
+
if (back && P.length) R.push(P.pop());
|
|
10286
|
+
else if (fwd && R.length) P.push(R.pop());
|
|
10287
|
+
state.dirtyTimeline = true;
|
|
10288
|
+
}
|
|
10289
|
+
}, true);
|
|
10290
|
+
|
|
9194
10291
|
/* ── Text direction (LTR / RTL) — per-line when direction is "auto".
|
|
9195
10292
|
Auto-detect uses a DOM probe + getComputedStyle; cache by line text so
|
|
9196
10293
|
preview/export frames don't re-resolve style every tick. ── */
|
|
@@ -10970,6 +12067,7 @@ async function runExportJob(ticket) {
|
|
|
10970
12067
|
await new Promise((r) => setTimeout(r, 100));
|
|
10971
12068
|
if (ticket.revision != null && (project.revision || 0) < ticket.revision) await syncFromServer();
|
|
10972
12069
|
if (ticket.kind === "scopes") return runScopesJob(ticket, fail);
|
|
12070
|
+
if (ticket.kind === "track") return runTrackJob(ticket, fail);
|
|
10973
12071
|
if (!state.ffmpeg) {
|
|
10974
12072
|
const j = await fetch("/api/export/ffmpeg").then((r) => r.json()).catch(() => ({}));
|
|
10975
12073
|
state.ffmpeg = !!j.available;
|
|
@@ -10989,6 +12087,110 @@ async function runExportJob(ticket) {
|
|
|
10989
12087
|
exportRangeForced = null;
|
|
10990
12088
|
}
|
|
10991
12089
|
}
|
|
12090
|
+
/* fablecut_track: follow a region of a clip in this tab, store the track,
|
|
12091
|
+
optionally bake it into a mask or another clip, save, and report. */
|
|
12092
|
+
async function runTrackJob(ticket, fail) {
|
|
12093
|
+
const spec = ticket.track || {}, c = getClip(spec.clip);
|
|
12094
|
+
if (!c) return fail(`no clip ${spec.clip}`);
|
|
12095
|
+
if (c.kind !== "video") return fail(`${c.id} is a ${c.kind} clip — tracking follows a video clip's picture`);
|
|
12096
|
+
if (isGroupLocked(c) && spec.force !== true) return fail(`${c.id} is locked — pass force:true only if the user asked to change it`);
|
|
12097
|
+
const el = getClipEl(c);
|
|
12098
|
+
if (el && !el.videoWidth) await new Promise((r) => { el.addEventListener("loadedmetadata", r, { once: true }); setTimeout(r, 15000); });
|
|
12099
|
+
if (!pictureRect(c, evalProps(c, c.start))) return fail("the clip's video did not load");
|
|
12100
|
+
const list = maskList(c), tracks = clipTracks(c), ap = spec.apply || null;
|
|
12101
|
+
let maskIdx = -1, other = null;
|
|
12102
|
+
if (ap && ap.mask != null) {
|
|
12103
|
+
maskIdx = typeof ap.mask === "string" ? list.findIndex((m) => m.name === ap.mask) : ap.mask;
|
|
12104
|
+
if (!list[maskIdx]) return fail(`${c.id} has no mask ${JSON.stringify(ap.mask)}${list.length ? ` (it has ${Mask.describe(list)})` : " — add one with setMask first"}`);
|
|
12105
|
+
} else if (ap && ap.clip != null) {
|
|
12106
|
+
other = getClip(ap.clip);
|
|
12107
|
+
if (!other || !isVisualClip(other) || other.id === c.id) return fail(`apply.clip must be another visual clip (${ap.clip} is not)`);
|
|
12108
|
+
if (isGroupLocked(other) && spec.force !== true) return fail(`${other.id} is locked`);
|
|
12109
|
+
}
|
|
12110
|
+
const at = clamp(Number.isFinite(spec.at) ? spec.at : c.start, c.start, c.start + c.duration), from = at - c.start;
|
|
12111
|
+
let name = typeof spec.name === "string" && spec.name.trim() ? spec.name.trim().slice(0, 60) : null;
|
|
12112
|
+
if (!name && maskIdx >= 0) name = list[maskIdx].name || `Mask ${maskIdx + 1}`;
|
|
12113
|
+
if (!name) { let n = 1; while (tracks.some((x) => x.name === `Track ${n}`)) n++; name = `Track ${n}`; }
|
|
12114
|
+
const g = pictureRect(c, evalProps(c, at)) || pictureRect(c, evalProps(c, c.start));
|
|
12115
|
+
let box = null;
|
|
12116
|
+
if (spec.region) box = { x: +spec.region.x, y: +spec.region.y, w: +spec.region.w, h: +spec.region.h };
|
|
12117
|
+
else if (spec.point) { const sz = clamp(+spec.size || 0.08, 0.02, 0.4); box = { x: +spec.point.x, y: +spec.point.y, w: sz, h: sz * g.dw / g.dh }; }
|
|
12118
|
+
else if (maskIdx >= 0 && !tracks.some((x) => x.name === name)) box = maskBoundsAt(c, list[maskIdx], from);
|
|
12119
|
+
const existing = tracks.find((x) => x.name === name);
|
|
12120
|
+
if (!box && !existing) return fail("say what to follow: region {x, y, w, h} or point {x, y} (fractions of the clip's picture), or a mask to apply it to");
|
|
12121
|
+
if (box && !(box.x >= 0 && box.x <= 1 && box.y >= 0 && box.y <= 1 && box.w > 0 && box.h > 0)) return fail("region / point must lie inside the clip's picture (x, y in 0…1)");
|
|
12122
|
+
const opts = { scale: spec.scale === true, rotation: spec.rotation === true };
|
|
12123
|
+
const dirs = spec.direction === "backward" ? [-1] : spec.direction === "both" ? [-1, 1] : [1];
|
|
12124
|
+
const to = Number.isFinite(spec.to) ? clamp(spec.to - c.start, 0, c.duration) : null;
|
|
12125
|
+
if (state.playing) pause();
|
|
12126
|
+
renderCancelled = false;
|
|
12127
|
+
const stamp = trackStamp(c), otherStamp = other ? trackStamp(other) : "", maskStamp = maskIdx >= 0 ? JSON.stringify(list[maskIdx]) : null;
|
|
12128
|
+
let samples = [], cancelled = false;
|
|
12129
|
+
const lostAt = [];
|
|
12130
|
+
try {
|
|
12131
|
+
if (box) {
|
|
12132
|
+
for (const [k, dir] of dirs.entries()) {
|
|
12133
|
+
const run = { cancel: false };
|
|
12134
|
+
const end = to != null && Math.sign(to - from) === dir ? to : dir > 0 ? c.duration : 0;
|
|
12135
|
+
const r = await runTrack(c, { box, from, to: end, dir, ...opts, run, onProgress: (f) => {
|
|
12136
|
+
run.cancel = renderCancelled;
|
|
12137
|
+
reportExportJob(ticket.id, { progress: (k + f) / dirs.length });
|
|
12138
|
+
} });
|
|
12139
|
+
if (r.lostAt != null) lostAt.push(+(c.start + r.lostAt).toFixed(3));
|
|
12140
|
+
cancelled = cancelled || r.cancelled;
|
|
12141
|
+
samples = dir < 0 ? [...r.samples, ...samples] : [...samples, ...r.samples];
|
|
12142
|
+
if (cancelled) break;
|
|
12143
|
+
}
|
|
12144
|
+
samples = samples.filter((sm, i) => i === 0 || sm[0] > samples[i - 1][0] + 1e-6);
|
|
12145
|
+
if (samples.length < 2) return fail(lostAt.length ? "lost the region on the very next frame — pick a spot with more detail, or a bigger region" : "nothing to track in that range");
|
|
12146
|
+
}
|
|
12147
|
+
// the project may have been reloaded or edited while tracking: apply only onto what was measured
|
|
12148
|
+
if (getClip(c.id) !== c || trackStamp(c) !== stamp || (other && (getClip(other.id) !== other || trackStamp(other) !== otherStamp)) ||
|
|
12149
|
+
(maskStamp != null && JSON.stringify(maskList(c)[maskIdx] ?? null) !== maskStamp))
|
|
12150
|
+
return fail("the clip changed while tracking (an edit or a reload) — nothing was applied; run it again");
|
|
12151
|
+
const beforeJob = undoSnapshot();
|
|
12152
|
+
pushUndo(beforeJob);
|
|
12153
|
+
const tr = box ? storeTrack(c, name, opts.scale || opts.rotation ? "box" : "point", box.w, box.h, samples) : existing;
|
|
12154
|
+
const used = box ? samples : tr.samples;
|
|
12155
|
+
let applied = null;
|
|
12156
|
+
if (maskIdx >= 0) applied = `mask ${JSON.stringify(list[maskIdx].name || maskIdx)}: ${bakeTrackIntoMask(c, maskIdx, used, opts, from)} keys`;
|
|
12157
|
+
else if (other) {
|
|
12158
|
+
const o = { scale: ap.scale === true, rotation: ap.rotation === true, at: Number.isFinite(ap.at) ? ap.at : at };
|
|
12159
|
+
const n = attachToTrack(c, tr, other, o);
|
|
12160
|
+
applied = n ? `clip ${other.id}: ${n} keys on ${["x", "y", ...(o.scale ? ["scale"] : []), ...(o.rotation ? ["rotation"] : [])].join(", ")}` : `clip ${other.id}: it does not overlap the track in time — nothing keyed`;
|
|
12161
|
+
}
|
|
12162
|
+
state.dirtyTimeline = true;
|
|
12163
|
+
if (state.connected) {
|
|
12164
|
+
clearTimeout(runtime.saveTimer); runtime.saveTimer = null;
|
|
12165
|
+
project.revision++;
|
|
12166
|
+
const res = await fetch("/api/project", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(projectJSON(), null, 2) });
|
|
12167
|
+
if (!res.ok) {
|
|
12168
|
+
project.revision--;
|
|
12169
|
+
// put back only the clips this job changed (edits elsewhere since stay), and drop its undo step
|
|
12170
|
+
const was = JSON.parse(beforeJob), wasClips = Array.isArray(was) ? was : was.clips;
|
|
12171
|
+
for (const id of [c.id, other && other.id].filter(Boolean)) {
|
|
12172
|
+
const i = project.clips.findIndex((x) => x.id === id), old = wasClips.find((x) => x.id === id);
|
|
12173
|
+
if (i >= 0 && old) project.clips[i] = old;
|
|
12174
|
+
}
|
|
12175
|
+
const u = runtime.undo.lastIndexOf(beforeJob);
|
|
12176
|
+
if (u >= 0) runtime.undo.splice(u, 1);
|
|
12177
|
+
pruneSelection(); renderInspector(); drawFrame();
|
|
12178
|
+
return fail(res.status === 409 ? "the project changed while tracking — nothing was saved; run it again" : `could not save the project (${res.status}) — nothing was kept`);
|
|
12179
|
+
}
|
|
12180
|
+
}
|
|
12181
|
+
renderInspector(); drawFrame();
|
|
12182
|
+
const qs = used.map((sm) => sm[5]);
|
|
12183
|
+
await reportExportJob(ticket.id, { status: "done", result: {
|
|
12184
|
+
clip: c.id, track: tr.name, kind: tr.kind, describe: Tracker.describe([tr]),
|
|
12185
|
+
from: +(c.start + used[0][0]).toFixed(3), to: +(c.start + used[used.length - 1][0]).toFixed(3),
|
|
12186
|
+
samples: used.length, tracked: !!box, lostAt, cancelled,
|
|
12187
|
+
quality: { min: +Math.min(...qs).toFixed(3), mean: +(qs.reduce((a, b) => a + b, 0) / qs.length).toFixed(3) },
|
|
12188
|
+
applied, revision: project.revision,
|
|
12189
|
+
} }, true);
|
|
12190
|
+
} catch (err) {
|
|
12191
|
+
fail("tracking failed: " + (err && err.message || err));
|
|
12192
|
+
}
|
|
12193
|
+
}
|
|
10992
12194
|
/* fablecut_scopes: render one still exactly as export would (graded, all
|
|
10993
12195
|
tracks), then measure it with color.js scopeStats. The user's playhead
|
|
10994
12196
|
stays where it is. */
|
|
@@ -11023,7 +12225,12 @@ async function runScopesJob(ticket, fail) {
|
|
|
11023
12225
|
runtime.matte = null; // measure the graded picture, not the matte the user may be looking at
|
|
11024
12226
|
const mClip = ticket.matte && getClip(ticket.matte.clip);
|
|
11025
12227
|
if (mClip) runtime.gradeCapture = { id: mClip.id, args: null };
|
|
11026
|
-
|
|
12228
|
+
const kClip = ticket.mask && getClip(ticket.mask.clip), view = runtime.maskView;
|
|
12229
|
+
runtime.maskCapture = kClip ? { id: kClip.id } : null;
|
|
12230
|
+
runtime.maskView = null;
|
|
12231
|
+
try { await renderStillAt(t); } finally { runtime.matte = shown; runtime.maskView = view; }
|
|
12232
|
+
const kcap = runtime.maskCapture;
|
|
12233
|
+
runtime.maskCapture = null;
|
|
11027
12234
|
const cap = runtime.gradeCapture;
|
|
11028
12235
|
runtime.gradeCapture = null;
|
|
11029
12236
|
const W = els.preview.width, H = els.preview.height;
|
|
@@ -11067,15 +12274,32 @@ async function runScopesJob(ticket, fail) {
|
|
|
11067
12274
|
}
|
|
11068
12275
|
}
|
|
11069
12276
|
}
|
|
11070
|
-
|
|
11071
|
-
|
|
11072
|
-
|
|
11073
|
-
|
|
11074
|
-
|
|
12277
|
+
let mask = null;
|
|
12278
|
+
if (ticket.mask) {
|
|
12279
|
+
mask = { clip: ticket.mask.clip };
|
|
12280
|
+
if (!kClip) mask.error = "no such clip";
|
|
12281
|
+
else if (!clipMasks(kClip)) mask.error = "the clip has no masks";
|
|
12282
|
+
else if (!kcap || kcap.coverage == null) mask.error = "the clip is not on screen at that time (or its masks are all off)";
|
|
12283
|
+
else Object.assign(mask, { masks: Mask.describe(clipMasks(kClip)), coverage: kcap.coverage, frameCoverage: kcap.frameCoverage });
|
|
12284
|
+
}
|
|
12285
|
+
const onScreen = visibleClipsAt(t), mattes = matteTracksAt(onScreen);
|
|
12286
|
+
const clips = onScreen.map((c) => {
|
|
12287
|
+
const tm = clipMatte(c);
|
|
12288
|
+
return {
|
|
12289
|
+
id: c.id, name: c.name, kind: c.kind, track: c.track,
|
|
12290
|
+
grade: Color.summarizeGrade(c.props.grade),
|
|
12291
|
+
...(clipMasks(c) ? { masks: Mask.describe(clipMasks(c)) } : {}),
|
|
12292
|
+
...(tm ? { matte: `${tm.mode} from ${tm.track}` } : {}),
|
|
12293
|
+
...(mattes.has(c.track) ? { isMatte: true } : {}), // cuts a clip below; not drawn itself
|
|
12294
|
+
...(c.props.blend && c.props.blend !== "normal" ? { blend: c.props.blend } : {}),
|
|
12295
|
+
};
|
|
12296
|
+
});
|
|
12297
|
+
await reportExportJob(ticket.id, { status: "done", result: { time: +t.toFixed(3), frame: { w: Math.round(r.w), h: Math.round(r.h) }, stats, clips, matte, mask } }, true);
|
|
11075
12298
|
} catch (err) {
|
|
11076
12299
|
fail("could not measure the frame: " + (err && err.message || err));
|
|
11077
12300
|
} finally {
|
|
11078
12301
|
runtime.gradeCapture = null;
|
|
12302
|
+
runtime.maskCapture = null;
|
|
11079
12303
|
state.rendering = false;
|
|
11080
12304
|
seekMediaWhilePaused(); // the videos sat on the job's frame — back to the playhead
|
|
11081
12305
|
if (wasPlaying) play();
|
|
@@ -12845,12 +14069,13 @@ function layerSectionsHtml(c) {
|
|
|
12845
14069
|
</div>
|
|
12846
14070
|
<div class="insp-section cp-mask"><h3>Mask</h3>
|
|
12847
14071
|
<div class="cl-row">${["none", "ellipse", "rect", "poly"].map((s) => `<button type="button" class="btn tiny${(m ? m.shape : "none") === s ? " toggle on" : ""}" data-mshape="${s}">${s === "none" ? "None" : s === "rect" ? "Rect" : s[0].toUpperCase() + s.slice(1)}</button>`).join("")}
|
|
14072
|
+
<button type="button" class="btn tiny${colorState.maskDraw || (m && m.shape === "bezier") ? " toggle on" : ""}" data-mdraw title="Drag a free-hand outline on the monitor — it becomes an editable bezier">Draw</button>
|
|
12848
14073
|
${m ? `<button type="button" class="btn tiny${m.invert ? " toggle on" : ""}" data-mact="invert" title="Grade outside the shape">Invert</button>` : ""}</div>
|
|
12849
14074
|
${m ? `<div class="cl-row"><button type="button" class="btn tiny${onKey ? " toggle on" : ""}" data-mact="key" title="${onKey ? "Remove the key at the playhead" : "Key the shape at the playhead — with keys, every change keys"}">◆ Key</button>
|
|
12850
14075
|
<span class="cl-note">${keyed ? `${keyed} key${keyed > 1 ? "s" : ""}` : "not animated"}</span>
|
|
12851
14076
|
${keyed ? `<button type="button" class="btn tiny" data-mact="clearkeys" title="Remove every key (keeps the shape at the playhead)">Clear keys</button>` : ""}</div>
|
|
12852
14077
|
${mrow("x")}${mrow("y")}${m.shape !== "poly" ? mrow("w") + mrow("h") : ""}${mrow("rotation")}${mrow("feather")}
|
|
12853
|
-
<p class="cl-note">Drag it on the monitor: inside moves, handles resize, the knob rotates${m.
|
|
14078
|
+
<p class="cl-note">Drag it on the monitor: inside moves, handles resize, the knob rotates${m.points ? "; Ctrl-click adds a point, double-click one removes it" : ""}.</p>` : colorState.maskDraw ? `<p class="cl-note">Drag around what this layer should grade. Esc cancels.</p>` : ""}
|
|
12854
14079
|
</div>`;
|
|
12855
14080
|
}
|
|
12856
14081
|
function bindLayerControls(c, guard) {
|
|
@@ -12919,6 +14144,13 @@ function bindLayerControls(c, guard) {
|
|
|
12919
14144
|
scheduleSave(); rerender();
|
|
12920
14145
|
});
|
|
12921
14146
|
// mask
|
|
14147
|
+
const draw = root.querySelector("[data-mdraw]");
|
|
14148
|
+
if (draw) draw.addEventListener("click", () => {
|
|
14149
|
+
if (!guard()) return;
|
|
14150
|
+
colorState.maskDraw = !colorState.maskDraw; colorState.maskStroke = null;
|
|
14151
|
+
if (colorState.maskDraw) { if (state.playing) pause(); toast("Drag around what this layer should grade"); }
|
|
14152
|
+
rerender();
|
|
14153
|
+
});
|
|
12922
14154
|
for (const b of root.querySelectorAll("[data-mshape]")) b.addEventListener("click", () => {
|
|
12923
14155
|
if (!guard()) return;
|
|
12924
14156
|
const s = b.dataset.mshape, cur = maskNow(c);
|
|
@@ -13044,11 +14276,11 @@ function maskHandles(c) {
|
|
|
13044
14276
|
if (!sp) return null;
|
|
13045
14277
|
const a = sp.aspect, at = (px, py) => { const q = maskLocalToUv(m, a, px, py); return sp.toCanvas(q.u, q.v); };
|
|
13046
14278
|
const rx = (m.w / 2) * a, ry = m.h / 2;
|
|
13047
|
-
const top = m.
|
|
14279
|
+
const top = m.points ? Math.min(...m.points.map((p) => p[1])) : -ry;
|
|
13048
14280
|
const W = els.preview.width, H = els.preview.height, inset = Math.max(8, W / 110), knob = at(0, top - 0.08);
|
|
13049
14281
|
knob.x = clamp(knob.x, inset, W - inset); knob.y = clamp(knob.y, inset, H - inset); // stays grabbable above the frame
|
|
13050
14282
|
const h = { m, sp, centre: at(0, 0), knob, knobBase: at(0, top), edges: [], points: [] };
|
|
13051
|
-
if (m.
|
|
14283
|
+
if (m.points) h.points = m.points.map(([x, y]) => at(x * a, y));
|
|
13052
14284
|
else h.edges = [["w", rx, 0], ["w", -rx, 0], ["h", 0, ry], ["h", 0, -ry]].map(([k, x, y]) => ({ k, ...at(x, y) }));
|
|
13053
14285
|
return h;
|
|
13054
14286
|
}
|
|
@@ -13058,6 +14290,9 @@ function maskOutline(c, h) {
|
|
|
13058
14290
|
for (let i = 0; i < 64; i++) { const t = i / 64 * Math.PI * 2; const q = maskLocalToUv(m, a, Math.cos(t) * m.w / 2 * a, Math.sin(t) * m.h / 2); pts.push(sp.toCanvas(q.u, q.v)); }
|
|
13059
14291
|
} else if (m.shape === "rect") {
|
|
13060
14292
|
for (const [x, y] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) { const q = maskLocalToUv(m, a, x * m.w / 2 * a, y * m.h / 2); pts.push(sp.toCanvas(q.u, q.v)); }
|
|
14293
|
+
} else if (m.shape === "bezier") {
|
|
14294
|
+
const A = Mask.anchors({ shape: "bezier", x: 0, y: 0, scale: 1, rotation: 0, points: m.points }, 1, 1);
|
|
14295
|
+
for (const [x, y] of Mask.flatten(A, 16)) { const q = maskLocalToUv(m, a, x * a, y); pts.push(sp.toCanvas(q.u, q.v)); }
|
|
13061
14296
|
} else pts.push(...h.points);
|
|
13062
14297
|
return pts;
|
|
13063
14298
|
}
|
|
@@ -13085,10 +14320,34 @@ function drawMaskOverlay(c, W) {
|
|
|
13085
14320
|
ctx2d.restore();
|
|
13086
14321
|
return true;
|
|
13087
14322
|
}
|
|
14323
|
+
/** Color tab Draw: a free-hand outline for the selected layer's mask. */
|
|
14324
|
+
function drawingLayerMask(c) {
|
|
14325
|
+
return colorState.maskDraw && colorState.ws === "color" && colorState.layer >= 0 && c && state.selId === c.id && activeAt(c, state.time) && !!maskSpace(c);
|
|
14326
|
+
}
|
|
14327
|
+
function drawLayerStroke(c, W) {
|
|
14328
|
+
const sp = maskSpace(c), st = colorState.maskStroke;
|
|
14329
|
+
if (!sp || !st || st.length < 2) return;
|
|
14330
|
+
ctx2d.save();
|
|
14331
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
14332
|
+
ctx2d.lineWidth = Math.max(2, W / 600); ctx2d.strokeStyle = "#ffd25a";
|
|
14333
|
+
ctx2d.beginPath();
|
|
14334
|
+
st.forEach(([u, v], i) => { const p = sp.toCanvas(u, v); if (i) ctx2d.lineTo(p.x, p.y); else ctx2d.moveTo(p.x, p.y); });
|
|
14335
|
+
ctx2d.stroke();
|
|
14336
|
+
ctx2d.restore();
|
|
14337
|
+
}
|
|
13088
14338
|
let maskDrag = null;
|
|
13089
14339
|
els.preview.addEventListener("pointerdown", (e) => {
|
|
13090
14340
|
if (e.button !== 0 || e.altKey || isSourceMode() || colorState.pick || colorState.curvePick || colorState.qualPick) return;
|
|
13091
14341
|
const c = getClip(state.selId);
|
|
14342
|
+
if (drawingLayerMask(c)) {
|
|
14343
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
14344
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
14345
|
+
const uv = maskSpace(c).toUv(canvasPt(e));
|
|
14346
|
+
colorState.maskStroke = [[uv.u, uv.v]];
|
|
14347
|
+
maskDrag = { mode: "draw", id: c.id };
|
|
14348
|
+
els.preview.setPointerCapture(e.pointerId);
|
|
14349
|
+
return;
|
|
14350
|
+
}
|
|
13092
14351
|
if (!maskEditing(c)) return;
|
|
13093
14352
|
const h = maskHandles(c);
|
|
13094
14353
|
if (!h) return;
|
|
@@ -13099,7 +14358,7 @@ els.preview.addEventListener("pointerdown", (e) => {
|
|
|
13099
14358
|
if (near(h.knob)) drag = { mode: "rotate" };
|
|
13100
14359
|
else if (h.points.some(near)) drag = { mode: "point", i: h.points.findIndex(near) };
|
|
13101
14360
|
else if (h.edges.some(near)) drag = { mode: h.edges.find(near).k };
|
|
13102
|
-
else if (m.
|
|
14361
|
+
else if (m.points && (e.ctrlKey || e.metaKey)) { // add a point on the nearest edge
|
|
13103
14362
|
const lp = uvToMaskLocal(m, a, uv.u, uv.v), P = m.points.map(([x, y]) => [x * a, y]);
|
|
13104
14363
|
let best = 0, bd = Infinity;
|
|
13105
14364
|
for (let i = 0; i < P.length; i++) {
|
|
@@ -13108,7 +14367,8 @@ els.preview.addEventListener("pointerdown", (e) => {
|
|
|
13108
14367
|
const d = Math.hypot(lp.x - x1 - ex * k, lp.y - y1 - ey * k);
|
|
13109
14368
|
if (d < bd) { bd = d; best = i; }
|
|
13110
14369
|
}
|
|
13111
|
-
|
|
14370
|
+
const most = m.shape === "bezier" ? Color.MAX_BEZIER : Color.MAX_POLY;
|
|
14371
|
+
if (m.points.length >= most) { toast(`A mask has at most ${most} points`); e.stopImmediatePropagation(); return; }
|
|
13112
14372
|
if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
|
|
13113
14373
|
const pts = m.points.map((p) => p.slice());
|
|
13114
14374
|
pts.splice(best + 1, 0, [lp.x / a, lp.y]);
|
|
@@ -13128,6 +14388,11 @@ els.preview.addEventListener("pointermove", (e) => {
|
|
|
13128
14388
|
e.stopImmediatePropagation();
|
|
13129
14389
|
const c = getClip(maskDrag.id), sp = c && maskSpace(c);
|
|
13130
14390
|
if (!sp) return;
|
|
14391
|
+
if (maskDrag.mode === "draw") {
|
|
14392
|
+
const uv = sp.toUv(canvasPt(e)), st = colorState.maskStroke, last = st && st[st.length - 1];
|
|
14393
|
+
if (last && Math.hypot((uv.u - last[0]) * sp.aspect, uv.v - last[1]) > 0.003) { st.push([uv.u, uv.v]); state.dirtyTimeline = true; }
|
|
14394
|
+
return;
|
|
14395
|
+
}
|
|
13131
14396
|
const uv = sp.toUv(canvasPt(e)), m0 = maskDrag.m0, a = sp.aspect, d = maskDrag;
|
|
13132
14397
|
const lp = uvToMaskLocal(m0, a, uv.u, uv.v);
|
|
13133
14398
|
let set;
|
|
@@ -13141,7 +14406,7 @@ els.preview.addEventListener("pointermove", (e) => {
|
|
|
13141
14406
|
set = { rotation: +r.toFixed(1) };
|
|
13142
14407
|
} else if (d.mode === "point") {
|
|
13143
14408
|
const pts = (curLayer(c)?.mask?.points || m0.points).map((p) => p.slice());
|
|
13144
|
-
pts[d.i] = [+(lp.x / a).toFixed(4), +lp.y.toFixed(4)];
|
|
14409
|
+
pts[d.i] = [+(lp.x / a).toFixed(4), +lp.y.toFixed(4), ...pts[d.i].slice(2)]; // a bezier anchor keeps its handles
|
|
13145
14410
|
set = { points: pts };
|
|
13146
14411
|
}
|
|
13147
14412
|
if (set) { writeMask(c, set); syncMaskInputs(c); state.dirtyTimeline = true; }
|
|
@@ -13149,6 +14414,19 @@ els.preview.addEventListener("pointermove", (e) => {
|
|
|
13149
14414
|
const endMaskDrag = (e) => {
|
|
13150
14415
|
if (!maskDrag) return;
|
|
13151
14416
|
e.stopImmediatePropagation();
|
|
14417
|
+
if (maskDrag.mode === "draw") {
|
|
14418
|
+
const c = getClip(maskDrag.id), sp = c && maskSpace(c), st = colorState.maskStroke;
|
|
14419
|
+
maskDrag = null; colorState.maskStroke = null; state.dirtyTimeline = true;
|
|
14420
|
+
if (!c || !sp || !st || st.length < 6) { toast("Drag around what this layer should grade"); return; }
|
|
14421
|
+
const cur = maskNow(c), m = { shape: "freehand", stroke: st, aspect: sp.aspect, feather: cur ? cur.feather : 0.03 };
|
|
14422
|
+
if (cur && cur.invert) m.invert = true;
|
|
14423
|
+
pushUndo();
|
|
14424
|
+
writeLayer(c, colorState.layer, { mask: null });
|
|
14425
|
+
writeLayer(c, colorState.layer, { mask: m });
|
|
14426
|
+
colorState.maskDraw = false;
|
|
14427
|
+
scheduleSave(); renderColorPage(true);
|
|
14428
|
+
return;
|
|
14429
|
+
}
|
|
13152
14430
|
maskDrag = null; colorState.mdrag = false;
|
|
13153
14431
|
scheduleSave(); renderColorPage(true);
|
|
13154
14432
|
};
|