fablecut 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +429 -5
- package/README.md +23 -2
- package/app.js +1965 -103
- package/comp.js +195 -0
- package/edit-ops.js +50 -18
- package/index.html +146 -68
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mcp-server.js +437 -17
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +25 -2
- package/server.js +20 -1
- package/style.css +177 -24
- package/templates.js +576 -0
package/app.js
CHANGED
|
@@ -46,6 +46,11 @@ const TIMELINE_FIT_FILL = 0.95; // ⇧Z / Fit — clip content fills this fracti
|
|
|
46
46
|
|
|
47
47
|
const DEFAULT_PROPS = {
|
|
48
48
|
x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0,
|
|
49
|
+
anchorX: 0, anchorY: 0, // anchor point, px from the picture's centre (motion.js)
|
|
50
|
+
trimStart: 0, trimEnd: 100, trimOffset: 0, // shape layers: the part of every path that draws (%)
|
|
51
|
+
autoOrient: false, // turn along the motion path
|
|
52
|
+
pathOffset: 0, pathAlign: "start", // text on a path: where along it (%), which end sits there
|
|
53
|
+
motionBlur: 0, motionBlurSamples: 8, // shutter angle (°, 0 = off) and sub-frame samples
|
|
49
54
|
gain: 0, // clip gain, dB — applied before volume
|
|
50
55
|
channelMode: "stereo", // stereo | mono | left | right | swap
|
|
51
56
|
duck: 0, // ducking, dB (≤ 0) — keyframed by Auto-duck, multiplies volume
|
|
@@ -77,7 +82,7 @@ const DEFAULT_PROPS = {
|
|
|
77
82
|
const ANIMATABLE = ["x", "y", "scale", "rotation", "opacity", "volume", "pan", "duck", "speed",
|
|
78
83
|
"brightness", "contrast", "saturation", "hue", "blur", "grayscale", "sepia", "invert",
|
|
79
84
|
"temperature", "tint", "vignette", "cornerRadius", "shake", "rgbSplit", "grain",
|
|
80
|
-
"fontSize", "letterSpacing", "glow"];
|
|
85
|
+
"fontSize", "letterSpacing", "glow", "anchorX", "anchorY", "trimStart", "trimEnd", "trimOffset", "pathOffset"];
|
|
81
86
|
const TRANSITIONS = ["none", "fade", "slide-left", "slide-right", "slide-up", "slide-down",
|
|
82
87
|
"zoom", "wipe", "wipe-right", "wipe-up", "wipe-down", "iris", "spin", "blur", "whip",
|
|
83
88
|
"glitch", "pop"];
|
|
@@ -429,6 +434,10 @@ function showClipCtxMenu(clientX, clientY, c) {
|
|
|
429
434
|
item("Crossfade with neighbours", "Shift+D", () => crossfadeSelected(), locked ? "Locked — unlock to change" : null);
|
|
430
435
|
item("Auto-duck under other tracks", "", () => autoDuckClips(selectedClips()), locked ? "Locked — unlock to change" : null);
|
|
431
436
|
}
|
|
437
|
+
if (selectedClips().some((x) => isVisualClip(x) && x.kind !== "null"))
|
|
438
|
+
item("Parent to a new null", "", parentSelectionToNull, locked ? "Locked — unlock to change" : null);
|
|
439
|
+
item("Precompose", "", precomposeSelection, locked ? "Locked — unlock to change" : null);
|
|
440
|
+
if (c.kind === "comp") item("Open composition", "", () => enterComp(c.compId, c));
|
|
432
441
|
const linked = selectedClips().some((x) => x.linkGroup || x.linkedId);
|
|
433
442
|
if (linked) item("Unlink audio / video", "Ctrl+L", toggleLinkSelected, locked ? "Locked — unlock to change" : null);
|
|
434
443
|
else if (c.kind === "video" || c.kind === "audio") {
|
|
@@ -556,7 +565,7 @@ const state = {
|
|
|
556
565
|
webCodecs: false, // VideoEncoder + Annex-B H.264 supported
|
|
557
566
|
dirtyTimeline: true, gesture: false,
|
|
558
567
|
workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT
|
|
559
|
-
binTab: "project", // project | elements | sfx | svg
|
|
568
|
+
binTab: "project", // project | elements | sfx | svg | shapes
|
|
560
569
|
disabledTracks: new Set(), // mirror of project.disabledTracks for fast lookup
|
|
561
570
|
lockedTracks: new Set(), // mirror of project.lockedTracks
|
|
562
571
|
untargetedTracks: new Set(), // mirror of project.untargetedTracks
|
|
@@ -609,7 +618,11 @@ const {
|
|
|
609
618
|
listAlignedGaps, adjacentClip, rippleTrim, rollEdit, slipClip, slideClip, crossfadeCuts, crossfadeCut,
|
|
610
619
|
} = EDIT;
|
|
611
620
|
/** Does this clip reach the picture / the mix? Track output and clip enable. */
|
|
612
|
-
function clipRenders(c) {
|
|
621
|
+
function clipRenders(c) {
|
|
622
|
+
// comp audio plays through the main timeline's track of the same id, so that track's switch applies too
|
|
623
|
+
if (c._comp) return c._on !== false && (c.kind !== "audio" || isTrackEnabled(c.track));
|
|
624
|
+
return isTrackEnabled(c.track) && c.disabled !== true;
|
|
625
|
+
}
|
|
613
626
|
function toastLocked(partial = false) {
|
|
614
627
|
toast(partial ? "Locked clips were left untouched — unlock them to edit"
|
|
615
628
|
: "Locked — unlock the clip or its track to edit");
|
|
@@ -873,7 +886,7 @@ function projDur() {
|
|
|
873
886
|
}
|
|
874
887
|
return mx;
|
|
875
888
|
}
|
|
876
|
-
const clipSpeed =
|
|
889
|
+
const clipSpeed = FableCutEdit.clipSpeed; // comp clips run at 1×
|
|
877
890
|
|
|
878
891
|
/* ── Speed ramps (time remapping) ──
|
|
879
892
|
`speed` is keyframable: media time = in + ∫ speed(t) dt over the clip.
|
|
@@ -921,8 +934,17 @@ function propsAtPlayhead(c) {
|
|
|
921
934
|
const v = kfChannel(c, k, local, +(p[k] ?? DEFAULT_PROPS[k] ?? 0));
|
|
922
935
|
if (typeof v === "number" && !isNaN(v)) p[k] = v;
|
|
923
936
|
}
|
|
937
|
+
applyMotionPath(c, p, local, false);
|
|
924
938
|
return p;
|
|
925
939
|
}
|
|
940
|
+
/* Motion path (motion.js): position keys with spatial handles move the clip along a
|
|
941
|
+
curve; auto-orient turns it with its direction of travel. */
|
|
942
|
+
function applyMotionPath(c, p, local, orient) {
|
|
943
|
+
if (!c.keyframes || !c.keyframes.x || !c.keyframes.y) return;
|
|
944
|
+
const q = self.FableCutMotion.pathPositionAt(c, local);
|
|
945
|
+
if (q) { p.x = q.x; p.y = q.y; }
|
|
946
|
+
if (orient && p.autoOrient) { const a = self.FableCutMotion.orientAt(c, local); if (a != null) p.rotation = (+p.rotation || 0) + a; }
|
|
947
|
+
}
|
|
926
948
|
function fmtInspNum(v, step) {
|
|
927
949
|
const n = +v;
|
|
928
950
|
if (!Number.isFinite(n)) return "0";
|
|
@@ -980,6 +1002,11 @@ function setAnimProp(c, k, v) {
|
|
|
980
1002
|
const eps = kfTimeEps();
|
|
981
1003
|
const dup = arr.find((kf) => Math.abs(kf.t - lt) < eps);
|
|
982
1004
|
if (dup) { dup.v = v; return true; }
|
|
1005
|
+
const other = k === "x" ? "y" : k === "y" ? "x" : null, oarr = other && c.keyframes[other];
|
|
1006
|
+
if (other && Array.isArray(oarr) && oarr.length && self.FableCutMotion.hasPathHandles(c) && !oarr.some((kf) => Math.abs(kf.t - lt) < eps)) {
|
|
1007
|
+
oarr.push({ t: lt, v: +self.FableCutMotion.positionAt(c, lt)[other].toFixed(3) });
|
|
1008
|
+
oarr.sort((a, b) => a.t - b.t);
|
|
1009
|
+
}
|
|
983
1010
|
arr.push({ t: lt, v });
|
|
984
1011
|
arr.sort((a, b) => a.t - b.t);
|
|
985
1012
|
state.dirtyTimeline = true;
|
|
@@ -1073,7 +1100,7 @@ function toggleKfAtPlayhead(c, k) {
|
|
|
1073
1100
|
return true;
|
|
1074
1101
|
}
|
|
1075
1102
|
function hasSpeedRamp(c) {
|
|
1076
|
-
return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0;
|
|
1103
|
+
return c.kind !== "comp" && Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0; // comps run at 1×
|
|
1077
1104
|
}
|
|
1078
1105
|
function mediaTimeAt(c, t) {
|
|
1079
1106
|
const base = clipSpeed(c);
|
|
@@ -1390,7 +1417,19 @@ function syncExportFrameSel() {
|
|
|
1390
1417
|
html += `<option value="custom" selected>Custom · ${ef.w}×${ef.h}</option>`;
|
|
1391
1418
|
els.exportFrameSel.innerHTML = html;
|
|
1392
1419
|
}
|
|
1393
|
-
function applyProject(data) {
|
|
1420
|
+
function applyProject(data, opts = {}) {
|
|
1421
|
+
if (state.compCtx && !opts.ctx) { // the root changed on disk (an agent, another tab): keep the open comp open
|
|
1422
|
+
const x = state.compCtx;
|
|
1423
|
+
x.root = data;
|
|
1424
|
+
// every undo step here and in the levels above predates this document; replaying one would save over it
|
|
1425
|
+
for (const f of x.frames) { f.undo = []; f.redo = []; }
|
|
1426
|
+
runtime.undo.length = 0; runtime.redo.length = 0;
|
|
1427
|
+
const path = x.path.filter((id) => CompLib.getComp(data, id));
|
|
1428
|
+
if (path.length !== x.path.length) { state.compCtx = null; applyProject(data, { ctx: true }); syncCompCrumb(); toast("The open composition was removed"); return; }
|
|
1429
|
+
applyProject(compSyntheticDoc(data, path), { ctx: true });
|
|
1430
|
+
syncCompCrumb();
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1394
1433
|
const wa = normalizeWorkArea(data.inPoint, data.outPoint);
|
|
1395
1434
|
Object.assign(project, {
|
|
1396
1435
|
name: data.name || "Untitled Project",
|
|
@@ -1408,7 +1447,13 @@ function applyProject(data) {
|
|
|
1408
1447
|
encodeProfile: data.encodeProfile || null,
|
|
1409
1448
|
master: normalizeMaster(data.master),
|
|
1410
1449
|
buses: normalizeBuses(data.buses),
|
|
1450
|
+
comps: (Array.isArray(data.comps) ? data.comps : []).filter((cp) => cp && cp.id).map((cp) => ({
|
|
1451
|
+
...cp, clips: (cp.clips || []).map((x) => {
|
|
1452
|
+
if (x.keyframes) for (const arr of Object.values(x.keyframes)) if (Array.isArray(arr)) arr.sort((a, b) => a.t - b.t);
|
|
1453
|
+
return { ...x, props: { ...DEFAULT_PROPS, ...(x.kind === "comp" ? { fit: "none" } : {}), ...(x.props || {}) } };
|
|
1454
|
+
}) })),
|
|
1411
1455
|
});
|
|
1456
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
1412
1457
|
applyTracksFromProject(data.tracks);
|
|
1413
1458
|
ensureTracksCoverClips();
|
|
1414
1459
|
project.tracks = serializeTracks();
|
|
@@ -1433,7 +1478,7 @@ function applyProject(data) {
|
|
|
1433
1478
|
c.fx = normFx(c.fx);
|
|
1434
1479
|
if (!c.fx) delete c.fx;
|
|
1435
1480
|
const raw = c.props || {};
|
|
1436
|
-
c.props = { ...DEFAULT_PROPS, ...raw };
|
|
1481
|
+
c.props = { ...DEFAULT_PROPS, ...(c.kind === "comp" ? { fit: "none" } : {}), ...raw };
|
|
1437
1482
|
if (c.props.grade !== undefined) {
|
|
1438
1483
|
const g = self.FableCutColor.normalizeGrade(c.props.grade);
|
|
1439
1484
|
if (g) c.props.grade = g; else delete c.props.grade;
|
|
@@ -1494,7 +1539,28 @@ function scheduleSave() {
|
|
|
1494
1539
|
} catch { }
|
|
1495
1540
|
}, 400);
|
|
1496
1541
|
}
|
|
1542
|
+
/** One clip as saved: only the fields the editor knows (unknown runtime fields stay out). */
|
|
1543
|
+
function serializeClip({ id, mediaId, kind, compId, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup, locked, disabled, unlinked, fx, parent }) {
|
|
1544
|
+
const clipOut = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut };
|
|
1545
|
+
if (compId) clipOut.compId = compId;
|
|
1546
|
+
if (fx?.length) clipOut.fx = fx;
|
|
1547
|
+
if (linkGroup) clipOut.linkGroup = linkGroup;
|
|
1548
|
+
if (linkedId) clipOut.linkedId = linkedId;
|
|
1549
|
+
// Written only when set, so untouched projects stay byte-identical.
|
|
1550
|
+
if (locked === true) clipOut.locked = true;
|
|
1551
|
+
if (disabled === true) clipOut.disabled = true;
|
|
1552
|
+
if (unlinked === true) clipOut.unlinked = true;
|
|
1553
|
+
if (parent) clipOut.parent = parent;
|
|
1554
|
+
return clipOut;
|
|
1555
|
+
}
|
|
1556
|
+
/** The document to save: the root project, with the open comp (if any) written into it. */
|
|
1497
1557
|
function projectJSON() {
|
|
1558
|
+
if (!state.compCtx) return projectJSONHere();
|
|
1559
|
+
const root = commitCompCtx();
|
|
1560
|
+
return { ...root, revision: project.revision };
|
|
1561
|
+
}
|
|
1562
|
+
/** The project as it is loaded right now (an open comp's stand-in project, or the root). */
|
|
1563
|
+
function projectJSONHere() {
|
|
1498
1564
|
const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks, encodeProfile } = project;
|
|
1499
1565
|
const out = {
|
|
1500
1566
|
name, width, height, fps, background, revision,
|
|
@@ -1507,17 +1573,7 @@ function projectJSON() {
|
|
|
1507
1573
|
if (derivedFrom) { mo.derivedFrom = derivedFrom; if (denoise) mo.denoise = denoise; }
|
|
1508
1574
|
return mo;
|
|
1509
1575
|
}),
|
|
1510
|
-
clips: clips.map(
|
|
1511
|
-
const clipOut = { id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut };
|
|
1512
|
-
if (fx?.length) clipOut.fx = fx;
|
|
1513
|
-
if (linkGroup) clipOut.linkGroup = linkGroup;
|
|
1514
|
-
if (linkedId) clipOut.linkedId = linkedId;
|
|
1515
|
-
// Written only when set, so untouched projects stay byte-identical.
|
|
1516
|
-
if (locked === true) clipOut.locked = true;
|
|
1517
|
-
if (disabled === true) clipOut.disabled = true;
|
|
1518
|
-
if (unlinked === true) clipOut.unlinked = true;
|
|
1519
|
-
return clipOut;
|
|
1520
|
-
}),
|
|
1576
|
+
clips: clips.map(serializeClip),
|
|
1521
1577
|
markers: normalizeMarkers(markers),
|
|
1522
1578
|
inPoint: inPoint == null ? null : inPoint,
|
|
1523
1579
|
outPoint: outPoint == null ? null : outPoint,
|
|
@@ -1534,6 +1590,7 @@ function projectJSON() {
|
|
|
1534
1590
|
if (master) out.master = master;
|
|
1535
1591
|
const buses = normalizeBuses(project.buses);
|
|
1536
1592
|
if (buses.length) out.buses = buses;
|
|
1593
|
+
if (project.comps && project.comps.length) out.comps = project.comps.map((cp) => ({ ...cp, clips: (cp.clips || []).map(serializeClip) }));
|
|
1537
1594
|
return out;
|
|
1538
1595
|
}
|
|
1539
1596
|
function listenSSE() {
|
|
@@ -1559,7 +1616,9 @@ function listenSSE() {
|
|
|
1559
1616
|
async function syncFromServer(force) {
|
|
1560
1617
|
if (state.gesture || state.exporting) { runtime.pendingSync = true; return; }
|
|
1561
1618
|
runtime.pendingSync = false;
|
|
1562
|
-
if (state.binTab
|
|
1619
|
+
if (state.binTab === "templates") fetchSaved("templates").then(() => { if (state.binTab === "templates") renderTemplatesTab(); });
|
|
1620
|
+
else if (state.binTab !== "project" && state.binTab !== "shapes") fetchLibrary(state.binTab).then(renderLibrary);
|
|
1621
|
+
fetchSaved("presets");
|
|
1563
1622
|
loadLibraryFonts();
|
|
1564
1623
|
fetchEncodeProfiles();
|
|
1565
1624
|
try {
|
|
@@ -1570,6 +1629,8 @@ async function syncFromServer(force) {
|
|
|
1570
1629
|
if (!force && (data.revision || 0) === (project.revision || 0)) return; // our own save
|
|
1571
1630
|
if (runtime.saveTimer) { // unsaved local edit vs. external write: external wins, tell the user
|
|
1572
1631
|
clearTimeout(runtime.saveTimer); runtime.saveTimer = null;
|
|
1632
|
+
// the undo steps describe the discarded edit; replaying one would re-save over the external write
|
|
1633
|
+
runtime.undo.length = 0; runtime.redo.length = 0;
|
|
1573
1634
|
toast("Project was updated externally — your last change may need redoing.");
|
|
1574
1635
|
}
|
|
1575
1636
|
applyProject(data);
|
|
@@ -1833,10 +1894,12 @@ async function importFromUrl(url) {
|
|
|
1833
1894
|
}
|
|
1834
1895
|
|
|
1835
1896
|
function renderBin() {
|
|
1836
|
-
els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root").forEach((n) => n.remove());
|
|
1837
|
-
const
|
|
1897
|
+
els.binList.querySelectorAll(".bin-item, .bin-folder, .bin-drop-root, .comp-item").forEach((n) => n.remove());
|
|
1898
|
+
const comps = project.comps || [];
|
|
1899
|
+
const empty = !project.media.length && !project.folders.length && !comps.length;
|
|
1838
1900
|
els.binEmpty.style.display = empty ? "" : "none";
|
|
1839
1901
|
if (empty) return;
|
|
1902
|
+
for (const comp of comps) els.binList.appendChild(makeCompItem(comp));
|
|
1840
1903
|
|
|
1841
1904
|
const clearBinDropHints = () => {
|
|
1842
1905
|
els.binList.querySelectorAll(".bin-drop-over").forEach((n) => n.classList.remove("bin-drop-over"));
|
|
@@ -1944,9 +2007,13 @@ function renderBin() {
|
|
|
1944
2007
|
});
|
|
1945
2008
|
item.querySelector(".bin-del").addEventListener("click", (e) => {
|
|
1946
2009
|
e.stopPropagation();
|
|
1947
|
-
|
|
2010
|
+
if (state.compCtx && (state.compCtx.root.clips || []).some((c) => c.mediaId === m.id)) { toast("It's used on the main timeline — go back to Main to delete it"); return; }
|
|
2011
|
+
pushUndo(JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media })); // undo brings the media back too
|
|
1948
2012
|
project.media = project.media.filter((x) => x.id !== m.id);
|
|
1949
2013
|
project.clips = project.clips.filter((c) => c.mediaId !== m.id);
|
|
2014
|
+
// and its clips inside comps (and on the main timeline while a comp is open)
|
|
2015
|
+
project.comps = (project.comps || []).map((cp) => ({ ...cp, clips: (cp.clips || []).filter((c) => c.mediaId !== m.id) }));
|
|
2016
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
1950
2017
|
if (state.source.mediaId === m.id) clearSource();
|
|
1951
2018
|
renderBin(); scheduleSave(); renderInspector();
|
|
1952
2019
|
});
|
|
@@ -2161,14 +2228,16 @@ function setBinTab(tab) {
|
|
|
2161
2228
|
const isProj = tab === "project";
|
|
2162
2229
|
els.binList.classList.toggle("hidden", !isProj);
|
|
2163
2230
|
els.libList.classList.toggle("hidden", isProj);
|
|
2164
|
-
if (
|
|
2231
|
+
if (tab === "shapes") renderShapesTab();
|
|
2232
|
+
else if (tab === "templates") { renderTemplatesTab(); fetchSaved("templates").then(() => { if (state.binTab === "templates") renderTemplatesTab(); }); }
|
|
2233
|
+
else if (!isProj) fetchLibrary(tab).then(renderLibrary);
|
|
2165
2234
|
}
|
|
2166
2235
|
|
|
2167
2236
|
/* ═══════════════════════════ EDIT OPERATIONS ═══════════════════════════ */
|
|
2168
2237
|
/* Undo entries are {clips, markers} snapshots. Drag gestures still push a bare
|
|
2169
2238
|
clips array (taken at pointerdown) — restoring one leaves markers alone. */
|
|
2170
2239
|
function undoSnapshot() {
|
|
2171
|
-
return JSON.stringify({ clips: project.clips, markers: project.markers || [] });
|
|
2240
|
+
return JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [] });
|
|
2172
2241
|
}
|
|
2173
2242
|
/** Record an undo step — `snap` lets an edit snapshot first and record only
|
|
2174
2243
|
* once it knows it changed something. */
|
|
@@ -2180,7 +2249,8 @@ function pushUndo(snap = undoSnapshot()) {
|
|
|
2180
2249
|
function restoreSnapshot(json) {
|
|
2181
2250
|
const snap = JSON.parse(json);
|
|
2182
2251
|
if (Array.isArray(snap)) project.clips = snap;
|
|
2183
|
-
else { project.clips = snap.clips; project.markers = snap.markers; }
|
|
2252
|
+
else { project.clips = snap.clips; project.markers = snap.markers; if (snap.comps) project.comps = snap.comps; if (snap.media) { project.media = snap.media; renderBin(); } }
|
|
2253
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
2184
2254
|
}
|
|
2185
2255
|
function undo() {
|
|
2186
2256
|
if (!runtime.undo.length) return;
|
|
@@ -3302,10 +3372,19 @@ function rebuildClips() {
|
|
|
3302
3372
|
})();
|
|
3303
3373
|
const label = c.kind === "text" ? "T · " + (c.props.text || "").split("\n")[0]
|
|
3304
3374
|
: c.kind === "adjust" ? "FX · " + (c.name || "")
|
|
3375
|
+
: c.kind === "shape" ? "◆ · " + (c.name || "")
|
|
3376
|
+
: c.kind === "null" ? "✥ · " + (c.name || "")
|
|
3377
|
+
: c.kind === "comp" ? "▣ · " + ((getComp(c.compId) || {}).name || c.name || "missing comp")
|
|
3305
3378
|
: c.kind === "audio" ? chTag + (c.name || "")
|
|
3306
3379
|
: (c.name || "");
|
|
3307
3380
|
body += `<div class="fade"></div>
|
|
3308
3381
|
<div class="clip-label">${badge}${escapeHtml(label)}</div>`;
|
|
3382
|
+
if (c.kind === "comp") {
|
|
3383
|
+
// say on hover how to get inside (wide enough clips show it in the clip, every one in its tooltip)
|
|
3384
|
+
const comp = getComp(c.compId);
|
|
3385
|
+
div.title = comp ? `${comp.template ? "Template" : "Composition"} "${comp.name}" — double-click to open and edit${comp.template ? " · its fields are in the Inspector" : ""}` : "Its composition is missing";
|
|
3386
|
+
if (comp && c.duration * state.pps >= label.length * 7 + 190) body += `<div class="comp-open-hint">Double-click to open</div>`;
|
|
3387
|
+
}
|
|
3309
3388
|
body += clipKeyframesHtml(c);
|
|
3310
3389
|
let inner = `<div class="clip-body">${body}</div>`;
|
|
3311
3390
|
inner += transitionMarksHtml(c, tr.h);
|
|
@@ -3909,6 +3988,7 @@ function startTrimToolGesture(e, c, mode) {
|
|
|
3909
3988
|
transitionIn: cloneTr(x.transitionIn), transitionOut: cloneTr(x.transitionOut),
|
|
3910
3989
|
masks: x.props && x.props.masks, // head trims re-base mask keys: each step starts from the originals
|
|
3911
3990
|
tracks: x.props && x.props.tracks,
|
|
3991
|
+
shapes: x.props && x.props.shapes,
|
|
3912
3992
|
}]));
|
|
3913
3993
|
const restore = () => {
|
|
3914
3994
|
for (const x of project.clips) {
|
|
@@ -3919,6 +3999,7 @@ function startTrimToolGesture(e, c, mode) {
|
|
|
3919
3999
|
if (x.props) {
|
|
3920
4000
|
if (b.masks) x.props.masks = b.masks; else delete x.props.masks;
|
|
3921
4001
|
if (b.tracks) x.props.tracks = b.tracks; else delete x.props.tracks;
|
|
4002
|
+
if (b.shapes) x.props.shapes = b.shapes; else delete x.props.shapes;
|
|
3922
4003
|
}
|
|
3923
4004
|
}
|
|
3924
4005
|
};
|
|
@@ -4050,6 +4131,7 @@ els.tracksContent.addEventListener("dblclick", (e) => {
|
|
|
4050
4131
|
}
|
|
4051
4132
|
if (e.target.closest(".handle, .trans-mark, .trans-dur-handle, .clip-kf, .fade-grip, .vol-hit")) return;
|
|
4052
4133
|
e.preventDefault();
|
|
4134
|
+
if (c.kind === "comp") { enterComp(c.compId, c); return; } // a comp opens on the timeline
|
|
4053
4135
|
loadSourceFromClip(c, { timelineTime: timeAtEvent(e) });
|
|
4054
4136
|
});
|
|
4055
4137
|
|
|
@@ -4103,6 +4185,7 @@ function startClipGesture(e, c, mode, collapseOnClick) {
|
|
|
4103
4185
|
keyframes: c.keyframes ? JSON.parse(JSON.stringify(c.keyframes)) : undefined,
|
|
4104
4186
|
masks: c.props.masks,
|
|
4105
4187
|
tracks: c.props.tracks,
|
|
4188
|
+
shapes: c.props.shapes,
|
|
4106
4189
|
};
|
|
4107
4190
|
// moving a clip that belongs to a multi-selection drags the whole group;
|
|
4108
4191
|
// AV-linked partners (video+audio from one file) always move together
|
|
@@ -4150,21 +4233,23 @@ function startClipGesture(e, c, mode, collapseOnClick) {
|
|
|
4150
4233
|
} else if (mode === "trim-l") {
|
|
4151
4234
|
let ns = snapTime(orig.start + dt, groupIds);
|
|
4152
4235
|
const sp = clipSpeed(c);
|
|
4153
|
-
const maxShiftLeft = (c
|
|
4236
|
+
const maxShiftLeft = FableCutEdit.hasSourceTime(c) ? orig.in / sp : 1e6;
|
|
4154
4237
|
ns = clamp(ns, Math.max(0, orig.start - maxShiftLeft), orig.start + orig.duration - MIN_DUR);
|
|
4155
4238
|
const d = ns - orig.start;
|
|
4156
4239
|
c.start = ns;
|
|
4157
|
-
c.in = (c
|
|
4240
|
+
c.in = FableCutEdit.hasSourceTime(c) ? orig.in + d * sp : 0;
|
|
4158
4241
|
c.duration = orig.duration - d;
|
|
4159
4242
|
c.keyframes = shiftKF(orig.keyframes, d, c.duration);
|
|
4160
4243
|
if (orig.masks) c.props.masks = Mask.shiftKeys(orig.masks, d);
|
|
4244
|
+
if (orig.shapes) c.props.shapes = Motion.shiftKeys(orig.shapes, d);
|
|
4161
4245
|
if (orig.tracks) { const tr = Tracker.shiftTracks(orig.tracks, d); if (tr.length) c.props.tracks = tr; else delete c.props.tracks; }
|
|
4162
4246
|
syncLinkedTiming(c);
|
|
4163
4247
|
} else { // trim-r
|
|
4164
4248
|
let ne = snapTime(orig.start + orig.duration + dt, groupIds);
|
|
4165
4249
|
let maxDur = 1e6;
|
|
4166
|
-
|
|
4167
|
-
|
|
4250
|
+
const srcLen = c.kind === "comp" ? (getComp(c.compId) || {}).duration : media?.duration;
|
|
4251
|
+
if (FableCutEdit.hasSourceTime(c) && srcLen)
|
|
4252
|
+
maxDur = Math.max(MIN_DUR, (srcLen - orig.in) / clipSpeed(c));
|
|
4168
4253
|
c.duration = clamp(ne - orig.start, MIN_DUR, maxDur);
|
|
4169
4254
|
c.keyframes = shiftKF(orig.keyframes, 0, c.duration);
|
|
4170
4255
|
syncLinkedTiming(c);
|
|
@@ -4623,7 +4708,7 @@ function updateWorkArea() {
|
|
|
4623
4708
|
|
|
4624
4709
|
/* ── Drag & drop: bin → timeline, files → window ── */
|
|
4625
4710
|
els.timelineScroll.addEventListener("dragover", (e) => {
|
|
4626
|
-
if (e.dataTransfer.types.includes(
|
|
4711
|
+
if (["text/fablecut-media", "text/fablecut-comp", "text/fablecut-template"].some((t) => e.dataTransfer.types.includes(t))) {
|
|
4627
4712
|
e.preventDefault();
|
|
4628
4713
|
e.dataTransfer.dropEffect = "copy";
|
|
4629
4714
|
for (const row of els.tracks.children)
|
|
@@ -4635,6 +4720,10 @@ els.timelineScroll.addEventListener("dragleave", () => {
|
|
|
4635
4720
|
});
|
|
4636
4721
|
els.timelineScroll.addEventListener("drop", (e) => {
|
|
4637
4722
|
for (const row of els.tracks.children) row.classList.remove("drop-hint");
|
|
4723
|
+
const cid = e.dataTransfer.getData("text/fablecut-comp");
|
|
4724
|
+
if (cid) { e.preventDefault(); const comp = getComp(cid); if (comp) addCompClip(comp, trackAtEvent(e), snapTime(timeAtEvent(e), null)); return; }
|
|
4725
|
+
const tid = e.dataTransfer.getData("text/fablecut-template");
|
|
4726
|
+
if (tid) { e.preventDefault(); const t = findTemplate(tid); if (t) addTemplateClip(t, trackAtEvent(e), snapTime(timeAtEvent(e), null)); return; }
|
|
4638
4727
|
const mid = e.dataTransfer.getData("text/fablecut-media");
|
|
4639
4728
|
if (!mid) return;
|
|
4640
4729
|
e.preventDefault();
|
|
@@ -4861,7 +4950,7 @@ function renderInspector(lite) {
|
|
|
4861
4950
|
let html = (state.selIds.size > 1
|
|
4862
4951
|
? `<div class="insp-multi">${state.selIds.size} clips selected — drag moves them together, Del deletes all. Fields below edit the primary (white-outlined) clip.</div>`
|
|
4863
4952
|
: "") + clipStatusBar(c) + `<div class="insp-section"><h3>Clip — ${c.kind}</h3>
|
|
4864
|
-
${row("Name", `<input type="text" data-k="name" value="${c.name
|
|
4953
|
+
${row("Name", `<input type="text" data-k="name" value="${escapeHtml(c.name || "")}">`)}
|
|
4865
4954
|
${c.mediaId ? row("Source", `<button type="button" class="btn tiny style-picker-btn" data-media-open title="Replace this clip's media — keeps position, trim, keyframes and effects">${escapeHtml((getMedia(c.mediaId) || {}).name || "Missing media")} ▾</button>`) : ""}
|
|
4866
4955
|
${row("Start (s)", `<input type="number" data-k="start" step="0.01" value="${c.start.toFixed(2)}">`)}
|
|
4867
4956
|
${row("Length (s)", `<input type="number" data-k="duration" step="0.01" value="${c.duration.toFixed(2)}">`)}
|
|
@@ -4882,6 +4971,16 @@ function renderInspector(lite) {
|
|
|
4882
4971
|
${slider("opacity", 0, 1, 0.01, p.opacity)}
|
|
4883
4972
|
${matteRows()}
|
|
4884
4973
|
</div>`;
|
|
4974
|
+
} else if (c.kind === "null") {
|
|
4975
|
+
html += `<div class="insp-section"><h3>Null object</h3>
|
|
4976
|
+
<p class="cl-note">A null never shows in the picture. Parent clips to it (their Parent field), then move, turn or scale the null to move them together.</p>
|
|
4977
|
+
${row("Position X", `<input type="number" data-k="x" value="${fmtInspNum(p.x)}">`, "x")}
|
|
4978
|
+
${row("Position Y", `<input type="number" data-k="y" value="${fmtInspNum(p.y)}">`, "y")}
|
|
4979
|
+
${slider("scale", 0.1, 4, 0.01, p.scale)}
|
|
4980
|
+
${slider("rotation", -180, 180, 1, p.rotation, "°")}
|
|
4981
|
+
${anchorRowsHtml(c, p, row)}
|
|
4982
|
+
${parentRowHtml(c)}
|
|
4983
|
+
</div>`;
|
|
4885
4984
|
} else if (c.kind !== "audio") {
|
|
4886
4985
|
html += `<div class="insp-section"><h3>Transform</h3>
|
|
4887
4986
|
${row("Position X", `<input type="number" data-k="x" value="${fmtInspNum(p.x)}">`, "x")}
|
|
@@ -4889,11 +4988,15 @@ function renderInspector(lite) {
|
|
|
4889
4988
|
${slider("scale", 0.1, 4, 0.01, p.scale)}
|
|
4890
4989
|
${slider("rotation", -180, 180, 1, p.rotation, "°")}
|
|
4891
4990
|
${slider("opacity", 0, 1, 0.01, p.opacity)}
|
|
4991
|
+
${anchorRowsHtml(c, p, row)}
|
|
4992
|
+
${parentRowHtml(c)}
|
|
4892
4993
|
${sel("Blend", "blend", BLEND_MODES, p.blend)}
|
|
4893
4994
|
${matteRows()}
|
|
4894
4995
|
</div>`;
|
|
4895
4996
|
}
|
|
4896
|
-
if (c.kind === "
|
|
4997
|
+
if (c.kind === "shape") html += shapeSectionHtml(c, p, row) + `<div class="insp-section"><h3>Look</h3>${slider("blur", 0, 40, 0.5, p.blur, "px")}${slider("shake", 0, 40, 0.5, p.shake, "px")}${slider("shakeSpeed", 1, 30, 0.5, p.shakeSpeed)}</div>`;
|
|
4998
|
+
if (c.kind === "comp") html += templateSectionHtml(c, row) + compSectionHtml(c, row);
|
|
4999
|
+
if (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "comp") {
|
|
4897
5000
|
html += `<div class="insp-section"><h3>Layout</h3>
|
|
4898
5001
|
${sel("Fit", "fit", ["contain", "cover", "stretch", "none"], p.fit)}
|
|
4899
5002
|
${row("Crop L/R %", `<input type="number" data-k="cropL" min="0" max="95" value="${p.cropL}" style="max-width:58px">
|
|
@@ -4905,7 +5008,7 @@ function renderInspector(lite) {
|
|
|
4905
5008
|
${check("Flip V", "flipV", p.flipV)}
|
|
4906
5009
|
</div>`;
|
|
4907
5010
|
}
|
|
4908
|
-
if (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust") {
|
|
5011
|
+
if (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust" || c.kind === "comp") {
|
|
4909
5012
|
html += `<div class="insp-section"><h3>Filter / Color</h3>
|
|
4910
5013
|
${sel("Preset", "filterPreset", Object.keys(FILTER_PRESETS), p.filterPreset)}
|
|
4911
5014
|
${slider("brightness", 0, 200, 1, p.brightness, "%")}
|
|
@@ -4981,7 +5084,9 @@ function renderInspector(lite) {
|
|
|
4981
5084
|
const curveSel = (label, k, tr) => tr?.type === "fade" && c.kind === "audio"
|
|
4982
5085
|
? row(label, `<select data-k="${k}" title="Fade shape (audio)">${[["", "Smooth (eased)"], ...Object.entries(AUDIO_FADE_CURVES)].map(([v, l]) =>
|
|
4983
5086
|
`<option value="${v}" ${(tr.curve || "") === v ? "selected" : ""}>${l}</option>`).join("")}</select>`) : "";
|
|
4984
|
-
if (c.kind
|
|
5087
|
+
if (c.kind === "text") html += textPathSectionHtml(c, p, row);
|
|
5088
|
+
if (isVisualClip(c)) html += motionSectionHtml(c, p, row);
|
|
5089
|
+
if (c.kind !== "audio" && c.kind !== "null") html += maskSectionHtml(c);
|
|
4985
5090
|
if (c.kind === "video") html += trackSectionHtml(c);
|
|
4986
5091
|
html += `<div class="insp-section"><h3>Transition</h3>
|
|
4987
5092
|
${tsel("In", "transIn", c.transitionIn)}
|
|
@@ -5241,6 +5346,13 @@ function renderInspector(lite) {
|
|
|
5241
5346
|
});
|
|
5242
5347
|
bindMaskControls(c);
|
|
5243
5348
|
bindTrackControls(c);
|
|
5349
|
+
bindParentAnchorControls(c);
|
|
5350
|
+
bindShapeControls(c);
|
|
5351
|
+
bindMotionControls(c);
|
|
5352
|
+
bindTextPathControls(c);
|
|
5353
|
+
bindCompControls(c);
|
|
5354
|
+
bindTemplateControls(c);
|
|
5355
|
+
bindPresetControls(c);
|
|
5244
5356
|
syncInspectorOffClip(c);
|
|
5245
5357
|
renderKfGraphsPanel();
|
|
5246
5358
|
}
|
|
@@ -5328,6 +5440,7 @@ function syncInspectorPlayhead() {
|
|
|
5328
5440
|
if (btn.textContent !== label) btn.textContent = label;
|
|
5329
5441
|
}
|
|
5330
5442
|
syncMaskInspector(c);
|
|
5443
|
+
syncShapeInspector(c);
|
|
5331
5444
|
syncInspectorOffClip(c); // after the class pass, so ◆ titles read the fresh "on" state
|
|
5332
5445
|
}
|
|
5333
5446
|
|
|
@@ -5339,6 +5452,7 @@ const KF_GRAPH_LABEL = {
|
|
|
5339
5452
|
invert: "Invert", temperature: "Temp", tint: "Tint", vignette: "Vignette",
|
|
5340
5453
|
cornerRadius: "Radius", shake: "Shake", rgbSplit: "RGB", grain: "Grain",
|
|
5341
5454
|
fontSize: "Size", letterSpacing: "Track", glow: "Glow",
|
|
5455
|
+
anchorX: "Anchor X", anchorY: "Anchor Y", trimStart: "Trim start", trimEnd: "Trim end", trimOffset: "Trim offset", pathOffset: "Path offset",
|
|
5342
5456
|
};
|
|
5343
5457
|
function toggleKfGraph(key) {
|
|
5344
5458
|
if (!ANIMATABLE.includes(key)) return;
|
|
@@ -5893,8 +6007,8 @@ function loadSourceFromMedia(m, opts = {}) {
|
|
|
5893
6007
|
}
|
|
5894
6008
|
function loadSourceFromClip(c, opts = {}) {
|
|
5895
6009
|
if (!c || !c.mediaId) {
|
|
5896
|
-
if (c && (c.kind === "text" || c.kind === "adjust"))
|
|
5897
|
-
toast("Titles and adjustment layers have no source media");
|
|
6010
|
+
if (c && (c.kind === "text" || c.kind === "adjust" || c.kind === "shape" || c.kind === "null" || c.kind === "comp"))
|
|
6011
|
+
toast("Titles, shapes, nulls and adjustment layers have no source media");
|
|
5898
6012
|
return;
|
|
5899
6013
|
}
|
|
5900
6014
|
const m = getMedia(c.mediaId);
|
|
@@ -8048,7 +8162,7 @@ function refreshAudioHold() {
|
|
|
8048
8162
|
for (const el of runtime.clipEls.values()) { if (!el.paused) el.pause(); }
|
|
8049
8163
|
for (const chain of runtime.clipGain.values()) muteClipChain(chain);
|
|
8050
8164
|
|
|
8051
|
-
for (const c of
|
|
8165
|
+
for (const c of mediaClips()) {
|
|
8052
8166
|
if (c.kind !== "audio" && c.kind !== "video") continue;
|
|
8053
8167
|
if (!clipRenders(c) || !activeAt(c, t)) continue;
|
|
8054
8168
|
const m = getMedia(c.mediaId);
|
|
@@ -8141,7 +8255,7 @@ function syncMedia() {
|
|
|
8141
8255
|
const reversing = state.playing && rate < 0;
|
|
8142
8256
|
// Timeline lookahead grows with preview rate so wall-clock budget stays ≈VIDEO_PREFETCH_SEC.
|
|
8143
8257
|
const prefetchTl = VIDEO_PREFETCH_SEC * Math.max(rate, 1);
|
|
8144
|
-
for (const c of
|
|
8258
|
+
for (const c of mediaClips()) {
|
|
8145
8259
|
if (c.kind === "text" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust") continue;
|
|
8146
8260
|
const el = getClipEl(c); if (!el) continue;
|
|
8147
8261
|
const enabled = clipRenders(c);
|
|
@@ -8184,7 +8298,7 @@ function syncMedia() {
|
|
|
8184
8298
|
function seekMediaWhilePaused() {
|
|
8185
8299
|
if (state.playing) return;
|
|
8186
8300
|
const t = state.time;
|
|
8187
|
-
for (const c of
|
|
8301
|
+
for (const c of mediaClips()) {
|
|
8188
8302
|
if (c.kind !== "video") continue;
|
|
8189
8303
|
if (!clipRenders(c)) continue;
|
|
8190
8304
|
if (!activeAt(c, t)) continue;
|
|
@@ -8226,6 +8340,7 @@ function evalProps(c, t) {
|
|
|
8226
8340
|
}
|
|
8227
8341
|
if (typeof v === "number" && !isNaN(v)) p[k] = v;
|
|
8228
8342
|
}
|
|
8343
|
+
applyMotionPath(c, p, local, true);
|
|
8229
8344
|
}
|
|
8230
8345
|
applyFilterPreset(p);
|
|
8231
8346
|
const W = els.preview.width, H = els.preview.height;
|
|
@@ -8522,7 +8637,7 @@ function drawAdjust(c, W, H, t) {
|
|
|
8522
8637
|
if (adjScratch.height !== H) adjScratch.height = H;
|
|
8523
8638
|
const a = adjScratch.getContext("2d");
|
|
8524
8639
|
a.clearRect(0, 0, W, H);
|
|
8525
|
-
a.drawImage(els.preview, 0, 0);
|
|
8640
|
+
a.drawImage(runtime.adjSource || els.preview, 0, 0); // inside a comp: the comp's own layers below
|
|
8526
8641
|
ctx2d.save();
|
|
8527
8642
|
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
8528
8643
|
if (p.shake > 0) { // whole-frame impact shake
|
|
@@ -8858,28 +8973,541 @@ function drawFrame(t = state.time) {
|
|
|
8858
8973
|
if (!state.exporting && !runtime.sampling) drawMaskView(W, H);
|
|
8859
8974
|
if (!runtime.sampling) updateScopes(); // before the handles, so they never reach the scopes
|
|
8860
8975
|
// on-canvas selection handles (never during export or playback)
|
|
8861
|
-
if (!state.exporting && !state.playing && !runtime.sampling) drawSelectionOverlay(W, H, t);
|
|
8976
|
+
if (!state.exporting && !state.playing && !runtime.sampling) { drawNullGuides(clips, W, H, t); drawSelectionOverlay(W, H, t); }
|
|
8977
|
+
}
|
|
8978
|
+
/** Nulls never render; the monitor shows where they are (never in export or the scopes). */
|
|
8979
|
+
function drawNullGuides(clips, W, H, t) {
|
|
8980
|
+
for (const c of clips) {
|
|
8981
|
+
if (c.kind !== "null" || c.id === state.selId) continue;
|
|
8982
|
+
drawNullMark(clipBounds(c, evalProps(c, t), W, H), W, 0.45);
|
|
8983
|
+
}
|
|
8984
|
+
}
|
|
8985
|
+
function drawNullMark(b, W, alpha) {
|
|
8986
|
+
const lw = Math.max(1.5, W / 800);
|
|
8987
|
+
ctx2d.save();
|
|
8988
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
8989
|
+
ctx2d.globalAlpha = alpha; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
|
|
8990
|
+
ctx2d.translate(b.cx, b.cy); ctx2d.rotate(b.rot);
|
|
8991
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#ff6b6b";
|
|
8992
|
+
ctx2d.strokeRect(-b.hw, -b.hh, b.hw * 2, b.hh * 2);
|
|
8993
|
+
ctx2d.beginPath(); ctx2d.moveTo(-b.hw * 0.4, 0); ctx2d.lineTo(b.hw * 0.4, 0); ctx2d.moveTo(0, -b.hh * 0.4); ctx2d.lineTo(0, b.hh * 0.4); ctx2d.stroke();
|
|
8994
|
+
ctx2d.restore();
|
|
8995
|
+
}
|
|
8996
|
+
/** Where the clip's anchor point is on screen (its x / y in its parents' space). */
|
|
8997
|
+
function anchorScreenPt(c, p, W, H, t = state.time) {
|
|
8998
|
+
const q = parentScreenMatrix(c, W, H, t).transformPoint(new DOMPoint(+p.x || 0, +p.y || 0));
|
|
8999
|
+
return { x: q.x, y: q.y };
|
|
8862
9000
|
}
|
|
8863
9001
|
|
|
8864
9002
|
/* ═══════════ Direct manipulation on the program monitor ═══════════
|
|
8865
9003
|
Drag a clip to move it, corner handles to resize (scale), the top
|
|
8866
9004
|
handle to rotate. Maps gestures straight onto props.x/y/scale/rotation. */
|
|
8867
|
-
function clipBounds(c, p, W, H) {
|
|
8868
|
-
const
|
|
8869
|
-
const
|
|
8870
|
-
|
|
9005
|
+
function clipBounds(c, p, W, H, t = state.time) {
|
|
9006
|
+
const box = layerBox(c, p, W, H, t), m = layerScreenMatrix(c, p, W, H, t);
|
|
9007
|
+
const o = m.transformPoint(new DOMPoint(box.ox, box.oy)), k = Math.hypot(m.a, m.b) || 1;
|
|
9008
|
+
return { cx: o.x, cy: o.y, hw: box.hw * k, hh: box.hh * k, rot: Math.atan2(m.b, m.a) };
|
|
9009
|
+
}
|
|
9010
|
+
/** The picture's box in its drawn space (content scale applied): centre offset and half size. */
|
|
9011
|
+
const NULL_HALF = 40;
|
|
9012
|
+
function layerBox(c, p, W, H, t) {
|
|
9013
|
+
const sc = +p.scale || 1;
|
|
8871
9014
|
if (c.kind === "text") {
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
9015
|
+
const tb = p.textPath ? textPathBox(c, p, t) : null;
|
|
9016
|
+
if (tb) return tb;
|
|
9017
|
+
if (hasTextBox(p)) return { ox: 0, oy: 0, hw: +p.boxW / 2, hh: +p.boxH / 2 };
|
|
9018
|
+
const half = measureTextHalfSize(p);
|
|
9019
|
+
return { ox: 0, oy: 0, hw: half.hw, hh: half.hh };
|
|
9020
|
+
}
|
|
9021
|
+
if (c.kind === "null") return { ox: 0, oy: 0, hw: NULL_HALF * sc, hh: NULL_HALF * sc };
|
|
9022
|
+
if (c.kind === "shape") {
|
|
9023
|
+
const b = Motion.bounds(shapeItemsAt(c, t));
|
|
9024
|
+
if (!b) return { ox: 0, oy: 0, hw: NULL_HALF * sc, hh: NULL_HALF * sc };
|
|
9025
|
+
return { ox: (b.x0 + b.x1) / 2 * sc, oy: (b.y0 + b.y1) / 2 * sc, hw: Math.max(4, (b.x1 - b.x0) / 2) * sc, hh: Math.max(4, (b.y1 - b.y0) / 2) * sc };
|
|
9026
|
+
}
|
|
9027
|
+
if (c.kind === "comp") { // a comp clip's box is the comp's own size (fit none) or fitted like a picture
|
|
9028
|
+
const comp = getComp(c.compId);
|
|
9029
|
+
if (comp) {
|
|
9030
|
+
const cw = comp.width, ch = comp.height;
|
|
9031
|
+
const f = p.fit === "none" ? 1 : p.fit === "cover" ? Math.max(W / cw, H / ch) : p.fit === "stretch" ? null : Math.min(W / cw, H / ch);
|
|
9032
|
+
return f == null ? { ox: 0, oy: 0, hw: (W / 2) * sc, hh: (H / 2) * sc } : { ox: 0, oy: 0, hw: cw * f / 2 * sc, hh: ch * f / 2 * sc };
|
|
8877
9033
|
}
|
|
8878
|
-
} else { // media/svg: canvas-sized base box, scaled
|
|
8879
|
-
hw = (W / 2) * sc; hh = (H / 2) * sc;
|
|
8880
9034
|
}
|
|
8881
|
-
return {
|
|
9035
|
+
return { ox: 0, oy: 0, hw: (W / 2) * sc, hh: (H / 2) * sc }; // media/svg: canvas-sized base box, scaled
|
|
9036
|
+
}
|
|
9037
|
+
/* ── Layer transforms (motion.js): the anchor point and parenting ──
|
|
9038
|
+
A clip draws at canvas centre · its parents · translate(x, y) · rotate ·
|
|
9039
|
+
(its picture at its scale, shifted so the anchor point sits at x / y). */
|
|
9040
|
+
const Motion = self.FableCutMotion;
|
|
9041
|
+
function parentM(c, t) { return c.parent ? Motion.parentMatrix(c, anyClip, t) : null; }
|
|
9042
|
+
/** Canvas centre and the parents: the space a clip's x / y live in. */
|
|
9043
|
+
function parentScreenMatrix(c, W, H, t) {
|
|
9044
|
+
const m = new DOMMatrix().translate(W / 2, H / 2), pm = parentM(c, t);
|
|
9045
|
+
return pm ? m.multiply(new DOMMatrix(pm)) : m;
|
|
9046
|
+
}
|
|
9047
|
+
/** Canvas px of the clip's drawn space, before its content scale (shake left out). */
|
|
9048
|
+
function layerScreenMatrix(c, p, W, H, t) {
|
|
9049
|
+
const as = Motion.contentScale(c, p);
|
|
9050
|
+
return parentScreenMatrix(c, W, H, t).translate(+p.x || 0, +p.y || 0).rotate(+p.rotation || 0)
|
|
9051
|
+
.translate(-(+p.anchorX || 0) * as, -(+p.anchorY || 0) * as);
|
|
9052
|
+
}
|
|
9053
|
+
/** How much the clip's parents scale it (1 without a parent). */
|
|
9054
|
+
function parentScaleAt(c, t = state.time) { const m = parentScreenMatrix(c, 0, 0, t); return Math.hypot(m.a, m.b) || 1; }
|
|
9055
|
+
/** The x / y that put the centre of the clip's (unshifted) picture at screen (sx, sy). */
|
|
9056
|
+
function posForScreenCentre(c, p, sx, sy, W, H, t = state.time) {
|
|
9057
|
+
const q = parentScreenMatrix(c, W, H, t).inverse().transformPoint(new DOMPoint(sx, sy));
|
|
9058
|
+
const th = (+p.rotation || 0) * Math.PI / 180, as = Motion.contentScale(c, p), ax = (+p.anchorX || 0) * as, ay = (+p.anchorY || 0) * as;
|
|
9059
|
+
return { x: q.x + ax * Math.cos(th) - ay * Math.sin(th), y: q.y + ax * Math.sin(th) + ay * Math.cos(th) };
|
|
9060
|
+
}
|
|
9061
|
+
/** A screen-space drag delta in the clip's x / y space (its parents undone). */
|
|
9062
|
+
function screenDeltaToParent(c, dx, dy, t = state.time) {
|
|
9063
|
+
const m = parentScreenMatrix(c, els.preview.width, els.preview.height, t), det = m.a * m.d - m.b * m.c || 1;
|
|
9064
|
+
return { x: (m.d * dx - m.c * dy) / det, y: (-m.b * dx + m.a * dy) / det };
|
|
9065
|
+
}
|
|
9066
|
+
/* ═══════════ COMPOSITIONS (comp.js) ═══════════
|
|
9067
|
+
project.comps holds nested sequences; a "comp" clip places one. The media
|
|
9068
|
+
inside comps is flattened into virtual clips (mediaClips) so playback,
|
|
9069
|
+
export seeking and the audio mix treat it like timeline media; the comp
|
|
9070
|
+
clip draws its comp's frame like a picture (renderComp). Opening a comp
|
|
9071
|
+
loads it as a stand-in project, so the whole timeline edits it; saves write
|
|
9072
|
+
it back into the root document. */
|
|
9073
|
+
const CompLib = self.FableCutComp;
|
|
9074
|
+
function compCtxId() { const x = state.compCtx; return x ? x.path[x.path.length - 1] : null; }
|
|
9075
|
+
function getComp(id) { return CompLib.getComp(project, id); }
|
|
9076
|
+
let flatMemo = null;
|
|
9077
|
+
/** Timeline clips plus the clips every comp clip brings in (virtual, at timeline times). */
|
|
9078
|
+
function mediaClips() {
|
|
9079
|
+
const now = performance.now();
|
|
9080
|
+
if (flatMemo && flatMemo.clips === project.clips && flatMemo.comps === project.comps && flatMemo.gen === runtime.compGen && now - flatMemo.at < 4) return flatMemo.list;
|
|
9081
|
+
const virt = project.comps && project.comps.length && project.clips.some((c) => c.kind === "comp")
|
|
9082
|
+
? CompLib.flatten(project, project.clips, { inComp: compCtxId(), renders: (c) => clipRenders(c) }) : [];
|
|
9083
|
+
const list = virt.length ? project.clips.concat(virt) : project.clips;
|
|
9084
|
+
flatMemo = { at: now, clips: project.clips, comps: project.comps, gen: runtime.compGen, list, virt, byId: new Map(virt.map((v) => [v.id, v])) };
|
|
9085
|
+
return list;
|
|
9086
|
+
}
|
|
9087
|
+
function virtualClip(id) { mediaClips(); return (flatMemo && flatMemo.byId.get(id)) || null; }
|
|
9088
|
+
/** The clips a comp clip (or virtual comp clip) shows directly. */
|
|
9089
|
+
function compChildren(c) { mediaClips(); return flatMemo ? flatMemo.virt.filter((v) => v._comp === c.id) : []; }
|
|
9090
|
+
/** The comp's frame at timeline time t, as a canvas of the comp's size (null when it has none). */
|
|
9091
|
+
function renderComp(c, t) {
|
|
9092
|
+
const comp = getComp(c.compId);
|
|
9093
|
+
const depth = (runtime.compDepth || 0) + 1;
|
|
9094
|
+
if (!comp || depth > CompLib.MAX_DEPTH) return null;
|
|
9095
|
+
const W2 = comp.width, H2 = comp.height;
|
|
9096
|
+
const prev = { ctx: ctx2d, scope: runtime.scope, depth: runtime.compDepth, adj: runtime.adjSource };
|
|
9097
|
+
runtime.compDepth = depth; // every scratch buffer below gets this depth's own copy
|
|
9098
|
+
const cv = maskCtx("comp", W2, H2);
|
|
9099
|
+
cv.setTransform(1, 0, 0, 1, 0, 0); cv.globalAlpha = 1; cv.globalCompositeOperation = "source-over"; cv.filter = "none";
|
|
9100
|
+
cv.clearRect(0, 0, W2, H2);
|
|
9101
|
+
if (comp.background) { cv.fillStyle = comp.background; cv.fillRect(0, 0, W2, H2); }
|
|
9102
|
+
const kids = compChildren(c), order = CompLib.drawOrder(comp).video;
|
|
9103
|
+
const on = kids.filter((v) => v._on && v.kind !== "audio" && activeAt(v, t))
|
|
9104
|
+
.sort((a, b) => (order.indexOf(a.track) - order.indexOf(b.track)) || (a.start - b.start));
|
|
9105
|
+
runtime.scope = { clips: kids, videoTracks: order };
|
|
9106
|
+
ctx2d = cv; runtime.adjSource = cv.canvas;
|
|
9107
|
+
try {
|
|
9108
|
+
const mattes = matteTracksAt(on);
|
|
9109
|
+
for (const v of on) if (!mattes.has(v.track)) drawClip(v, W2, H2, t);
|
|
9110
|
+
} finally { ctx2d = prev.ctx; runtime.scope = prev.scope; runtime.compDepth = prev.depth; runtime.adjSource = prev.adj; }
|
|
9111
|
+
return cv.canvas;
|
|
9112
|
+
}
|
|
9113
|
+
const anyClip = (id) => getClip(id) || virtualClip(id);
|
|
9114
|
+
|
|
9115
|
+
/* ── Opening a comp: it becomes the timeline until you go back ── */
|
|
9116
|
+
function compSyntheticDoc(root, path) {
|
|
9117
|
+
const comp = CompLib.getComp(root, path[path.length - 1]);
|
|
9118
|
+
if (!comp) return null;
|
|
9119
|
+
const names = path.map((id) => (CompLib.getComp(root, id) || { name: id }).name);
|
|
9120
|
+
return {
|
|
9121
|
+
...root, name: `${root.name} › ${names.join(" › ")}`,
|
|
9122
|
+
width: comp.width, height: comp.height, background: comp.background || "#000000",
|
|
9123
|
+
clips: comp.clips, tracks: CompLib.compTracks(comp), markers: comp.markers || [], inPoint: null, outPoint: null,
|
|
9124
|
+
disabledTracks: comp.disabledTracks || [], lockedTracks: [], untargetedTracks: [], exportFrame: null,
|
|
9125
|
+
};
|
|
8882
9126
|
}
|
|
9127
|
+
/** Write the open comp's clips and tracks back into the root document (and return it). */
|
|
9128
|
+
function commitCompCtx() {
|
|
9129
|
+
const x = state.compCtx;
|
|
9130
|
+
if (!x) return null;
|
|
9131
|
+
const cur = projectJSONHere();
|
|
9132
|
+
const id = compCtxId();
|
|
9133
|
+
const comps = (cur.comps || []).map((c) => (c.id === id
|
|
9134
|
+
? { ...c, clips: cur.clips, tracks: cur.tracks, disabledTracks: cur.disabledTracks && cur.disabledTracks.length ? cur.disabledTracks : undefined, markers: cur.markers && cur.markers.length ? cur.markers : undefined, background: c.background }
|
|
9135
|
+
: c));
|
|
9136
|
+
x.root = { ...x.root, media: cur.media, folders: cur.folders, fps: cur.fps, master: cur.master, buses: cur.buses, revision: cur.revision, comps };
|
|
9137
|
+
return x.root;
|
|
9138
|
+
}
|
|
9139
|
+
function enterComp(compId, viaClip) {
|
|
9140
|
+
if (state.exporting) return;
|
|
9141
|
+
if (state.playing) pause();
|
|
9142
|
+
const comp = getComp(compId);
|
|
9143
|
+
if (!comp) { toast("That composition is missing"); return; }
|
|
9144
|
+
const here = compCtxId();
|
|
9145
|
+
if (here && CompLib.wouldCycle(state.compCtx.root, here, compId) && here === compId) return;
|
|
9146
|
+
const root = state.compCtx ? commitCompCtx() : projectJSONHere();
|
|
9147
|
+
const t = viaClip && activeAt(viaClip, state.time) ? state.time - viaClip.start + (viaClip.in || 0) : 0;
|
|
9148
|
+
const frame = { undo: runtime.undo.slice(), redo: runtime.redo.slice(), time: state.time, sel: state.selId };
|
|
9149
|
+
state.compCtx = { root, path: [...(state.compCtx ? state.compCtx.path : []), compId], frames: [...(state.compCtx ? state.compCtx.frames : []), frame] };
|
|
9150
|
+
runtime.undo.length = 0; runtime.redo.length = 0;
|
|
9151
|
+
applyProject(compSyntheticDoc(root, state.compCtx.path), { ctx: true });
|
|
9152
|
+
setSelection([]);
|
|
9153
|
+
setTime(clamp(t, 0, comp.duration));
|
|
9154
|
+
syncCompCrumb();
|
|
9155
|
+
}
|
|
9156
|
+
function exitComp(levels = 1) {
|
|
9157
|
+
const x = state.compCtx;
|
|
9158
|
+
if (!x) return;
|
|
9159
|
+
const root = commitCompCtx();
|
|
9160
|
+
let frame = null;
|
|
9161
|
+
for (let i = 0; i < levels && x.path.length; i++) { x.path.pop(); frame = x.frames.pop(); }
|
|
9162
|
+
if (!x.path.length) { state.compCtx = null; applyProject(root, { ctx: true }); }
|
|
9163
|
+
else applyProject(compSyntheticDoc(root, x.path), { ctx: true });
|
|
9164
|
+
if (frame) {
|
|
9165
|
+
runtime.undo.splice(0, runtime.undo.length, ...frame.undo); runtime.redo.splice(0, runtime.redo.length, ...frame.redo);
|
|
9166
|
+
setSelection(frame.sel && getClip(frame.sel) ? [frame.sel] : []);
|
|
9167
|
+
setTime(frame.time);
|
|
9168
|
+
}
|
|
9169
|
+
syncCompCrumb();
|
|
9170
|
+
scheduleSave();
|
|
9171
|
+
}
|
|
9172
|
+
function syncCompCrumb() {
|
|
9173
|
+
if (els.projectName) els.projectName.textContent = project.name + (state.connected ? " · 🟢 connected" : " · ⚪ local session");
|
|
9174
|
+
const el = document.getElementById("compCrumb");
|
|
9175
|
+
if (!el) return;
|
|
9176
|
+
const x = state.compCtx;
|
|
9177
|
+
el.classList.toggle("hidden", !x);
|
|
9178
|
+
if (!x) { el.innerHTML = ""; return; }
|
|
9179
|
+
const names = x.path.map((id) => (CompLib.getComp(x.root, id) || { name: id }).name);
|
|
9180
|
+
el.innerHTML = `<button type="button" class="btn tiny" data-crumb="${x.path.length}" title="Back to the main timeline">◀ Main</button>` +
|
|
9181
|
+
names.map((n, i) => `<span class="crumb-sep">›</span>` + (i < names.length - 1
|
|
9182
|
+
? `<button type="button" class="btn tiny" data-crumb="${x.path.length - 1 - i}">${escapeHtml(n)}</button>`
|
|
9183
|
+
: `<b title="You are editing this composition — Esc or ◀ Main goes back">${escapeHtml(n)}</b>`)).join("");
|
|
9184
|
+
for (const b of el.querySelectorAll("[data-crumb]")) b.addEventListener("click", () => exitComp(+b.dataset.crumb));
|
|
9185
|
+
}
|
|
9186
|
+
|
|
9187
|
+
/* ── Making comps ── */
|
|
9188
|
+
function newCompId() { let id; do id = "comp_" + uid(); while (getComp(id)); return id; }
|
|
9189
|
+
/** Precompose the selection: the clips move into a new comp, a comp clip takes their place. */
|
|
9190
|
+
function precomposeSelection() {
|
|
9191
|
+
const sel = withLinked(selectedClips());
|
|
9192
|
+
if (!sel.length) { toast("Select the clips to put in a composition"); return; }
|
|
9193
|
+
if (sel.some(isClipLocked)) { toastLocked(); return; }
|
|
9194
|
+
pushUndo();
|
|
9195
|
+
const doc = { clips: project.clips, comps: project.comps || [], tracks: project.tracks, width: project.width, height: project.height, disabledTracks: project.disabledTracks || [] };
|
|
9196
|
+
const { comp, clip } = CompLib.precompose(doc, sel.map((c) => c.id), { id: newCompId(), name: "Comp " + ((project.comps || []).length + 1) });
|
|
9197
|
+
project.clips = doc.clips; project.comps = doc.comps;
|
|
9198
|
+
clip.id = "c_" + uid(); clip.props = { ...DEFAULT_PROPS, fit: "none" };
|
|
9199
|
+
for (const c of comp.clips) c.props = { ...DEFAULT_PROPS, ...(c.props || {}) };
|
|
9200
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
9201
|
+
setSelection([clip.id]);
|
|
9202
|
+
state.dirtyTimeline = true;
|
|
9203
|
+
scheduleSave(); renderBin(); renderInspector();
|
|
9204
|
+
toast(`${sel.length} clip${sel.length > 1 ? "s" : ""} now live in "${comp.name}" — double-click it to open`);
|
|
9205
|
+
}
|
|
9206
|
+
function addNewComp() {
|
|
9207
|
+
project.comps = project.comps || [];
|
|
9208
|
+
pushUndo();
|
|
9209
|
+
const comp = { id: newCompId(), name: "Comp " + (project.comps.length + 1), width: project.width, height: project.height, duration: 5, clips: [], tracks: CompLib.DEFAULT_TRACKS.map((t) => ({ ...t })) };
|
|
9210
|
+
project.comps.push(comp);
|
|
9211
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
9212
|
+
scheduleSave(); renderBin();
|
|
9213
|
+
return comp;
|
|
9214
|
+
}
|
|
9215
|
+
function addCompClip(comp, trackId, at) {
|
|
9216
|
+
const track = trackId && TRACKS.some((t) => t.id === trackId && t.kind === "video") ? trackId : "V1";
|
|
9217
|
+
if (isTrackLocked(track)) { toast(`${track} is locked`); return; }
|
|
9218
|
+
const here = compCtxId();
|
|
9219
|
+
if (here && CompLib.wouldCycle(state.compCtx ? state.compCtx.root : project, here, comp.id)) { toast("A composition can't contain itself"); return; }
|
|
9220
|
+
pushUndo();
|
|
9221
|
+
const c = { id: "c_" + uid(), mediaId: null, kind: "comp", compId: comp.id, track, start: Math.max(0, at), in: 0, duration: comp.duration, name: comp.name, props: { ...DEFAULT_PROPS, fit: "none" } };
|
|
9222
|
+
project.clips.push(c);
|
|
9223
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
9224
|
+
selectClip(c.id); scheduleSave(); renderInspector();
|
|
9225
|
+
}
|
|
9226
|
+
/** The Project tab's compositions: drag one onto the timeline, double-click to open it. */
|
|
9227
|
+
function makeCompItem(comp) {
|
|
9228
|
+
const item = document.createElement("div");
|
|
9229
|
+
item.className = "bin-item comp-item";
|
|
9230
|
+
item.draggable = true;
|
|
9231
|
+
const used = CompLib.usages(state.compCtx ? state.compCtx.root : project, comp.id).length;
|
|
9232
|
+
item.innerHTML = `<div class="bin-thumb comp-thumb">▣</div>
|
|
9233
|
+
<div class="bin-meta"><div class="bin-name"></div><div class="bin-sub">Composition · ${comp.width}×${comp.height} · ${comp.duration}s${used ? ` · used ${used}×` : ""}</div></div>
|
|
9234
|
+
<button class="btn tiny accent lib-add" title="Add at the playhead">+</button>`;
|
|
9235
|
+
item.querySelector(".bin-name").textContent = comp.name;
|
|
9236
|
+
item.title = "Drag onto the timeline · double-click to open and edit";
|
|
9237
|
+
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/fablecut-comp", comp.id); e.dataTransfer.effectAllowed = "copy"; });
|
|
9238
|
+
item.addEventListener("dblclick", () => enterComp(comp.id));
|
|
9239
|
+
item.querySelector(".lib-add").addEventListener("click", (e) => { e.stopPropagation(); addCompClip(comp, "V1", state.time); });
|
|
9240
|
+
item.addEventListener("contextmenu", (e) => { e.preventDefault(); showCompMenu(e.clientX, e.clientY, comp); });
|
|
9241
|
+
return item;
|
|
9242
|
+
}
|
|
9243
|
+
function showCompMenu(x, y, comp) {
|
|
9244
|
+
hideTrackCtxMenu();
|
|
9245
|
+
const menu = document.createElement("div");
|
|
9246
|
+
menu.className = "ctx-menu"; menu.id = "trackCtxMenu";
|
|
9247
|
+
const item = (label, run, why) => {
|
|
9248
|
+
const b = document.createElement("button");
|
|
9249
|
+
b.type = "button"; b.className = "ctx-item"; b.innerHTML = `<span>${escapeHtml(label)}</span>`;
|
|
9250
|
+
if (why) { b.disabled = true; b.title = why; } else b.addEventListener("click", () => { hideTrackCtxMenu(); run(); });
|
|
9251
|
+
menu.appendChild(b);
|
|
9252
|
+
};
|
|
9253
|
+
item("Open", () => enterComp(comp.id));
|
|
9254
|
+
item("Duplicate", () => {
|
|
9255
|
+
pushUndo();
|
|
9256
|
+
const copy = JSON.parse(JSON.stringify(comp)); copy.id = newCompId(); copy.name = `${comp.name} copy`.slice(0, 80);
|
|
9257
|
+
project.comps.push(copy); runtime.compGen = (runtime.compGen || 0) + 1; scheduleSave(); renderBin();
|
|
9258
|
+
});
|
|
9259
|
+
// an open comp's live clips are on the timeline, not yet in its entry
|
|
9260
|
+
item("Save as template", () => saveCompAsTemplate(comp.id === compCtxId() ? { ...comp, clips: project.clips, tracks: project.tracks } : comp));
|
|
9261
|
+
const used = CompLib.usages(state.compCtx ? state.compCtx.root : project, comp.id).length;
|
|
9262
|
+
item("Delete", () => {
|
|
9263
|
+
pushUndo();
|
|
9264
|
+
project.comps = project.comps.filter((c) => c.id !== comp.id);
|
|
9265
|
+
runtime.compGen = (runtime.compGen || 0) + 1; scheduleSave(); renderBin();
|
|
9266
|
+
}, used ? `Used ${used}× — remove its clips first` : state.compCtx && state.compCtx.path.includes(comp.id) ? "It is open" : null);
|
|
9267
|
+
document.body.appendChild(menu);
|
|
9268
|
+
trackCtxMenu = menu;
|
|
9269
|
+
menu.style.left = Math.max(6, Math.min(x, innerWidth - menu.offsetWidth - 6)) + "px";
|
|
9270
|
+
menu.style.top = Math.max(6, Math.min(y, innerHeight - menu.offsetHeight - 6)) + "px";
|
|
9271
|
+
}
|
|
9272
|
+
function compSectionHtml(c, row) {
|
|
9273
|
+
const comp = getComp(c.compId);
|
|
9274
|
+
if (!comp) return `<div class="insp-section"><h3>Composition</h3><p class="cl-note">Its composition (${escapeHtml(c.compId || "?")}) is missing.</p></div>`;
|
|
9275
|
+
return `<div class="insp-section" data-comp-section><h3>Composition</h3>
|
|
9276
|
+
${row("Comp", `<span class="insp-ctrls"><input type="text" data-comp-name value="${escapeHtml(comp.name)}" maxlength="80" title="The composition's name (every clip using it shows it)"> <button type="button" class="btn tiny" data-comp-open title="Open it on the timeline (or double-click the clip)">Open</button></span>`)}
|
|
9277
|
+
${row("Size", `<span class="insp-ctrls"><input type="number" data-comp-w min="16" max="8192" value="${comp.width}" style="max-width:72px"> × <input type="number" data-comp-h min="16" max="8192" value="${comp.height}" style="max-width:72px"></span>`)}
|
|
9278
|
+
${row("Length (s)", `<input type="number" data-comp-dur min="0.05" step="0.01" value="${comp.duration}">`)}
|
|
9279
|
+
${row("Background", `<span class="insp-ctrls"><input type="color" data-comp-bg value="${comp.background || "#000000"}"><button type="button" class="btn tiny${comp.background ? "" : " toggle on"}" data-comp-bgoff title="Transparent: what is under the comp clip shows through">None</button></span>`)}
|
|
9280
|
+
<p class="cl-note">The clip shows the comp from its In point (${(+c.in || 0).toFixed(2)} s). Every clip using this comp changes when you edit it.</p>
|
|
9281
|
+
</div>`;
|
|
9282
|
+
}
|
|
9283
|
+
function bindCompControls(c) {
|
|
9284
|
+
const root = els.inspector.querySelector("[data-comp-section]");
|
|
9285
|
+
const comp = root && getComp(c.compId);
|
|
9286
|
+
if (!comp) return;
|
|
9287
|
+
const edit = (fn) => { pushUndo(); fn(); runtime.compGen = (runtime.compGen || 0) + 1; state.dirtyTimeline = true; scheduleSave(); renderBin(); renderInspector(); };
|
|
9288
|
+
root.querySelector("[data-comp-open]")?.addEventListener("click", () => enterComp(comp.id, c));
|
|
9289
|
+
root.querySelector("[data-comp-name]")?.addEventListener("change", (e) => { const n = e.target.value.trim(); if (n) edit(() => { comp.name = n.slice(0, 80); }); });
|
|
9290
|
+
root.querySelector("[data-comp-w]")?.addEventListener("change", (e) => { const v = Math.round(+e.target.value); if (v >= 16 && v <= 8192) edit(() => { comp.width = v; }); });
|
|
9291
|
+
root.querySelector("[data-comp-h]")?.addEventListener("change", (e) => { const v = Math.round(+e.target.value); if (v >= 16 && v <= 8192) edit(() => { comp.height = v; }); });
|
|
9292
|
+
root.querySelector("[data-comp-dur]")?.addEventListener("change", (e) => { const v = +e.target.value; if (v > 0.05) edit(() => { comp.duration = +v.toFixed(3); }); });
|
|
9293
|
+
root.querySelector("[data-comp-bg]")?.addEventListener("change", (e) => edit(() => { comp.background = e.target.value; }));
|
|
9294
|
+
root.querySelector("[data-comp-bgoff]")?.addEventListener("click", () => edit(() => { delete comp.background; }));
|
|
9295
|
+
}
|
|
9296
|
+
|
|
9297
|
+
/* ── Animation presets and templates (the data and the maths live in templates.js) ── */
|
|
9298
|
+
const Tpl = self.FableCutTemplates;
|
|
9299
|
+
const savedLib = { presets: [], templates: [] }; // the user's own, from library/presets and library/templates
|
|
9300
|
+
async function fetchSaved(kind) {
|
|
9301
|
+
try { const r = await fetch("/api/" + kind, { cache: "no-store" }); savedLib[kind] = r.ok ? await r.json() : []; }
|
|
9302
|
+
catch { savedLib[kind] = []; }
|
|
9303
|
+
return savedLib[kind];
|
|
9304
|
+
}
|
|
9305
|
+
const findTemplate = (id) => Tpl.findTemplate(id, savedLib.templates);
|
|
9306
|
+
const TEMPLATE_ICONS = {
|
|
9307
|
+
"Lower thirds": '<rect x="2" y="13" width="14" height="5" rx="1" fill="#ffd166"/><rect x="2" y="13" width="2" height="5" fill="#fff"/><rect x="6" y="15" width="7" height="1.4" rx=".7" fill="#111"/>',
|
|
9308
|
+
"Logo animations": '<circle cx="12" cy="11" r="7" fill="none" stroke="#7b6cff" stroke-width="2.2"/><rect x="9" y="8" width="6" height="6" rx="1.5" fill="#ff7ac8"/>',
|
|
9309
|
+
Social: '<rect x="7" y="2.5" width="10" height="19" rx="2" fill="none" stroke="#22d3ee" stroke-width="2"/><rect x="9" y="8" width="6" height="2" rx="1" fill="#fff"/><rect x="9" y="12" width="4" height="1.5" rx=".7" fill="#fff"/>',
|
|
9310
|
+
Saved: '<path d="M6 3h12v18l-6-4-6 4z" fill="#ffd166"/>',
|
|
9311
|
+
};
|
|
9312
|
+
/* The bin's Templates tab: lower thirds, logo animations and social layouts, then the user's own. */
|
|
9313
|
+
function renderTemplatesTab() {
|
|
9314
|
+
els.libList.innerHTML = "";
|
|
9315
|
+
let group = null;
|
|
9316
|
+
for (const t of [...Tpl.TEMPLATES, ...savedLib.templates]) {
|
|
9317
|
+
if (t.group !== group) {
|
|
9318
|
+
group = t.group;
|
|
9319
|
+
const h = document.createElement("div");
|
|
9320
|
+
h.className = "lib-group";
|
|
9321
|
+
h.textContent = group;
|
|
9322
|
+
els.libList.appendChild(h);
|
|
9323
|
+
}
|
|
9324
|
+
const item = document.createElement("div");
|
|
9325
|
+
item.className = "bin-item lib-item";
|
|
9326
|
+
item.draggable = true;
|
|
9327
|
+
item.title = "Drag onto the timeline or click + — then edit its fields in the Inspector";
|
|
9328
|
+
item.innerHTML = `<div class="bin-thumb shape-thumb"><svg viewBox="0 0 24 24" aria-hidden="true">${TEMPLATE_ICONS[t.group] || TEMPLATE_ICONS.Saved}</svg></div>
|
|
9329
|
+
<div class="bin-meta"><div class="bin-name"></div><div class="bin-sub"></div></div>
|
|
9330
|
+
<button class="btn tiny accent lib-add" title="Add at the playhead">+</button>`;
|
|
9331
|
+
item.querySelector(".bin-name").textContent = t.name;
|
|
9332
|
+
item.querySelector(".bin-sub").textContent = `${t.width}×${t.height} · ${t.duration}s · ${t.fields.map((f) => f.label).join(", ") || "no fields"}`;
|
|
9333
|
+
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("text/fablecut-template", t.id); e.dataTransfer.effectAllowed = "copy"; });
|
|
9334
|
+
item.addEventListener("dblclick", () => addTemplateClip(t, null, state.time));
|
|
9335
|
+
item.querySelector(".lib-add").addEventListener("click", (e) => { e.stopPropagation(); addTemplateClip(t, null, state.time); });
|
|
9336
|
+
els.libList.appendChild(item);
|
|
9337
|
+
}
|
|
9338
|
+
if (!savedLib.templates.length) {
|
|
9339
|
+
const p = document.createElement("p");
|
|
9340
|
+
p.className = "lib-hint";
|
|
9341
|
+
p.textContent = "Right-click a composition in the Project tab → Save as template to keep your own here.";
|
|
9342
|
+
els.libList.appendChild(p);
|
|
9343
|
+
}
|
|
9344
|
+
}
|
|
9345
|
+
/** Place a template: its own comp (so its fields are this copy's alone) and a comp clip at `at`. */
|
|
9346
|
+
function addTemplateClip(t, trackId, at) {
|
|
9347
|
+
const track = trackId && TRACKS.some((x) => x.id === trackId && x.kind === "video") ? trackId : shapeTrack();
|
|
9348
|
+
if (!track || isTrackLocked(track)) { toast(track ? `${track} is locked` : "V1–V3 are locked — unlock a video track to add a template"); return; }
|
|
9349
|
+
const snap = JSON.stringify({ clips: project.clips, markers: project.markers || [], comps: project.comps || [], media: project.media });
|
|
9350
|
+
const had = new Set(project.media.map((m) => m.id));
|
|
9351
|
+
project.comps = project.comps || [];
|
|
9352
|
+
let r;
|
|
9353
|
+
try { r = Tpl.instantiate(project, t, { compId: newCompId(), start: Math.max(0, at), track }); }
|
|
9354
|
+
catch (err) { restoreSnapshot(snap); toast(err.message); return; } // put back any media it added; history untouched
|
|
9355
|
+
pushUndo(snap);
|
|
9356
|
+
const { comp, clip } = r;
|
|
9357
|
+
for (const x of comp.clips) {
|
|
9358
|
+
x.props = { ...DEFAULT_PROPS, ...(x.kind === "comp" ? { fit: "none" } : {}), ...(x.props || {}) };
|
|
9359
|
+
if (x.kind === "text") ensureFont(x.props.font);
|
|
9360
|
+
}
|
|
9361
|
+
clip.id = "c_" + uid();
|
|
9362
|
+
clip.props = { ...DEFAULT_PROPS, ...clip.props };
|
|
9363
|
+
project.clips.push(clip);
|
|
9364
|
+
for (const m of project.media) if (!had.has(m.id)) loadMediaMetadata(m).then(() => { state.dirtyTimeline = true; }).catch(() => { });
|
|
9365
|
+
runtime.compGen = (runtime.compGen || 0) + 1;
|
|
9366
|
+
selectClip(clip.id); scheduleSave(); renderBin(); renderInspector();
|
|
9367
|
+
toast(`${comp.name} placed — edit its fields in the Inspector`);
|
|
9368
|
+
}
|
|
9369
|
+
/** Set the canvas size (the aspect menu and a template's "Use … canvas" button). */
|
|
9370
|
+
function setCanvasSize(w, h) {
|
|
9371
|
+
project.width = w; project.height = h;
|
|
9372
|
+
els.preview.width = w; els.preview.height = h;
|
|
9373
|
+
if (project.exportFrame) project.exportFrame = normalizeExportFrame(project.exportFrame, w, h);
|
|
9374
|
+
updateMonitorRes();
|
|
9375
|
+
syncAspectSel();
|
|
9376
|
+
syncExportFrameSel();
|
|
9377
|
+
updateExportFrameOverlay();
|
|
9378
|
+
seekMediaWhilePaused();
|
|
9379
|
+
scheduleSave();
|
|
9380
|
+
}
|
|
9381
|
+
/** The Inspector's Template section on a placed template: its fields, its length, and the canvas it was made for. */
|
|
9382
|
+
function templateSectionHtml(c, row) {
|
|
9383
|
+
const comp = getComp(c.compId), tpl = comp && comp.template;
|
|
9384
|
+
if (!tpl) return "";
|
|
9385
|
+
const pics = project.media.filter((m) => m.kind === "image" || m.kind === "svg" || m.kind === "video");
|
|
9386
|
+
const fields = (tpl.fields || []).map((f) => {
|
|
9387
|
+
const v = Tpl.fieldValue(comp, f), label = escapeHtml(f.label);
|
|
9388
|
+
if (f.type === "text") return row(label, `<textarea data-tf="${escapeHtml(f.key)}" rows="${String(v || "").length > 28 ? 2 : 1}" maxlength="2000">${escapeHtml(v || "")}</textarea>`);
|
|
9389
|
+
if (f.type === "color") return row(label, `<input type="color" data-tf="${escapeHtml(f.key)}" value="${/^#[0-9a-f]{6}/i.test(v || "") ? v.slice(0, 7) : "#ffffff"}">`);
|
|
9390
|
+
return row(label, `<select data-tf="${escapeHtml(f.key)}" title="Import your picture (or drop it in the bin), then pick it here">${pics.map((m) => `<option value="${m.id}" ${m.id === v ? "selected" : ""}>${escapeHtml(m.name)}</option>`).join("")}</select>`);
|
|
9391
|
+
}).join("");
|
|
9392
|
+
const sameShape = Math.abs(comp.width / comp.height - project.width / project.height) < 0.01;
|
|
9393
|
+
return `<div class="insp-section" data-tpl-section><h3>Template</h3>
|
|
9394
|
+
${fields}
|
|
9395
|
+
${row("Length (s)", `<input type="number" data-tf-dur min="0.5" step="0.1" value="${comp.duration}" title="Longer holds longer — the exit keeps its timing at the end">`)}
|
|
9396
|
+
${sameShape || state.compCtx ? "" : row("Canvas", `<button type="button" class="btn tiny" data-tf-canvas title="Make the project canvas ${comp.width}×${comp.height}, the size this template was made for">Use ${comp.width}×${comp.height}</button>`)}
|
|
9397
|
+
<p class="cl-note">These fields belong to this copy (its comp "${escapeHtml(comp.name)}"). Open it to change anything else.</p>
|
|
9398
|
+
</div>`;
|
|
9399
|
+
}
|
|
9400
|
+
function bindTemplateControls(c) {
|
|
9401
|
+
const root = els.inspector.querySelector("[data-tpl-section]");
|
|
9402
|
+
const comp = root && getComp(c.compId);
|
|
9403
|
+
if (!comp || !comp.template) return;
|
|
9404
|
+
let undoTaken = false;
|
|
9405
|
+
const refresh = () => { runtime.compGen = (runtime.compGen || 0) + 1; state.dirtyTimeline = true; scheduleSave(); };
|
|
9406
|
+
const set = (key, value) => {
|
|
9407
|
+
if (!undoTaken) { pushUndo(); undoTaken = true; }
|
|
9408
|
+
try { Tpl.setField(comp, key, value, project); } catch (err) { toast(err.message); return; }
|
|
9409
|
+
refresh();
|
|
9410
|
+
};
|
|
9411
|
+
for (const el of root.querySelectorAll("[data-tf]")) {
|
|
9412
|
+
const key = el.dataset.tf;
|
|
9413
|
+
if (el.tagName === "SELECT") el.addEventListener("change", () => {
|
|
9414
|
+
const m = getMedia(el.value);
|
|
9415
|
+
set(key, el.value);
|
|
9416
|
+
if (m && !runtime.mediaAux.get(m.id)?.svgText && !runtime.mediaAux.get(m.id)?.img) loadMediaMetadata(m).then(refresh).catch(() => { });
|
|
9417
|
+
});
|
|
9418
|
+
else el.addEventListener("input", () => set(key, el.value));
|
|
9419
|
+
el.addEventListener("change", () => { undoTaken = false; renderBin(); });
|
|
9420
|
+
}
|
|
9421
|
+
root.querySelector("[data-tf-dur]")?.addEventListener("change", (e) => {
|
|
9422
|
+
const v = +e.target.value, old = comp.duration;
|
|
9423
|
+
const snap = undoSnapshot(); // recorded only once the new length is accepted
|
|
9424
|
+
try { Tpl.retime(comp, +v.toFixed(3)); } catch (err) { toast(err.message); renderInspector(); return; }
|
|
9425
|
+
pushUndo(snap);
|
|
9426
|
+
// clips that showed the comp to its end keep doing so
|
|
9427
|
+
for (const x of project.clips) if (x.kind === "comp" && x.compId === comp.id && Math.abs((+x.in || 0) + x.duration - old) < 1e-3) x.duration = +(comp.duration - (+x.in || 0)).toFixed(4);
|
|
9428
|
+
refresh(); renderBin(); renderInspector();
|
|
9429
|
+
});
|
|
9430
|
+
root.querySelector("[data-tf-canvas]")?.addEventListener("click", () => { setCanvasSize(comp.width, comp.height); renderInspector(); toast(`Canvas is now ${comp.width}×${comp.height}`); });
|
|
9431
|
+
}
|
|
9432
|
+
/** Save comp `comp` to library/templates as a template (its titles' text and its pictures become fields). */
|
|
9433
|
+
async function saveCompAsTemplate(comp) {
|
|
9434
|
+
let template;
|
|
9435
|
+
try { template = Tpl.templateFromComp(comp, { media: project.media }); } catch (err) { toast(err.message); return; }
|
|
9436
|
+
try {
|
|
9437
|
+
const r = await fetch("/api/templates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template }) });
|
|
9438
|
+
const j = await r.json();
|
|
9439
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
9440
|
+
toast(`Saved "${j.name}" to ${j.file}`);
|
|
9441
|
+
} catch (err) { toast("Couldn't save the template: " + err.message); return; }
|
|
9442
|
+
await fetchSaved("templates");
|
|
9443
|
+
if (state.binTab === "templates") renderTemplatesTab();
|
|
9444
|
+
}
|
|
9445
|
+
|
|
9446
|
+
/** Inspector rows (in Motion): put a preset on the selected clips, or save this clip's animation as one. */
|
|
9447
|
+
function presetRowsHtml(c, row) {
|
|
9448
|
+
const opts = (anchor, first) => {
|
|
9449
|
+
const own = savedLib.presets.filter((p) => p.anchor === anchor);
|
|
9450
|
+
const list = (l) => l.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("");
|
|
9451
|
+
return `<select data-preset="${anchor}"><option value="">${first}</option>${list(Tpl.PRESETS.filter((p) => p.anchor === anchor))}${own.length ? `<optgroup label="Saved">${list(own)}</optgroup>` : ""}</select>`;
|
|
9452
|
+
};
|
|
9453
|
+
return row("Presets", `<span class="insp-ctrls">${opts("in", "In…")}${opts("out", "Out…")}</span>`)
|
|
9454
|
+
+ row("", `<span class="insp-ctrls">${opts("at", "At playhead…")}${opts("whole", "Loop…")}</span>`)
|
|
9455
|
+
+ row("Save as preset", `<span class="insp-ctrls"><input type="text" data-preset-name maxlength="60" placeholder="Name" style="max-width:110px">
|
|
9456
|
+
<select data-preset-anchor title="In: from the clip's start · Out: up to its end · Loop: the whole clip"><option value="in">In</option><option value="out">Out</option><option value="whole">Loop</option></select>
|
|
9457
|
+
<button type="button" class="btn tiny" data-preset-save title="Save this clip's keyframes to library/presets — every project can use it">Save</button></span>`);
|
|
9458
|
+
}
|
|
9459
|
+
function bindPresetControls(c) {
|
|
9460
|
+
for (const s of els.inspector.querySelectorAll("[data-preset]")) s.addEventListener("change", () => {
|
|
9461
|
+
const p = Tpl.findPreset(s.value, savedLib.presets);
|
|
9462
|
+
s.value = "";
|
|
9463
|
+
if (!p) return;
|
|
9464
|
+
const clips = selectedClips().filter(isVisualClip);
|
|
9465
|
+
const targets = clips.length ? clips : [c];
|
|
9466
|
+
if (targets.some(isGroupLocked)) { toastLocked(); return; }
|
|
9467
|
+
pushUndo();
|
|
9468
|
+
for (const x of targets) Tpl.applyPreset(x, p, { at: state.time - x.start });
|
|
9469
|
+
inspPropGen++; state.dirtyTimeline = true;
|
|
9470
|
+
scheduleSave(); renderInspector();
|
|
9471
|
+
toast(`${p.name} on ${targets.length} clip${targets.length > 1 ? "s" : ""}`);
|
|
9472
|
+
});
|
|
9473
|
+
els.inspector.querySelector("[data-preset-save]")?.addEventListener("click", async () => {
|
|
9474
|
+
const name = els.inspector.querySelector("[data-preset-name]").value.trim();
|
|
9475
|
+
const anchor = els.inspector.querySelector("[data-preset-anchor]").value;
|
|
9476
|
+
if (!name) { toast("Name the preset first"); return; }
|
|
9477
|
+
const times = Object.values(c.keyframes || {}).flat().map((x) => x.t);
|
|
9478
|
+
if (!times.length) { toast("This clip has no keyframes to save"); return; }
|
|
9479
|
+
const from = anchor === "out" ? Math.min(...times) : 0, to = anchor === "in" ? Math.max(...times) : c.duration;
|
|
9480
|
+
let preset;
|
|
9481
|
+
try { preset = Tpl.presetFromClip(c, { name, anchor, from, to }); } catch (err) { toast(err.message); return; }
|
|
9482
|
+
try {
|
|
9483
|
+
const r = await fetch("/api/presets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset }) });
|
|
9484
|
+
const j = await r.json();
|
|
9485
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
9486
|
+
toast(`Saved "${j.name}" to ${j.file}`);
|
|
9487
|
+
} catch (err) { toast("Couldn't save the preset: " + err.message); return; }
|
|
9488
|
+
await fetchSaved("presets");
|
|
9489
|
+
renderInspector();
|
|
9490
|
+
});
|
|
9491
|
+
}
|
|
9492
|
+
window.addEventListener("keydown", (e) => {
|
|
9493
|
+
if (e.key !== "Escape" || !state.compCtx) return;
|
|
9494
|
+
const tg = e.target;
|
|
9495
|
+
if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
|
|
9496
|
+
if (state.selIds.size || shapeUi.sel || shapeUi.tool || maskUi.sel >= 0 || maskUi.tool || runtime.anchorTool) return; // Esc steps back through those first
|
|
9497
|
+
e.preventDefault(); exitComp();
|
|
9498
|
+
});
|
|
9499
|
+
|
|
9500
|
+
/* ── Shape layers (props.shapes — math in motion.js) ── */
|
|
9501
|
+
const shapeNorm = new WeakMap(); // stored list → validated list
|
|
9502
|
+
function clipShapes(c) {
|
|
9503
|
+
const list = c && c.props && c.props.shapes;
|
|
9504
|
+
if (!Array.isArray(list) || !list.length) return null;
|
|
9505
|
+
let n = shapeNorm.get(list);
|
|
9506
|
+
if (n === undefined) { n = Motion.normalizeShapes(list); shapeNorm.set(list, n); }
|
|
9507
|
+
return n;
|
|
9508
|
+
}
|
|
9509
|
+
/** The shape layer's items at timeline time t (keys resolved). */
|
|
9510
|
+
function shapeItemsAt(c, t) { const l = clipShapes(c); return l ? Motion.shapesAt(l, t - c.start) : null; }
|
|
8883
9511
|
function isVisualClip(c) { return c && c.kind !== "adjust" && c.kind !== "audio"; }
|
|
8884
9512
|
/* Screen-space handle positions for the selection overlay. Each handle is
|
|
8885
9513
|
clamped into the visible canvas (inset by its own size) so it stays visible
|
|
@@ -8904,7 +9532,10 @@ function drawSelectionOverlay(W, H, t) {
|
|
|
8904
9532
|
if (maskEditActive(c) && drawClipMaskOverlay(c, W)) return; // so does a clip mask being edited
|
|
8905
9533
|
drawTrackOverlay(c, W);
|
|
8906
9534
|
if (!isVisualClip(c) || !activeAt(c, t) || !clipRenders(c)) return;
|
|
8907
|
-
|
|
9535
|
+
if (shapeEditActive(c) && drawShapeOverlay(c, W, H, t)) return; // a picked shape item replaces the box
|
|
9536
|
+
drawMotionPathOverlay(c, W, H, t);
|
|
9537
|
+
const ep = evalProps(c, t), b = clipBounds(c, ep, W, H, t);
|
|
9538
|
+
if (c.kind === "null") drawNullMark(b, W, 1);
|
|
8908
9539
|
const lw = Math.max(2, W / 640);
|
|
8909
9540
|
const hd = overlayHandles(b, W, H), hs = hd.hs;
|
|
8910
9541
|
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
@@ -8923,6 +9554,12 @@ function drawSelectionOverlay(W, H, t) {
|
|
|
8923
9554
|
}
|
|
8924
9555
|
ctx2d.beginPath(); ctx2d.arc(hd.rotate.x, hd.rotate.y, hs * 1.1, 0, Math.PI * 2);
|
|
8925
9556
|
ctx2d.fillStyle = "#ffce5c"; ctx2d.fill(); ctx2d.stroke();
|
|
9557
|
+
// the anchor point: what x / y place and what rotation and scale turn around
|
|
9558
|
+
const ap = anchorScreenPt(c, ep, W, H, t), ar = hs * (runtime.anchorTool ? 1.3 : 0.9);
|
|
9559
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = runtime.anchorTool ? "#ffce5c" : "#ffffff";
|
|
9560
|
+
ctx2d.beginPath(); ctx2d.arc(ap.x, ap.y, ar, 0, Math.PI * 2);
|
|
9561
|
+
ctx2d.moveTo(ap.x - ar * 1.7, ap.y); ctx2d.lineTo(ap.x + ar * 1.7, ap.y);
|
|
9562
|
+
ctx2d.moveTo(ap.x, ap.y - ar * 1.7); ctx2d.lineTo(ap.x, ap.y + ar * 1.7); ctx2d.stroke();
|
|
8926
9563
|
ctx2d.restore();
|
|
8927
9564
|
}
|
|
8928
9565
|
function canvasPt(e) {
|
|
@@ -8939,9 +9576,13 @@ function toLocal(pt, b) {
|
|
|
8939
9576
|
function pickClipAt(pt, W, H) {
|
|
8940
9577
|
const all = visibleClipsAt(state.time), mattes = matteTracksAt(all);
|
|
8941
9578
|
const seq = all.filter((c) => isVisualClip(c) && !mattes.has(c.track));
|
|
8942
|
-
for (
|
|
8943
|
-
|
|
8944
|
-
|
|
9579
|
+
for (const nulls of [false, true]) { // a null (invisible) is picked only where nothing else is
|
|
9580
|
+
for (let i = seq.length - 1; i >= 0; i--) {
|
|
9581
|
+
const c = seq[i];
|
|
9582
|
+
if ((c.kind === "null") !== nulls) continue;
|
|
9583
|
+
const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
|
|
9584
|
+
if (Math.abs(lp.x) <= b.hw && Math.abs(lp.y) <= b.hh) return c;
|
|
9585
|
+
}
|
|
8945
9586
|
}
|
|
8946
9587
|
return null;
|
|
8947
9588
|
}
|
|
@@ -8975,11 +9616,14 @@ els.preview.addEventListener("pointerdown", (e) => {
|
|
|
8975
9616
|
if (isVisualClip(cur) && activeAt(cur, state.time)) {
|
|
8976
9617
|
const b = clipBounds(cur, evalProps(cur, state.time), W, H), lp = toLocal(pt, b);
|
|
8977
9618
|
const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
|
|
8978
|
-
if (
|
|
9619
|
+
if (runtime.anchorTool) { // Anchor tool: the anchor point follows the pointer, the picture stays put
|
|
9620
|
+
const p0 = propsAtPlayhead(cur);
|
|
9621
|
+
canvasDrag = { mode: "anchor", id: cur.id, p0, m: layerScreenMatrix(cur, p0, W, H, state.time).scale(Motion.contentScale(cur, p0)).inverse() };
|
|
9622
|
+
} else if (Math.hypot(pt.x - hd.rotate.x, pt.y - hd.rotate.y) <= grab) {
|
|
8979
9623
|
const ep = propsAtPlayhead(cur);
|
|
8980
9624
|
canvasDrag = { mode: "rotate", id: cur.id, startRot: +ep.rotation || 0, startAng: Math.atan2(pt.y - b.cy, pt.x - b.cx), cx: b.cx, cy: b.cy };
|
|
8981
9625
|
} else if (hd.corners.some((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab)) {
|
|
8982
|
-
if (cur.kind === "text") {
|
|
9626
|
+
if (cur.kind === "text" && !cur.props.textPath) { // a title on a path scales; its box is the path's
|
|
8983
9627
|
canvasDrag = {
|
|
8984
9628
|
...beginTextBoxDrag(cur, pt, W, H),
|
|
8985
9629
|
seedBox: !hasTextBox(cur.props),
|
|
@@ -9029,7 +9673,8 @@ function updateCanvasCursor(e) {
|
|
|
9029
9673
|
const W = els.preview.width, H = els.preview.height, pt = canvasPt(e);
|
|
9030
9674
|
const cur = getClip(state.selId);
|
|
9031
9675
|
let cursor = "default";
|
|
9032
|
-
if (isVisualClip(cur) && activeAt(cur, state.time) && !state.playing && !state.exporting)
|
|
9676
|
+
if (runtime.anchorTool && isVisualClip(cur) && activeAt(cur, state.time) && !state.playing && !state.exporting) cursor = "crosshair";
|
|
9677
|
+
else if (isVisualClip(cur) && activeAt(cur, state.time) && !state.playing && !state.exporting) {
|
|
9033
9678
|
const b = clipBounds(cur, evalProps(cur, state.time), W, H);
|
|
9034
9679
|
const hd = overlayHandles(b, W, H), grab = hd.hs * 1.8;
|
|
9035
9680
|
const corner = hd.corners.find((h) => Math.abs(pt.x - h.x) <= grab && Math.abs(pt.y - h.y) <= grab);
|
|
@@ -9060,8 +9705,9 @@ els.preview.addEventListener("pointermove", (e) => {
|
|
|
9060
9705
|
}
|
|
9061
9706
|
}
|
|
9062
9707
|
if (canvasDrag.mode === "move") {
|
|
9063
|
-
|
|
9064
|
-
setAnimProp(c, "
|
|
9708
|
+
const d = screenDeltaToParent(c, pt.x - canvasDrag.startPt.x, pt.y - canvasDrag.startPt.y);
|
|
9709
|
+
setAnimProp(c, "x", Math.round(canvasDrag.startX + d.x));
|
|
9710
|
+
setAnimProp(c, "y", Math.round(canvasDrag.startY + d.y));
|
|
9065
9711
|
} else if (canvasDrag.mode === "box") {
|
|
9066
9712
|
const aspect = canvasDrag.aspect || 1;
|
|
9067
9713
|
const lockAR = e.shiftKey;
|
|
@@ -9072,8 +9718,8 @@ els.preview.addEventListener("pointermove", (e) => {
|
|
|
9072
9718
|
const env = transOffsetAt(c, state.time);
|
|
9073
9719
|
if (e.ctrlKey || e.metaKey) {
|
|
9074
9720
|
// Ctrl/Cmd: resize from center (all corners move).
|
|
9075
|
-
const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
|
|
9076
|
-
let bw = Math.abs(lp.x) * 2, bh = Math.abs(lp.y) * 2;
|
|
9721
|
+
const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b), pk = parentScaleAt(c);
|
|
9722
|
+
let bw = Math.abs(lp.x) * 2 / pk, bh = Math.abs(lp.y) * 2 / pk;
|
|
9077
9723
|
if (lockAR) {
|
|
9078
9724
|
if (bw / aspect >= bh) bh = bw / aspect;
|
|
9079
9725
|
else bw = bh * aspect;
|
|
@@ -9110,11 +9756,22 @@ els.preview.addEventListener("pointermove", (e) => {
|
|
|
9110
9756
|
const c2 = Math.cos(rot), s2 = Math.sin(rot);
|
|
9111
9757
|
const freeX = fix.x + ldx * c2 - ldy * s2;
|
|
9112
9758
|
const freeY = fix.y + ldx * s2 + ldy * c2;
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
c
|
|
9116
|
-
c
|
|
9759
|
+
// the box's centre on screen → the clip's x / y (its parents and anchor undone)
|
|
9760
|
+
const pos = posForScreenCentre(c, propsAtPlayhead(c), (fix.x + freeX) / 2, (fix.y + freeY) / 2, W, H);
|
|
9761
|
+
setAnimProp(c, "x", Math.round(pos.x - env.x));
|
|
9762
|
+
setAnimProp(c, "y", Math.round(pos.y - env.y));
|
|
9763
|
+
const pk = parentScaleAt(c);
|
|
9764
|
+
c.props.boxW = +(Math.abs(ldx) / pk).toFixed(1);
|
|
9765
|
+
c.props.boxH = +(Math.abs(ldy) / pk).toFixed(1);
|
|
9117
9766
|
}
|
|
9767
|
+
} else if (canvasDrag.mode === "anchor") {
|
|
9768
|
+
let q = canvasDrag.m.transformPoint(new DOMPoint(pt.x, pt.y));
|
|
9769
|
+
const box = layerBox(c, canvasDrag.p0, W, H, state.time), as = Motion.contentScale(c, canvasDrag.p0);
|
|
9770
|
+
const snap = Math.max(6, W / 120) * Math.hypot(canvasDrag.m.a, canvasDrag.m.b); // screen px → layer px
|
|
9771
|
+
const cx = box.ox / as, cy = box.oy / as;
|
|
9772
|
+
if (e.shiftKey || Math.hypot(q.x - cx, q.y - cy) < snap) q = { x: cx, y: cy }; // snaps to the picture's centre
|
|
9773
|
+
const v = Motion.anchorMoveValues(c, canvasDrag.p0, Math.round(q.x), Math.round(q.y));
|
|
9774
|
+
for (const k of ["anchorX", "anchorY", "x", "y"]) setAnimProp(c, k, v[k]);
|
|
9118
9775
|
} else if (canvasDrag.mode === "scale") {
|
|
9119
9776
|
const b = clipBounds(c, evalProps(c, state.time), W, H), lp = toLocal(pt, b);
|
|
9120
9777
|
setAnimProp(c, "scale", clamp(+(canvasDrag.startScale * (Math.hypot(lp.x, lp.y) / canvasDrag.startDist)).toFixed(3), 0.05, 12));
|
|
@@ -9136,9 +9793,77 @@ els.preview.addEventListener("pointerup", endCanvasDrag);
|
|
|
9136
9793
|
els.preview.addEventListener("pointercancel", endCanvasDrag);
|
|
9137
9794
|
|
|
9138
9795
|
function drawClip(c, W, H, t, asMatte = false) {
|
|
9796
|
+
if (c.kind === "null") return; // a null only carries a transform for its children
|
|
9797
|
+
if (+c.props.motionBlur > 0 && c.kind !== "adjust" && drawMotionBlurred(c, W, H, t, asMatte)) return;
|
|
9798
|
+
drawClipCut(c, W, H, t, asMatte, false);
|
|
9799
|
+
}
|
|
9800
|
+
/** The clip cut by its masks / matte. raw: no opacity or blend (a motion-blur sample). */
|
|
9801
|
+
function drawClipCut(c, W, H, t, asMatte, raw) {
|
|
9139
9802
|
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,
|
|
9803
|
+
if (masks || matte) drawMaskedClip(c, W, H, t, masks, matte, raw);
|
|
9804
|
+
else drawClipPicture(c, W, H, t, raw);
|
|
9805
|
+
}
|
|
9806
|
+
/* ── Motion blur: the clip drawn at sub-frame times across the shutter and
|
|
9807
|
+
averaged (premultiplied, "lighter" at 1/n each), then composited once with its
|
|
9808
|
+
opacity and blend. Preview and export share it; preview playback uses fewer samples. */
|
|
9809
|
+
function drawMotionBlurred(c, W, H, t, asMatte) {
|
|
9810
|
+
const fps = projectFps() || 30, span = clamp(+c.props.motionBlur, 0, 720) / 360 / fps;
|
|
9811
|
+
let n = clamp(Math.round(+c.props.motionBlurSamples || 8), 2, 32);
|
|
9812
|
+
// playback: as many samples as fit in ~12 ms (all of them on a fast machine); a paused frame and export use them all
|
|
9813
|
+
const live = state.playing && !state.exporting, cost = live && (runtime.mbCost ||= new Map()).get(c.id);
|
|
9814
|
+
if (cost) n = clamp(Math.floor(12 / cost), 2, n);
|
|
9815
|
+
const began = live ? performance.now() : 0;
|
|
9816
|
+
const lo = c.start, hi = clipEnd(c) - 1e-4;
|
|
9817
|
+
const t0 = clamp(t - span / 2, lo, hi), t1 = clamp(t + span / 2, lo, hi);
|
|
9818
|
+
if (t1 - t0 < 1e-6 || !clipMovesBetween(c, t0, t1, W, H)) return false;
|
|
9819
|
+
// a blurred clip on a matte track blurs inside this one's samples: each depth has its own buffers
|
|
9820
|
+
const depth = runtime.mbDepth = (runtime.mbDepth || 0) + 1;
|
|
9821
|
+
const acc = maskCtx("mbAcc" + depth, W, H), smp = maskCtx("mbSmp" + depth, W, H);
|
|
9822
|
+
const main = ctx2d;
|
|
9823
|
+
try {
|
|
9824
|
+
acc.setTransform(1, 0, 0, 1, 0, 0); acc.globalAlpha = 1; acc.globalCompositeOperation = "source-over"; acc.filter = "none";
|
|
9825
|
+
acc.clearRect(0, 0, W, H);
|
|
9826
|
+
acc.fillStyle = "#000";
|
|
9827
|
+
for (let i = 0; i < n; i++) {
|
|
9828
|
+
const ts = t0 + (t1 - t0) * i / (n - 1);
|
|
9829
|
+
smp.setTransform(1, 0, 0, 1, 0, 0); smp.globalAlpha = 1; smp.globalCompositeOperation = "source-over"; smp.filter = "none";
|
|
9830
|
+
smp.clearRect(0, 0, W, H);
|
|
9831
|
+
ctx2d = smp;
|
|
9832
|
+
try { drawClipCut(c, W, H, ts, asMatte, true); } finally { ctx2d = main; }
|
|
9833
|
+
// running average, exact in transparent areas too: acc = acc·(1−a) + sample·a with a = 1/(i+1).
|
|
9834
|
+
// (Adding every sample at 1/n instead rounds each 8-bit contribution and bands the result.)
|
|
9835
|
+
const a = 1 / (i + 1);
|
|
9836
|
+
if (i > 0) { acc.globalCompositeOperation = "destination-in"; acc.globalAlpha = 1 - a; acc.fillRect(0, 0, W, H); }
|
|
9837
|
+
acc.globalCompositeOperation = "lighter"; acc.globalAlpha = a;
|
|
9838
|
+
acc.drawImage(smp.canvas, 0, 0);
|
|
9839
|
+
}
|
|
9840
|
+
} finally { runtime.mbDepth = depth - 1; ctx2d = main; }
|
|
9841
|
+
acc.globalCompositeOperation = "source-over"; acc.globalAlpha = 1;
|
|
9842
|
+
if (live) { const per = (performance.now() - began) / n, prev = runtime.mbCost.get(c.id); runtime.mbCost.set(c.id, prev ? prev * 0.7 + per * 0.3 : per); }
|
|
9843
|
+
const p = evalProps(c, t);
|
|
9844
|
+
main.save();
|
|
9845
|
+
main.setTransform(1, 0, 0, 1, 0, 0);
|
|
9846
|
+
main.globalAlpha = clamp(p.opacity, 0, 1);
|
|
9847
|
+
main.globalCompositeOperation = p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend) ? p.blend : "source-over";
|
|
9848
|
+
main.filter = "none";
|
|
9849
|
+
main.drawImage(acc.canvas, 0, 0);
|
|
9850
|
+
main.restore();
|
|
9851
|
+
return true;
|
|
9852
|
+
}
|
|
9853
|
+
/** Does the clip move (or its shapes change) between t0 and t1? Still clips draw once. */
|
|
9854
|
+
function clipMovesBetween(c, t0, t1, W, H) {
|
|
9855
|
+
if (c.kind === "shape" && (shapeKeyed(c) || c.keyframes?.trimStart || c.keyframes?.trimEnd || c.keyframes?.trimOffset)) return true;
|
|
9856
|
+
const a = clipBounds(c, evalProps(c, t0), W, H, t0), b = clipBounds(c, evalProps(c, t1), W, H, t1);
|
|
9857
|
+
const corners = (q) => [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sy]) => {
|
|
9858
|
+
const cs = Math.cos(q.rot), sn = Math.sin(q.rot), x = sx * q.hw, y = sy * q.hh;
|
|
9859
|
+
return [q.cx + x * cs - y * sn, q.cy + x * sn + y * cs];
|
|
9860
|
+
});
|
|
9861
|
+
const A = corners(a), B = corners(b);
|
|
9862
|
+
return A.some((p, i) => Math.hypot(p[0] - B[i][0], p[1] - B[i][1]) > 0.5);
|
|
9863
|
+
}
|
|
9864
|
+
function shapeKeyed(c) {
|
|
9865
|
+
const any = (list) => (list || []).some((it) => (it.keys && it.keys.length) || (it.type === "group" && any(it.items)));
|
|
9866
|
+
return any(c.props.shapes);
|
|
9142
9867
|
}
|
|
9143
9868
|
/** Draw one clip. `layered`: into a mask layer — opacity and blend wait for
|
|
9144
9869
|
* the composite. Returns { p, geom } — geom = where the clip's picture landed
|
|
@@ -9169,7 +9894,10 @@ function drawClipPicture(c, W, H, t, layered) {
|
|
|
9169
9894
|
ctx2d.globalAlpha = clamp(p.opacity, 0, 1);
|
|
9170
9895
|
if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) ctx2d.globalCompositeOperation = p.blend;
|
|
9171
9896
|
}
|
|
9172
|
-
ctx2d.translate(W / 2
|
|
9897
|
+
ctx2d.translate(W / 2, H / 2);
|
|
9898
|
+
const pm = parentM(c, t);
|
|
9899
|
+
if (pm) ctx2d.transform(pm[0], pm[1], pm[2], pm[3], pm[4], pm[5]);
|
|
9900
|
+
ctx2d.translate(+p.x || 0, +p.y || 0);
|
|
9173
9901
|
ctx2d.rotate((p.rotation || 0) * Math.PI / 180);
|
|
9174
9902
|
if (p.shake > 0) { // deterministic multi-sine handheld/impact shake
|
|
9175
9903
|
const a = +p.shake, tt = t * clamp(+p.shakeSpeed || 8, 0.5, 40) * Math.PI * 2;
|
|
@@ -9178,9 +9906,23 @@ function drawClipPicture(c, W, H, t, layered) {
|
|
|
9178
9906
|
Math.cos(tt * 1.7) * a * 0.5 + Math.sin(tt * 3.13) * a * 0.35);
|
|
9179
9907
|
ctx2d.rotate(Math.sin(tt * 0.9) * a * 0.0022);
|
|
9180
9908
|
}
|
|
9909
|
+
if (+p.anchorX || +p.anchorY) { // the anchor point sits at x / y; rotation and scale turn around it
|
|
9910
|
+
const as = Motion.contentScale(c, p);
|
|
9911
|
+
ctx2d.translate(-(+p.anchorX || 0) * as, -(+p.anchorY || 0) * as);
|
|
9912
|
+
}
|
|
9913
|
+
if (c.kind === "shape") {
|
|
9914
|
+
const s = +p.scale || 1;
|
|
9915
|
+
ctx2d.scale(s, s);
|
|
9916
|
+
geom = noteMaskGeom(c, ctx2d.getTransform(), -W / 2, -H / 2, W, H); // masks live in the canvas-sized layer box
|
|
9917
|
+
ctx2d.filter = buildFilter(p);
|
|
9918
|
+
Motion.draw(ctx2d, shapeItemsAt(c, t), [+p.trimStart || 0, p.trimEnd == null ? 100 : +p.trimEnd, +p.trimOffset || 0]);
|
|
9919
|
+
ctx2d.filter = "none";
|
|
9920
|
+
ctx2d.restore();
|
|
9921
|
+
return { p, geom };
|
|
9922
|
+
}
|
|
9181
9923
|
if (c.kind === "text") {
|
|
9182
|
-
const b =
|
|
9183
|
-
geom = noteMaskGeom(c, ctx2d.getTransform(), -b.hw, -b.hh, b.hw * 2, b.hh * 2);
|
|
9924
|
+
const b = layerBox(c, p, W, H, t); // a path's box is usually off the origin (ox / oy)
|
|
9925
|
+
geom = noteMaskGeom(c, ctx2d.getTransform(), (b.ox || 0) - b.hw, (b.oy || 0) - b.hh, b.hw * 2, b.hh * 2);
|
|
9184
9926
|
drawText(c, p, t - c.start);
|
|
9185
9927
|
ctx2d.restore();
|
|
9186
9928
|
return { p, geom };
|
|
@@ -9195,6 +9937,9 @@ function drawClipPicture(c, W, H, t, layered) {
|
|
|
9195
9937
|
} else if (c.kind === "video") {
|
|
9196
9938
|
src = getClipEl(c);
|
|
9197
9939
|
if (src) { sw = src.videoWidth; sh = src.videoHeight; }
|
|
9940
|
+
} else if (c.kind === "comp") {
|
|
9941
|
+
src = renderComp(c, t);
|
|
9942
|
+
if (src) { sw = src.width; sh = src.height; }
|
|
9198
9943
|
}
|
|
9199
9944
|
if (src && sw && sh) {
|
|
9200
9945
|
// source crop (percent per edge)
|
|
@@ -9257,6 +10002,7 @@ function drawClipPicture(c, W, H, t, layered) {
|
|
|
9257
10002
|
const Mask = self.FableCutMask;
|
|
9258
10003
|
const maskCv = {};
|
|
9259
10004
|
function maskCtx(key, W, H) {
|
|
10005
|
+
if (runtime.compDepth) key += ":" + runtime.compDepth;
|
|
9260
10006
|
let cv = maskCv[key];
|
|
9261
10007
|
if (!cv) cv = maskCv[key] = document.createElement("canvas");
|
|
9262
10008
|
if (cv.width !== W) cv.width = W;
|
|
@@ -9292,7 +10038,7 @@ const videoTrackIds = () => TRACKS.filter((tr) => tr.kind === "video").map((tr)
|
|
|
9292
10038
|
/** { mode, track } when the clip has a track matte with a track to take it from. */
|
|
9293
10039
|
function clipMatte(c) {
|
|
9294
10040
|
if (c.kind === "audio" || !c.props || !c.props.matte) return null;
|
|
9295
|
-
const track = Mask.matteTrackFor(c, videoTrackIds());
|
|
10041
|
+
const track = Mask.matteTrackFor(c, runtime.scope ? runtime.scope.videoTracks : videoTrackIds());
|
|
9296
10042
|
return track ? { mode: c.props.matte, track } : null;
|
|
9297
10043
|
}
|
|
9298
10044
|
/** The tracks that are mattes for clips in `clips` (the frame's clips): they only cut. */
|
|
@@ -9308,7 +10054,7 @@ function trackMatteAt(matte, W, H, t) {
|
|
|
9308
10054
|
src.setTransform(1, 0, 0, 1, 0, 0);
|
|
9309
10055
|
src.globalAlpha = 1; src.globalCompositeOperation = "source-over"; src.filter = "none";
|
|
9310
10056
|
src.clearRect(0, 0, W, H);
|
|
9311
|
-
const clips = project.clips.filter((x) => x.track === matte.track && x
|
|
10057
|
+
const clips = (runtime.scope ? runtime.scope.clips : project.clips).filter((x) => x.track === matte.track && clipRenders(x) && x.kind !== "audio" &&
|
|
9312
10058
|
x.kind !== "adjust" && activeAt(x, t)).sort((a, b) => a.start - b.start);
|
|
9313
10059
|
if (!clips.length) return { canvas: src.canvas, empty: true };
|
|
9314
10060
|
if (luma) { src.fillStyle = "#000"; src.fillRect(0, 0, W, H); }
|
|
@@ -9357,7 +10103,7 @@ function makeLumaFilter() {
|
|
|
9357
10103
|
} catch { return ""; }
|
|
9358
10104
|
}
|
|
9359
10105
|
/** 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) {
|
|
10106
|
+
function drawMaskedClip(c, W, H, t, masks, matte = null, raw = false) {
|
|
9361
10107
|
const tm = matte ? trackMatteAt(matte, W, H, t) : null; // first: the matte's clips use the layer buffers too
|
|
9362
10108
|
const layer = maskCtx("layer", W, H);
|
|
9363
10109
|
layer.setTransform(1, 0, 0, 1, 0, 0);
|
|
@@ -9379,7 +10125,7 @@ function drawMaskedClip(c, W, H, t, masks, matte = null) {
|
|
|
9379
10125
|
main.save();
|
|
9380
10126
|
main.setTransform(1, 0, 0, 1, 0, 0);
|
|
9381
10127
|
const p = r.p;
|
|
9382
|
-
if (c.kind !== "adjust") { // an adjustment layer already drew at its own opacity
|
|
10128
|
+
if (c.kind !== "adjust" && !raw) { // an adjustment layer already drew at its own opacity; a raw sample composites later
|
|
9383
10129
|
main.globalAlpha = clamp(p.opacity, 0, 1);
|
|
9384
10130
|
if (p.blend && p.blend !== "normal" && BLEND_MODES.includes(p.blend)) main.globalCompositeOperation = p.blend;
|
|
9385
10131
|
}
|
|
@@ -9910,6 +10656,7 @@ function bindMaskControls(c) {
|
|
|
9910
10656
|
for (const b of root.querySelectorAll("[data-msel]")) b.addEventListener("click", () => {
|
|
9911
10657
|
const j = +b.dataset.msel;
|
|
9912
10658
|
maskUi.sel = maskUi.sel === j ? -1 : j; maskUi.pt = -1; maskUi.tool = null;
|
|
10659
|
+
if (maskUi.sel >= 0) { shapeUi.sel = null; shapeUi.tool = null; }
|
|
9913
10660
|
rerender();
|
|
9914
10661
|
});
|
|
9915
10662
|
for (const b of root.querySelectorAll("[data-mact]")) b.addEventListener("click", () => {
|
|
@@ -10288,6 +11035,1064 @@ window.addEventListener("keydown", (e) => {
|
|
|
10288
11035
|
}
|
|
10289
11036
|
}, true);
|
|
10290
11037
|
|
|
11038
|
+
/* ── Shape layers, nulls and parenting in the editor ──
|
|
11039
|
+
A shape clip draws props.shapes (motion.js). The Shape layer section lists
|
|
11040
|
+
its items; picking one shows its box, knob and points on the monitor. Pen:
|
|
11041
|
+
click to place points (drag pulls handles), click the first point to close,
|
|
11042
|
+
Enter to finish an open path. Item values with ◆ keys animate per value. */
|
|
11043
|
+
const SHAPE_PRESETS = {
|
|
11044
|
+
rect: { label: "Rectangle", item: { type: "rect", w: 420, h: 240, radius: 24, fill: "#ffd166" } },
|
|
11045
|
+
ellipse: { label: "Ellipse", item: { type: "ellipse", w: 260, h: 260, fill: "#7b6cff" } },
|
|
11046
|
+
polygon: { label: "Polygon", item: { type: "polygon", sides: 6, w: 260, h: 260, fill: "#22d3ee" } },
|
|
11047
|
+
star: { label: "Star", item: { type: "star", sides: 5, inner: 0.45, w: 280, h: 280, fill: "#ff7ac8" } },
|
|
11048
|
+
line: { label: "Line", item: { type: "line", points: [[-220, 0], [220, 0]], stroke: "#ffffff", strokeWidth: 10 } },
|
|
11049
|
+
};
|
|
11050
|
+
const shapeUi = { clip: null, sel: null, pt: -1, tool: null, pen: null, drag: null };
|
|
11051
|
+
const SHAPE_ROWS_UI = {
|
|
11052
|
+
x: ["X", -2000, 2000, 1], y: ["Y", -2000, 2000, 1], rotation: ["Rotation", -360, 360, 0.5], scale: ["Scale", 0.05, 6, 0.01],
|
|
11053
|
+
opacity: ["Opacity", 0, 1, 0.01], w: ["Width", 0, 2000, 1], h: ["Height", 0, 2000, 1], radius: ["Radius", 0, 500, 1],
|
|
11054
|
+
inner: ["Inner", 0.05, 1, 0.01], strokeWidth: ["Stroke px", 0, 100, 0.5],
|
|
11055
|
+
trimStart: ["Trim start", 0, 100, 0.5], trimEnd: ["Trim end", 0, 100, 0.5], trimOffset: ["Trim offset", -100, 100, 0.5],
|
|
11056
|
+
};
|
|
11057
|
+
const pathKey = (path) => (path || []).join(".");
|
|
11058
|
+
const samePath = (a, b) => !!a && !!b && pathKey(a) === pathKey(b);
|
|
11059
|
+
function trackFree(id) { return !isTrackLocked(id); }
|
|
11060
|
+
function shapeTrack() { return ["V2", "V3", "V1"].find((id) => TRACKS.some((tr) => tr.id === id) && trackFree(id)) || null; }
|
|
11061
|
+
function newVisualClip(kind, name, track, props = {}) {
|
|
11062
|
+
return { id: "c_" + uid(), mediaId: null, kind, track, start: state.time, in: 0, duration: 4, name, props: { ...DEFAULT_PROPS, ...props } };
|
|
11063
|
+
}
|
|
11064
|
+
function addShapeLayer(kind) {
|
|
11065
|
+
const track = shapeTrack();
|
|
11066
|
+
if (!track) { toast("V1–V3 are locked — unlock a video track to add a shape"); return; }
|
|
11067
|
+
pushUndo();
|
|
11068
|
+
const pre = SHAPE_PRESETS[kind];
|
|
11069
|
+
const c = newVisualClip("shape", pre ? pre.label : "Shape", track);
|
|
11070
|
+
const list = pre ? Motion.normalizeShapes([pre.item]) : null;
|
|
11071
|
+
if (list) c.props.shapes = list;
|
|
11072
|
+
project.clips.push(c);
|
|
11073
|
+
selectClip(c.id);
|
|
11074
|
+
Object.assign(shapeUi, { clip: c.id, sel: list ? [0] : null, pt: -1, tool: pre ? null : "pen", pen: null, drag: null });
|
|
11075
|
+
if (!pre && state.playing) pause();
|
|
11076
|
+
scheduleSave(); renderInspector();
|
|
11077
|
+
if (!pre) toast("Pen: click points on the monitor (drag to curve); click the first point to close, Enter for an open path");
|
|
11078
|
+
}
|
|
11079
|
+
function addNull() {
|
|
11080
|
+
const track = ["V3", "V2", "V1"].find((id) => TRACKS.some((tr) => tr.id === id) && trackFree(id));
|
|
11081
|
+
if (!track) { toast("V1–V3 are locked — unlock a video track to add a null"); return; }
|
|
11082
|
+
pushUndo();
|
|
11083
|
+
const c = newVisualClip("null", "Null", track);
|
|
11084
|
+
project.clips.push(c);
|
|
11085
|
+
selectClip(c.id); scheduleSave(); renderInspector();
|
|
11086
|
+
}
|
|
11087
|
+
/* The bin's Shapes tab: each row adds a new layer at the playhead (a selected
|
|
11088
|
+
shape layer takes more items from its own Shape layer section). */
|
|
11089
|
+
const SHAPE_ICONS = {
|
|
11090
|
+
rect: '<rect x="3" y="6" width="18" height="12" rx="2" fill="#ffd166"/>',
|
|
11091
|
+
ellipse: '<circle cx="12" cy="12" r="8" fill="#7b6cff"/>',
|
|
11092
|
+
polygon: '<polygon points="12,3 20,7.5 20,16.5 12,21 4,16.5 4,7.5" fill="#22d3ee"/>',
|
|
11093
|
+
star: '<polygon points="12,2.5 14.6,9 21.5,9.3 16.1,13.6 18,20.3 12,16.4 6,20.3 7.9,13.6 2.5,9.3 9.4,9" fill="#ff7ac8"/>',
|
|
11094
|
+
line: '<line x1="4" y1="18" x2="20" y2="6" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>',
|
|
11095
|
+
pen: '<path d="M4 18 C 8 4, 16 4, 20 16" fill="none" stroke="#5ad1ff" stroke-width="2.5" stroke-linecap="round"/><rect x="2.5" y="16.5" width="3" height="3" fill="#fff"/><rect x="18.5" y="14.5" width="3" height="3" fill="#fff"/>',
|
|
11096
|
+
null: '<rect x="5" y="5" width="14" height="14" fill="none" stroke="#ff6b6b" stroke-width="2"/><path d="M12 8v8M8 12h8" stroke="#ff6b6b" stroke-width="2"/>',
|
|
11097
|
+
};
|
|
11098
|
+
function renderShapesTab() {
|
|
11099
|
+
const rows = [
|
|
11100
|
+
...Object.entries(SHAPE_PRESETS).map(([k, pre]) => ({ k, name: pre.label, sub: "Shape layer", run: () => addShapeLayer(k) })),
|
|
11101
|
+
{ k: "pen", name: "Pen path", sub: "Click points on the monitor", run: () => addShapeLayer("pen") },
|
|
11102
|
+
{ k: "null", name: "Null object", sub: "Invisible parent for other clips", run: addNull },
|
|
11103
|
+
];
|
|
11104
|
+
els.libList.innerHTML = "";
|
|
11105
|
+
for (const r of rows) {
|
|
11106
|
+
const item = document.createElement("div");
|
|
11107
|
+
item.className = "bin-item lib-item";
|
|
11108
|
+
item.title = r.k === "null" ? "Add a null object at the playhead — parent clips to it to move them together" : `Add a ${r.name.toLowerCase()} layer at the playhead`;
|
|
11109
|
+
item.innerHTML = `<div class="bin-thumb shape-thumb"><svg viewBox="0 0 24 24" aria-hidden="true">${SHAPE_ICONS[r.k]}</svg></div>
|
|
11110
|
+
<div class="bin-meta"><div class="bin-name">${escapeHtml(r.name)}</div><div class="bin-sub">${escapeHtml(r.sub)}</div></div>
|
|
11111
|
+
<button class="btn tiny accent lib-add" title="Add at playhead">+</button>`;
|
|
11112
|
+
item.addEventListener("dblclick", r.run);
|
|
11113
|
+
item.querySelector(".lib-add").addEventListener("click", r.run);
|
|
11114
|
+
els.libList.appendChild(item);
|
|
11115
|
+
}
|
|
11116
|
+
}
|
|
11117
|
+
/** Parent the selected visual clips to a new null at their centre (a layer group). */
|
|
11118
|
+
function parentSelectionToNull() {
|
|
11119
|
+
const sel = selectedClips().filter((x) => isVisualClip(x) && x.kind !== "null");
|
|
11120
|
+
if (!sel.length) return;
|
|
11121
|
+
if (sel.some(isGroupLocked)) { toastLocked(); return; }
|
|
11122
|
+
const track = ["V3", "V2", "V1"].find((id) => TRACKS.some((tr) => tr.id === id) && trackFree(id));
|
|
11123
|
+
if (!track) { toast("V1–V3 are locked — unlock a video track for the null"); return; }
|
|
11124
|
+
pushUndo();
|
|
11125
|
+
const W = els.preview.width, H = els.preview.height;
|
|
11126
|
+
let sx = 0, sy = 0;
|
|
11127
|
+
for (const x of sel) { const b = clipBounds(x, evalProps(x, clamp(state.time, x.start, x.start + x.duration - 1e-3)), W, H); sx += b.cx; sy += b.cy; }
|
|
11128
|
+
const start = Math.min(...sel.map((x) => x.start)), end = Math.max(...sel.map((x) => x.start + x.duration));
|
|
11129
|
+
const n = { ...newVisualClip("null", "Null", track, { x: Math.round(sx / sel.length - W / 2), y: Math.round(sy / sel.length - H / 2) }), start, duration: +(end - start).toFixed(4) };
|
|
11130
|
+
project.clips.push(n);
|
|
11131
|
+
for (const x of sel) setClipParent(x, n.id);
|
|
11132
|
+
selectClip(n.id); scheduleSave(); renderInspector();
|
|
11133
|
+
toast(`${sel.length} clip${sel.length > 1 ? "s" : ""} now follow the null — move, turn or scale it`);
|
|
11134
|
+
}
|
|
11135
|
+
/** Change a clip's parent, keeping it where it is at the playhead (static values; keyed ones get a key). */
|
|
11136
|
+
function setClipParent(c, parentId) {
|
|
11137
|
+
if (parentId && Motion.wouldCycle(c.id, parentId, getClip)) { toast("That would parent the clip to itself"); return false; }
|
|
11138
|
+
const t = clamp(state.time, c.start, c.start + c.duration);
|
|
11139
|
+
const v = Motion.reparentValues(c, parentId || null, getClip, t);
|
|
11140
|
+
if (parentId) c.parent = parentId; else delete c.parent;
|
|
11141
|
+
for (const [k, val] of Object.entries(v)) {
|
|
11142
|
+
if (c.keyframes?.[k]?.length && playheadOverClip(c)) setAnimProp(c, k, val);
|
|
11143
|
+
else if (!c.keyframes?.[k]?.length) c.props[k] = val;
|
|
11144
|
+
}
|
|
11145
|
+
state.dirtyTimeline = true; inspPropGen++;
|
|
11146
|
+
return true;
|
|
11147
|
+
}
|
|
11148
|
+
/** Clips this clip may be parented to: other visual clips that are not its children. */
|
|
11149
|
+
function parentCandidates(c) {
|
|
11150
|
+
return project.clips.filter((x) => x.id !== c.id && isVisualClip(x) && !Motion.wouldCycle(c.id, x.id, getClip));
|
|
11151
|
+
}
|
|
11152
|
+
const clipShortLabel = (x) => x.track + " · " + (x.kind === "text" ? (x.props.text || "").split("\n")[0] : x.name || x.kind);
|
|
11153
|
+
function parentRowHtml(c) {
|
|
11154
|
+
const cands = parentCandidates(c), cur = c.parent && getClip(c.parent);
|
|
11155
|
+
const opts = [`<option value="">None</option>`, ...cands.map((x) => `<option value="${x.id}" ${x.id === c.parent ? "selected" : ""}>${escapeHtml(clipShortLabel(x))}${x.kind === "null" ? " (null)" : ""}</option>`)];
|
|
11156
|
+
if (c.parent && !cur) opts.push(`<option value="${escapeHtml(c.parent)}" selected>Missing clip</option>`);
|
|
11157
|
+
return `<div class="insp-row"><label title="Move, turn and scale with another clip (its opacity and effects are not inherited)">Parent</label><select data-parent>${opts.join("")}</select></div>`;
|
|
11158
|
+
}
|
|
11159
|
+
function anchorRowsHtml(c, p, row) {
|
|
11160
|
+
return `<div class="insp-row"><label>Anchor</label><span class="insp-ctrls">
|
|
11161
|
+
<button type="button" class="btn tiny${runtime.anchorTool ? " toggle on" : ""}" data-anchor-tool title="Drag on the monitor to move the anchor point — the picture stays put (Shift: snap to its centre)">✥ Move</button>
|
|
11162
|
+
<button type="button" class="btn tiny" data-anchor-centre title="Put the anchor point back on the picture's centre, keeping the picture where it is">Centre</button></span></div>
|
|
11163
|
+
${row("Anchor X", `<input type="number" data-k="anchorX" value="${fmtInspNum(p.anchorX)}">`, "anchorX")}
|
|
11164
|
+
${row("Anchor Y", `<input type="number" data-k="anchorY" value="${fmtInspNum(p.anchorY)}">`, "anchorY")}`;
|
|
11165
|
+
}
|
|
11166
|
+
function bindParentAnchorControls(c) {
|
|
11167
|
+
const root = els.inspector;
|
|
11168
|
+
root.querySelector("[data-parent]")?.addEventListener("change", (e) => {
|
|
11169
|
+
if (isGroupLocked(c)) { toastLocked(); renderInspector(); return; }
|
|
11170
|
+
pushUndo();
|
|
11171
|
+
setClipParent(c, e.target.value || null);
|
|
11172
|
+
scheduleSave(); renderInspector();
|
|
11173
|
+
});
|
|
11174
|
+
root.querySelector("[data-anchor-tool]")?.addEventListener("click", () => { runtime.anchorTool = !runtime.anchorTool; state.dirtyTimeline = true; renderInspector(); });
|
|
11175
|
+
root.querySelector("[data-anchor-centre]")?.addEventListener("click", () => {
|
|
11176
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
11177
|
+
if (!playheadOverClip(c)) { toast("Move the playhead over the clip"); return; }
|
|
11178
|
+
pushUndo();
|
|
11179
|
+
const p = propsAtPlayhead(c), box = layerBox(c, p, els.preview.width, els.preview.height, state.time), as = Motion.contentScale(c, p);
|
|
11180
|
+
const v = Motion.anchorMoveValues(c, p, Math.round(box.ox / as), Math.round(box.oy / as));
|
|
11181
|
+
for (const k of ["anchorX", "anchorY", "x", "y"]) setAnimProp(c, k, v[k]);
|
|
11182
|
+
scheduleSave(); renderInspector();
|
|
11183
|
+
});
|
|
11184
|
+
}
|
|
11185
|
+
|
|
11186
|
+
/* ── The Shape layer section ── */
|
|
11187
|
+
function shapeList(c) { return clipShapes(c) || []; }
|
|
11188
|
+
function shapeUiFor(c) {
|
|
11189
|
+
if (!c || shapeUi.clip !== c.id) Object.assign(shapeUi, { clip: c ? c.id : null, sel: null, pt: -1, tool: null, pen: null, drag: null });
|
|
11190
|
+
if (shapeUi.sel && !Motion.getItem(editList(c), shapeUi.sel)) { shapeUi.sel = null; shapeUi.pt = -1; }
|
|
11191
|
+
return shapeUi;
|
|
11192
|
+
}
|
|
11193
|
+
function writeShapes(c, list) {
|
|
11194
|
+
const n = list && list.length ? Motion.normalizeShapes(list) : null;
|
|
11195
|
+
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
11196
|
+
state.dirtyTimeline = true; inspPropGen++;
|
|
11197
|
+
}
|
|
11198
|
+
const shapeLocalT = (c) => state.time - c.start;
|
|
11199
|
+
function shapeKeyIndex(keys, t) {
|
|
11200
|
+
const tol = 0.5 / (projectFps() || 30);
|
|
11201
|
+
return keys ? keys.findIndex((k) => Math.abs(k.t - t) < tol) : -1;
|
|
11202
|
+
}
|
|
11203
|
+
const isAnimated = (it, k) => !!(it.keys && it.keys.some((x) => x[k] != null));
|
|
11204
|
+
/** The selected item as it is at the playhead. */
|
|
11205
|
+
function shapeNow(c, path = shapeUi.sel) { return path ? Motion.getItem(editItemsAt(c, state.time), path) : null; }
|
|
11206
|
+
/** Change item `path`; values that are animated land in a key at the playhead. */
|
|
11207
|
+
function writeShapeSet(c, path, set) {
|
|
11208
|
+
const list = editList(c), it = Motion.getItem(list, path);
|
|
11209
|
+
if (!it) return;
|
|
11210
|
+
const t = shapeLocalT(c), statics = {}, keyed = {};
|
|
11211
|
+
for (const [k, v] of Object.entries(set)) (Motion.KEYED.includes(k) && isAnimated(it, k) ? keyed : statics)[k] = v;
|
|
11212
|
+
const next = { ...statics };
|
|
11213
|
+
if (Object.keys(keyed).length) {
|
|
11214
|
+
// like setAnimProp: off the clip there is no "at the playhead" to key
|
|
11215
|
+
if (!playheadOverClip(c)) { toast("Move the playhead over the clip to edit its keyframes"); return false; }
|
|
11216
|
+
const keys = it.keys.map((k) => ({ ...k })), kt = +clamp(t, 0, c.duration).toFixed(4);
|
|
11217
|
+
let j = shapeKeyIndex(keys, kt);
|
|
11218
|
+
if (j < 0) { keys.push({ t: kt }); keys.sort((a, b) => a.t - b.t); j = shapeKeyIndex(keys, kt); }
|
|
11219
|
+
Object.assign(keys[j], keyed);
|
|
11220
|
+
next.keys = keys;
|
|
11221
|
+
}
|
|
11222
|
+
writeEditList(c, Motion.updateItem(list, path, (x) => Motion.mergeItem(x, next) || x));
|
|
11223
|
+
}
|
|
11224
|
+
/** ◆ on an item value: key it at the playhead, or remove the key there. */
|
|
11225
|
+
function toggleShapeKey(c, path, k) {
|
|
11226
|
+
const list = editList(c), it = Motion.getItem(list, path), now = shapeNow(c, path);
|
|
11227
|
+
if (!it || !now || now[k] == null) return;
|
|
11228
|
+
const t = shapeLocalT(c), keys = (it.keys || []).map((x) => ({ ...x })), j = shapeKeyIndex(keys, t);
|
|
11229
|
+
const set = {};
|
|
11230
|
+
if (j >= 0 && keys[j][k] != null) {
|
|
11231
|
+
delete keys[j][k];
|
|
11232
|
+
if (!Object.keys(keys[j]).some((p) => Motion.KEYED.includes(p))) keys.splice(j, 1);
|
|
11233
|
+
if (!keys.some((x) => x[k] != null)) set[k] = k === "points" ? now.points.map((q) => q.map((v) => +v.toFixed(3))) : now[k]; // last key gone: keep the value
|
|
11234
|
+
} else {
|
|
11235
|
+
const v = k === "points" ? now.points.map((q) => q.map((x) => +x.toFixed(3))) : typeof now[k] === "number" ? +now[k].toFixed(4) : now[k];
|
|
11236
|
+
if (j >= 0) keys[j][k] = v; else { keys.push({ t: +clamp(t, 0, c.duration).toFixed(4), [k]: v }); keys.sort((a, b) => a.t - b.t); }
|
|
11237
|
+
}
|
|
11238
|
+
set.keys = keys.length ? keys : null;
|
|
11239
|
+
writeEditList(c, Motion.updateItem(list, path, (x) => Motion.mergeItem(x, set) || x));
|
|
11240
|
+
}
|
|
11241
|
+
function shapeItemLabel(it, path) { return it.name || `${it.type[0].toUpperCase() + it.type.slice(1)} ${path.map((i) => i + 1).join(".")}`; }
|
|
11242
|
+
function shapeSectionHtml(c, p, row) {
|
|
11243
|
+
const ui = shapeUiFor(c), list = shapeList(c), full = Motion.countItems(list) >= Motion.MAX_ITEMS;
|
|
11244
|
+
const btn = (attr, label, title, on, dis) => `<button type="button" class="btn tiny${on ? " toggle on" : ""}" ${attr} title="${title}" ${dis ? "disabled" : ""}>${label}</button>`;
|
|
11245
|
+
const chips = Motion.itemPaths(list).map((path) => {
|
|
11246
|
+
const it = Motion.getItem(list, path);
|
|
11247
|
+
return `<button type="button" class="btn tiny cl-chip${samePath(path, ui.sel) ? " toggle on" : ""}${it.on === false ? " off" : ""}" data-ssel="${pathKey(path)}"
|
|
11248
|
+
title="${escapeHtml(it.type)} — click to edit on the monitor">${"› ".repeat(path.length - 1)}${escapeHtml(shapeItemLabel(it, path))}</button>`;
|
|
11249
|
+
}).join("");
|
|
11250
|
+
const slider = (k, min, max, step, unit = "") => {
|
|
11251
|
+
const shown = fmtInspNum(p[k], step);
|
|
11252
|
+
return row(KF_GRAPH_LABEL[k], `<input type="range" data-k="${k}" min="${min}" max="${max}" step="${step}" value="${shown}" title="Ctrl/Cmd-click: reset to default">
|
|
11253
|
+
<span class="val" data-val="${k}" data-unit="${unit}">${shown}${unit}</span>`, k);
|
|
11254
|
+
};
|
|
11255
|
+
let html = `<div class="insp-section" data-shape-section><h3>Shape layer</h3>
|
|
11256
|
+
<div class="cl-row">${Object.entries(SHAPE_PRESETS).map(([k, pre]) => btn(`data-sadd="${k}"`, "+ " + pre.label, `Add a ${pre.label.toLowerCase()} to this layer`, false, full)).join("")}
|
|
11257
|
+
${btn('data-stool="pen"', "Pen", "Click points on the monitor (drag to curve); click the first point to close, Enter for an open path", ui.tool === "pen", full)}</div>
|
|
11258
|
+
${ui.tool === "pen" ? `<p class="cl-note">Pen: click to add points, drag to curve. Click the first point to close, Enter keeps the path open. Backspace / Ctrl+Z removes the last point, Esc cancels.</p>` : ""}
|
|
11259
|
+
${list.length ? `<div class="cl-row shape-items">${chips}</div>` : ui.tool ? "" : `<p class="cl-note">No shapes yet — add one above.</p>`}
|
|
11260
|
+
${slider("trimStart", 0, 100, 0.5, "%")}${slider("trimEnd", 0, 100, 0.5, "%")}${slider("trimOffset", -100, 100, 0.5, "%")}`;
|
|
11261
|
+
const raw = ui.sel && Motion.getItem(list, ui.sel), now = raw && shapeNow(c);
|
|
11262
|
+
if (raw && now) {
|
|
11263
|
+
const t = shapeLocalT(c), j = shapeKeyIndex(raw.keys, t);
|
|
11264
|
+
const kbtn = (k) => `<button type="button" class="kf-btn${isAnimated(raw, k) ? " has" : ""}${j >= 0 && raw.keys[j][k] != null ? " on" : ""}" data-skey="${k}" title="Key this value at the playhead / remove the key there">◆</button>`;
|
|
11265
|
+
const srow = (k) => {
|
|
11266
|
+
const [lab, min, max, step] = SHAPE_ROWS_UI[k];
|
|
11267
|
+
return `<div class="insp-row cp-row"><label class="cp-label">${lab}</label>
|
|
11268
|
+
<input type="range" data-sk="${k}" min="${min}" max="${max}" step="${step}" value="${now[k]}">
|
|
11269
|
+
<input type="number" class="cp-num" data-sknum="${k}" step="${step}" value="${fmtG(now[k], step)}">${kbtn(k)}</div>`;
|
|
11270
|
+
};
|
|
11271
|
+
const color = (k, lab) => `<div class="insp-row"><label>${lab}</label><span class="insp-ctrls">
|
|
11272
|
+
<input type="color" data-scolor="${k}" value="${now[k] && now[k] !== "none" ? now[k].slice(0, 7) : "#ffffff"}">
|
|
11273
|
+
${btn(`data-snone="${k}"`, "None", `No ${lab.toLowerCase()}`, now[k] === "none")}${kbtn(k)}</span></div>`;
|
|
11274
|
+
const path = ui.sel, inGroup = path.length > 1, siblings = inGroup ? Motion.getItem(list, path.slice(0, -1)).items : list, i = path[path.length - 1];
|
|
11275
|
+
const own = Motion.OWN[raw.type];
|
|
11276
|
+
html += `<div class="cl-row"><input type="text" class="cl-name" data-sname value="${escapeHtml(raw.name || "")}" placeholder="${escapeHtml(shapeItemLabel({ type: raw.type }, path))}" maxlength="40" title="Item name">
|
|
11277
|
+
${btn('data-sact="on"', raw.on === false ? "Off" : "On", "Item on / off", raw.on !== false)}
|
|
11278
|
+
${btn('data-sact="up"', "↑", "Move down the stack (drawn earlier)", false, i === 0)}
|
|
11279
|
+
${btn('data-sact="down"', "↓", "Move up the stack (drawn later)", false, i >= siblings.length - 1)}
|
|
11280
|
+
${btn('data-sact="dup"', "Copy", "Duplicate", false, full)}
|
|
11281
|
+
${btn('data-sact="group"', "Group", "Put this item in a new group (move, turn, scale and trim them together)", false, full || path.length >= Motion.MAX_DEPTH)}
|
|
11282
|
+
${raw.type === "group" ? btn('data-sact="ungroup"', "Ungroup", "Take the items out of this group") : ""}
|
|
11283
|
+
${inGroup ? btn('data-sact="out"', "Out", "Move this item out of its group") : ""}
|
|
11284
|
+
${btn('data-sact="del"', "Delete", "Delete this item")}
|
|
11285
|
+
${raw.keys ? btn('data-sact="clearkeys"', "Clear keys", "Remove every key (keeps the item as it is at the playhead)") : ""}</div>
|
|
11286
|
+
${srow("x")}${srow("y")}${srow("rotation")}${srow("scale")}${srow("opacity")}
|
|
11287
|
+
${own.includes("w") ? srow("w") + srow("h") : ""}${own.includes("radius") ? srow("radius") : ""}
|
|
11288
|
+
${own.includes("sides") ? `<div class="insp-row"><label>Points</label><input type="number" data-ssides min="3" max="64" step="1" value="${raw.sides}"></div>` : ""}
|
|
11289
|
+
${own.includes("inner") ? srow("inner") : ""}
|
|
11290
|
+
${raw.type === "path" ? `<div class="insp-row"><label>Closed</label><input type="checkbox" data-sclosed ${raw.closed !== false ? "checked" : ""}>${kbtn("points")}</div>` : ""}
|
|
11291
|
+
${raw.type !== "group" ? color("fill", "Fill") + color("stroke", "Stroke") + srow("strokeWidth") +
|
|
11292
|
+
`<div class="insp-row"><label>Ends / joins</label><span class="insp-ctrls"><select data-scap>${Motion.CAPS.map((v) => `<option ${raw.lineCap === v ? "selected" : ""}>${v}</option>`).join("")}</select>
|
|
11293
|
+
<select data-sjoin>${Motion.JOINS.map((v) => `<option ${raw.lineJoin === v ? "selected" : ""}>${v}</option>`).join("")}</select></span></div>` : ""}
|
|
11294
|
+
${srow("trimStart")}${srow("trimEnd")}${srow("trimOffset")}
|
|
11295
|
+
<p class="cl-note">On the monitor: drag the item to move it, the knob turns it (Shift: 15° steps), the corners resize it${raw.type === "line" || raw.type === "path"
|
|
11296
|
+
? "; drag a point to move it, its handles to curve it (Alt: break the pair), Alt-drag a point to pull new handles" + (raw.type === "path" ? ", Ctrl-click the outline to add a point, double-click a point to remove it" : "")
|
|
11297
|
+
: ""}. Esc deselects.</p>`;
|
|
11298
|
+
}
|
|
11299
|
+
return html + `</div>`;
|
|
11300
|
+
}
|
|
11301
|
+
function bindShapeControls(c) {
|
|
11302
|
+
const root = els.inspector.querySelector("[data-shape-section]");
|
|
11303
|
+
if (!root) return;
|
|
11304
|
+
const guard = () => { if (isGroupLocked(c)) { toastLocked(); return false; } return true; };
|
|
11305
|
+
const rerender = () => { state.dirtyTimeline = true; renderInspector(); };
|
|
11306
|
+
const sel = () => shapeUi.sel;
|
|
11307
|
+
const edit = (set) => { writeShapeSet(c, sel(), set); scheduleSave(); };
|
|
11308
|
+
for (const b of root.querySelectorAll("[data-sadd]")) b.addEventListener("click", () => {
|
|
11309
|
+
if (!guard()) return;
|
|
11310
|
+
pushUndo();
|
|
11311
|
+
const list = shapeList(c).slice();
|
|
11312
|
+
list.push(SHAPE_PRESETS[b.dataset.sadd].item);
|
|
11313
|
+
writeShapes(c, list);
|
|
11314
|
+
Object.assign(shapeUi, { sel: [shapeList(c).length - 1], pt: -1, tool: null, pen: null });
|
|
11315
|
+
maskUi.sel = -1;
|
|
11316
|
+
scheduleSave(); rerender();
|
|
11317
|
+
});
|
|
11318
|
+
root.querySelector('[data-stool="pen"]')?.addEventListener("click", () => {
|
|
11319
|
+
if (!guard()) return;
|
|
11320
|
+
shapeUi.tool = shapeUi.tool === "pen" ? null : "pen"; shapeUi.pen = null; shapeUi.sel = null; maskUi.sel = -1; maskUi.tool = null;
|
|
11321
|
+
if (shapeUi.tool && state.playing) pause();
|
|
11322
|
+
if (shapeUi.tool && !activeAt(c, state.time)) toast("Move the playhead over the clip, then draw on the monitor");
|
|
11323
|
+
rerender();
|
|
11324
|
+
});
|
|
11325
|
+
for (const b of root.querySelectorAll("[data-ssel]")) b.addEventListener("click", () => {
|
|
11326
|
+
const path = b.dataset.ssel.split(".").map(Number);
|
|
11327
|
+
shapeUi.sel = samePath(shapeUi.sel, path) ? null : path; shapeUi.pt = -1; shapeUi.tool = null;
|
|
11328
|
+
if (shapeUi.sel) { maskUi.sel = -1; maskUi.tool = null; }
|
|
11329
|
+
rerender();
|
|
11330
|
+
});
|
|
11331
|
+
for (const b of root.querySelectorAll("[data-sact]")) b.addEventListener("click", () => {
|
|
11332
|
+
const a = b.dataset.sact, path = sel(), list = shapeList(c), it = path && Motion.getItem(list, path);
|
|
11333
|
+
if (!it || !guard()) return;
|
|
11334
|
+
pushUndo();
|
|
11335
|
+
const parentPath = path.slice(0, -1), i = path[path.length - 1];
|
|
11336
|
+
const withSiblings = (fn) => parentPath.length
|
|
11337
|
+
? Motion.updateItem(list, parentPath, (g) => ({ ...g, items: fn(g.items.slice()) }))
|
|
11338
|
+
: fn(list.slice());
|
|
11339
|
+
let next = list;
|
|
11340
|
+
if (a === "on") next = Motion.updateItem(list, path, (x) => Motion.mergeItem(x, { on: x.on === false ? null : false }));
|
|
11341
|
+
else if (a === "del") { next = Motion.updateItem(list, path, () => null); shapeUi.sel = null; }
|
|
11342
|
+
else if (a === "dup") { next = withSiblings((s) => { s.splice(i + 1, 0, JSON.parse(JSON.stringify({ ...it, name: it.name ? `${it.name} copy`.slice(0, 40) : undefined }))); return s; }); shapeUi.sel = [...parentPath, i + 1]; }
|
|
11343
|
+
else if (a === "up" || a === "down") {
|
|
11344
|
+
const j = a === "up" ? i - 1 : i + 1;
|
|
11345
|
+
next = withSiblings((s) => { [s[i], s[j]] = [s[j], s[i]]; return s; });
|
|
11346
|
+
shapeUi.sel = [...parentPath, j];
|
|
11347
|
+
} else if (a === "group") next = Motion.updateItem(list, path, (x) => ({ type: "group", x: x.x, y: x.y, items: [{ ...x, x: 0, y: 0 }] }));
|
|
11348
|
+
else if (a === "ungroup") {
|
|
11349
|
+
// the children keep their place: fold the group's transform into each (position, rotation, scale)
|
|
11350
|
+
const M = Motion.itemMatrix(it);
|
|
11351
|
+
const kids = (it.items || []).map((k) => {
|
|
11352
|
+
const m = Motion.mul(M, Motion.itemMatrix(k));
|
|
11353
|
+
return { ...k, x: +m[4].toFixed(3), y: +m[5].toFixed(3), rotation: +(Math.atan2(m[1], m[0]) * 180 / Math.PI).toFixed(3),
|
|
11354
|
+
scale: +Math.hypot(m[0], m[1]).toFixed(4), opacity: +((k.opacity ?? 1) * (it.opacity ?? 1)).toFixed(4) };
|
|
11355
|
+
});
|
|
11356
|
+
next = withSiblings((s) => { s.splice(i, 1, ...kids); return s; });
|
|
11357
|
+
shapeUi.sel = kids.length ? [...parentPath, i] : null;
|
|
11358
|
+
} else if (a === "out") {
|
|
11359
|
+
const gm = Motion.itemMatrix(Motion.getItem(list, parentPath)), m = Motion.mul(gm, Motion.itemMatrix(it));
|
|
11360
|
+
const moved = { ...it, x: +m[4].toFixed(3), y: +m[5].toFixed(3), rotation: +(Math.atan2(m[1], m[0]) * 180 / Math.PI).toFixed(3), scale: +Math.hypot(m[0], m[1]).toFixed(4) };
|
|
11361
|
+
const gp = parentPath.slice(0, -1), gi = parentPath[parentPath.length - 1];
|
|
11362
|
+
next = Motion.updateItem(list, path, () => null);
|
|
11363
|
+
next = gp.length ? Motion.updateItem(next, gp, (g) => { const s = g.items.slice(); s.splice(gi + 1, 0, moved); return { ...g, items: s }; })
|
|
11364
|
+
: (() => { const s = next.slice(); s.splice(gi + 1, 0, moved); return s; })();
|
|
11365
|
+
shapeUi.sel = [...gp, gi + 1];
|
|
11366
|
+
} else if (a === "clearkeys") {
|
|
11367
|
+
const now = shapeNow(c, path), set = { keys: null };
|
|
11368
|
+
for (const k of Motion.KEYED) if (isAnimated(it, k)) set[k] = k === "points" ? now.points.map((q) => q.map((v) => +v.toFixed(3))) : now[k];
|
|
11369
|
+
next = Motion.updateItem(list, path, (x) => Motion.mergeItem(x, set));
|
|
11370
|
+
}
|
|
11371
|
+
writeShapes(c, next); shapeUi.pt = -1; scheduleSave(); rerender();
|
|
11372
|
+
});
|
|
11373
|
+
for (const b of root.querySelectorAll("[data-skey]")) b.addEventListener("click", () => {
|
|
11374
|
+
if (!guard() || !sel()) return;
|
|
11375
|
+
if (!playheadOverClip(c)) { toast("Move the playhead over the clip to key it"); return; }
|
|
11376
|
+
pushUndo(); toggleShapeKey(c, sel(), b.dataset.skey); scheduleSave(); rerender();
|
|
11377
|
+
});
|
|
11378
|
+
root.querySelector("[data-sname]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ name: e.target.value.trim() || null }); rerender(); });
|
|
11379
|
+
root.querySelector("[data-ssides]")?.addEventListener("change", (e) => { const v = Math.round(+e.target.value); if (!guard() || !(v >= 3 && v <= 64)) return; pushUndo(); edit({ sides: v }); rerender(); });
|
|
11380
|
+
root.querySelector("[data-sclosed]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ closed: e.target.checked ? null : false }); rerender(); });
|
|
11381
|
+
root.querySelector("[data-scap]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ lineCap: e.target.value }); rerender(); });
|
|
11382
|
+
root.querySelector("[data-sjoin]")?.addEventListener("change", (e) => { if (!guard()) return; pushUndo(); edit({ lineJoin: e.target.value }); rerender(); });
|
|
11383
|
+
for (const inp of root.querySelectorAll("[data-scolor]")) {
|
|
11384
|
+
const k = inp.dataset.scolor;
|
|
11385
|
+
inp.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
|
|
11386
|
+
inp.addEventListener("input", () => {
|
|
11387
|
+
if (isGroupLocked(c)) return;
|
|
11388
|
+
const set = { [k]: inp.value };
|
|
11389
|
+
if (k === "stroke" && !(shapeNow(c)?.strokeWidth > 0)) set.strokeWidth = 6;
|
|
11390
|
+
edit(set);
|
|
11391
|
+
});
|
|
11392
|
+
inp.addEventListener("change", rerender);
|
|
11393
|
+
}
|
|
11394
|
+
for (const b of root.querySelectorAll("[data-snone]")) b.addEventListener("click", () => {
|
|
11395
|
+
if (!guard()) return;
|
|
11396
|
+
pushUndo(); edit({ [b.dataset.snone]: "none" }); rerender();
|
|
11397
|
+
});
|
|
11398
|
+
for (const r of root.querySelectorAll("[data-sk]")) {
|
|
11399
|
+
const k = r.dataset.sk;
|
|
11400
|
+
r.addEventListener("pointerdown", () => { if (guard()) pushUndo(); });
|
|
11401
|
+
r.addEventListener("input", () => {
|
|
11402
|
+
if (isGroupLocked(c)) return;
|
|
11403
|
+
edit({ [k]: +r.value });
|
|
11404
|
+
const n = root.querySelector(`[data-sknum="${k}"]`); if (n) n.value = fmtG(+r.value, SHAPE_ROWS_UI[k][3]);
|
|
11405
|
+
});
|
|
11406
|
+
}
|
|
11407
|
+
for (const n of root.querySelectorAll("[data-sknum]")) {
|
|
11408
|
+
const k = n.dataset.sknum;
|
|
11409
|
+
n.addEventListener("change", () => {
|
|
11410
|
+
const x = parseFloat(n.value);
|
|
11411
|
+
if (!Number.isFinite(x) || !guard()) return;
|
|
11412
|
+
pushUndo(); edit({ [k]: x }); rerender();
|
|
11413
|
+
document.activeElement?.blur?.();
|
|
11414
|
+
});
|
|
11415
|
+
}
|
|
11416
|
+
}
|
|
11417
|
+
/** Animated items: the rows and ◆ follow the playhead (called from the inspector sync). */
|
|
11418
|
+
function syncShapeInspector(c) {
|
|
11419
|
+
const root = els.inspector.querySelector("[data-shape-section]");
|
|
11420
|
+
if (!root || shapeUi.clip !== c.id || !shapeUi.sel || shapeUi.drag) return;
|
|
11421
|
+
const raw = Motion.getItem(editList(c), shapeUi.sel), now = shapeNow(c);
|
|
11422
|
+
if (!raw || !now) return;
|
|
11423
|
+
const active = document.activeElement, j = shapeKeyIndex(raw.keys, shapeLocalT(c));
|
|
11424
|
+
for (const k of Object.keys(SHAPE_ROWS_UI)) {
|
|
11425
|
+
const r = root.querySelector(`[data-sk="${k}"]`), n = root.querySelector(`[data-sknum="${k}"]`);
|
|
11426
|
+
if (r && r !== active) r.value = now[k];
|
|
11427
|
+
if (n && n !== active) n.value = fmtG(now[k], SHAPE_ROWS_UI[k][3]);
|
|
11428
|
+
}
|
|
11429
|
+
for (const b of root.querySelectorAll("[data-skey]")) b.classList.toggle("on", j >= 0 && raw.keys[j][b.dataset.skey] != null);
|
|
11430
|
+
for (const inp of root.querySelectorAll("[data-scolor]")) {
|
|
11431
|
+
const v = now[inp.dataset.scolor];
|
|
11432
|
+
if (inp !== active && v && v !== "none") inp.value = v.slice(0, 7);
|
|
11433
|
+
}
|
|
11434
|
+
}
|
|
11435
|
+
|
|
11436
|
+
/* ── Shape items on the monitor ── */
|
|
11437
|
+
function shapeEditActive(c) {
|
|
11438
|
+
return !!c && (c.kind === "shape" || (c.kind === "text" && !!clipTextPath(c)) || (c.kind === "text" && shapeUi.tool === "pen")) && shapeUi.clip === c.id && state.selId === c.id && (!!shapeUi.sel || !!shapeUi.tool) &&
|
|
11439
|
+
activeAt(c, state.time) && !state.playing && !maskEditActive(c);
|
|
11440
|
+
}
|
|
11441
|
+
/** Layer px → canvas px for a shape clip at the playhead. */
|
|
11442
|
+
function shapeLayerMatrix(c, W, H, t = state.time) {
|
|
11443
|
+
const p = evalProps(c, t);
|
|
11444
|
+
return layerScreenMatrix(c, p, W, H, t).scale(Motion.contentScale(c, p));
|
|
11445
|
+
}
|
|
11446
|
+
/* The item list the editor works on: a shape layer's items, or a title's path as a one-item list. */
|
|
11447
|
+
function editList(c) { return c.kind === "text" ? (clipTextPath(c) ? [clipTextPath(c)] : []) : shapeList(c); }
|
|
11448
|
+
function editItemsAt(c, t) { return c.kind === "text" ? (clipTextPath(c) ? [textPathAt(c, t)] : null) : shapeItemsAt(c, t); }
|
|
11449
|
+
function writeEditList(c, list) {
|
|
11450
|
+
if (c.kind !== "text") { writeShapes(c, list); return; }
|
|
11451
|
+
const n = list && list[0] ? Motion.normalizeTextPath(list[0]) : null;
|
|
11452
|
+
if (n) c.props.textPath = n; else delete c.props.textPath;
|
|
11453
|
+
state.dirtyTimeline = true; inspPropGen++;
|
|
11454
|
+
}
|
|
11455
|
+
/** The picked item's frames and handles in canvas px. */
|
|
11456
|
+
function shapeHandles(c, W, H) {
|
|
11457
|
+
const items = editItemsAt(c, state.time), path = shapeUi.sel, it = path && Motion.getItem(items, path);
|
|
11458
|
+
if (!it) return null;
|
|
11459
|
+
const L = shapeLayerMatrix(c, W, H);
|
|
11460
|
+
const F = L.multiply(new DOMMatrix(Motion.itemFrame(items, path.slice(0, -1)))); // the space the item's x / y live in
|
|
11461
|
+
const I = F.multiply(new DOMMatrix(Motion.itemMatrix(it))); // the item's own frame
|
|
11462
|
+
const own = it.type === "group" ? Motion.bounds(it.items) : Motion.bounds([{ ...it, x: 0, y: 0, rotation: 0, scale: 1, opacity: 1, on: true }]);
|
|
11463
|
+
const b = own || { x0: -20, y0: -20, x1: 20, y1: 20 };
|
|
11464
|
+
const at = (x, y) => { const q = I.transformPoint(new DOMPoint(x, y)); return { x: q.x, y: q.y }; };
|
|
11465
|
+
const corners = [[b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]].map(([x, y]) => ({ ...at(x, y), lx: x, ly: y }));
|
|
11466
|
+
const topMid = at((b.x0 + b.x1) / 2, b.y0), up = at((b.x0 + b.x1) / 2, b.y0 - 1), d = Math.hypot(up.x - topMid.x, up.y - topMid.y) || 1;
|
|
11467
|
+
const gap = Math.max(24, W / 34), inset = Math.max(8, W / 110);
|
|
11468
|
+
const knob = { x: clamp(topMid.x + (up.x - topMid.x) / d * gap, inset, W - inset), y: clamp(topMid.y + (up.y - topMid.y) / d * gap, inset, H - inset) };
|
|
11469
|
+
const ol = it.type === "group" ? null : Motion.outline(it);
|
|
11470
|
+
const A = ol ? ol.A.map((a) => ({ a: at(a.x, a.y), i: at(a.ix, a.iy), o: at(a.ox, a.oy) })) : [];
|
|
11471
|
+
return { it, F, I, corners, topMid, knob, centre: at(0, 0), outline: ol, A, editable: it.type === "line" || it.type === "path" };
|
|
11472
|
+
}
|
|
11473
|
+
function drawShapeOverlay(c, W, H) {
|
|
11474
|
+
const lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
|
|
11475
|
+
const L = shapeLayerMatrix(c, W, H);
|
|
11476
|
+
ctx2d.save();
|
|
11477
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
11478
|
+
ctx2d.lineJoin = "round";
|
|
11479
|
+
const dot = (p, r, fill, square) => {
|
|
11480
|
+
ctx2d.beginPath();
|
|
11481
|
+
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);
|
|
11482
|
+
ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.7)"; ctx2d.stroke();
|
|
11483
|
+
};
|
|
11484
|
+
const h = shapeUi.sel ? shapeHandles(c, W, H) : null;
|
|
11485
|
+
if (h) {
|
|
11486
|
+
if (h.outline) {
|
|
11487
|
+
const pts = Motion.flatten(h.outline.A, h.outline.closed, 24).map(([x, y]) => { const q = h.I.transformPoint(new DOMPoint(x, y)); return [q.x, q.y]; });
|
|
11488
|
+
ctx2d.lineWidth = lw * 2.2; ctx2d.strokeStyle = "rgba(0,0,0,0.55)"; strokeOutline(ctx2d, pts, h.outline.closed);
|
|
11489
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#5ad1ff"; strokeOutline(ctx2d, pts, h.outline.closed);
|
|
11490
|
+
}
|
|
11491
|
+
ctx2d.setLineDash([lw * 4, lw * 3]); ctx2d.strokeStyle = "#4f8cff";
|
|
11492
|
+
strokeOutline(ctx2d, h.corners.map((q) => [q.x, q.y]), true);
|
|
11493
|
+
ctx2d.setLineDash([]);
|
|
11494
|
+
ctx2d.beginPath(); ctx2d.moveTo(h.topMid.x, h.topMid.y); ctx2d.lineTo(h.knob.x, h.knob.y); ctx2d.stroke();
|
|
11495
|
+
for (const q of h.corners) dot(q, hs * 0.8, "#fff", true);
|
|
11496
|
+
if (h.editable) {
|
|
11497
|
+
const sp = h.A[shapeUi.pt];
|
|
11498
|
+
if (sp) {
|
|
11499
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#9fd0ff";
|
|
11500
|
+
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(); }
|
|
11501
|
+
dot(sp.i, hs * 0.6, "#9fd0ff"); dot(sp.o, hs * 0.6, "#9fd0ff");
|
|
11502
|
+
}
|
|
11503
|
+
h.A.forEach((p, j) => dot(p.a, hs * 0.62, j === shapeUi.pt ? "#5ad1ff" : "#fff", true));
|
|
11504
|
+
}
|
|
11505
|
+
dot(h.centre, hs * 0.5, "#5ad1ff");
|
|
11506
|
+
dot(h.knob, hs * 0.9, "#ffce5c");
|
|
11507
|
+
}
|
|
11508
|
+
if (shapeUi.tool === "pen" && shapeUi.pen && shapeUi.pen.pts.length) {
|
|
11509
|
+
const P = shapeUi.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] }));
|
|
11510
|
+
const open = Motion.flatten(A, false, 24).map(([x, y]) => { const q = L.transformPoint(new DOMPoint(x, y)); return [q.x, q.y]; });
|
|
11511
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#5ad1ff";
|
|
11512
|
+
if (A.length > 1) strokeOutline(ctx2d, open, false);
|
|
11513
|
+
A.forEach((a, j) => { const q = L.transformPoint(new DOMPoint(a.x, a.y)); dot(q, hs * (j === 0 && A.length >= 3 ? 0.9 : 0.62), j === 0 ? "#5ad1ff" : "#fff", true); });
|
|
11514
|
+
}
|
|
11515
|
+
ctx2d.restore();
|
|
11516
|
+
return true;
|
|
11517
|
+
}
|
|
11518
|
+
/** Finish the pen: a path item around the points' centre (closed when it was closed on the first point). */
|
|
11519
|
+
function finishShapePen(c, closed) {
|
|
11520
|
+
const P = shapeUi.pen && shapeUi.pen.pts;
|
|
11521
|
+
if (!P || P.length < (closed ? 3 : 2)) { toast(closed ? "A closed path needs at least 3 points" : "A path needs at least 2 points"); return; }
|
|
11522
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
11523
|
+
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); }
|
|
11524
|
+
const cx = Math.round((x0 + x1) / 2), cy = Math.round((y0 + y1) / 2);
|
|
11525
|
+
const item = { type: "path", x: cx, y: cy, points: P.slice(0, Motion.MAX_POINTS).map(([x, y, ix, iy, ox, oy]) => [x - cx, y - cy, ix, iy, ox, oy].map((v) => +v.toFixed(2))) };
|
|
11526
|
+
if (c.kind === "text") { if (!closed) item.closed = false; }
|
|
11527
|
+
else if (closed) Object.assign(item, { fill: "#ffffff" }); else Object.assign(item, { closed: false, stroke: "#ffffff", strokeWidth: 8 });
|
|
11528
|
+
pushUndo();
|
|
11529
|
+
const list = c.kind === "text" ? [] : editList(c).slice();
|
|
11530
|
+
list.push(item);
|
|
11531
|
+
writeEditList(c, list);
|
|
11532
|
+
Object.assign(shapeUi, { sel: [editList(c).length - 1], pt: -1, tool: null, pen: null });
|
|
11533
|
+
scheduleSave(); renderInspector();
|
|
11534
|
+
}
|
|
11535
|
+
function nearestShapeSegment(h, pt) {
|
|
11536
|
+
let best = null;
|
|
11537
|
+
Motion.segments(h.outline.A, h.outline.closed).forEach(([a, b], i) => {
|
|
11538
|
+
for (let k = 1; k < 24; k++) {
|
|
11539
|
+
const t = k / 24, u = 1 - t;
|
|
11540
|
+
const x = u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x;
|
|
11541
|
+
const y = u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y;
|
|
11542
|
+
const q = h.I.transformPoint(new DOMPoint(x, y)), d = Math.hypot(q.x - pt.x, q.y - pt.y);
|
|
11543
|
+
if (!best || d < best.d) best = { d, i, t };
|
|
11544
|
+
}
|
|
11545
|
+
});
|
|
11546
|
+
return best;
|
|
11547
|
+
}
|
|
11548
|
+
/** Add or remove points of a path: the base points and every keyed outline change together. */
|
|
11549
|
+
function writeShapeTopology(c, path, edit) {
|
|
11550
|
+
writeEditList(c, Motion.updateItem(editList(c), path, (it) => Motion.mergeItem(it, {
|
|
11551
|
+
points: edit(it.points), keys: it.keys ? it.keys.map((k) => (k.points ? { ...k, points: edit(k.points) } : k)) : null,
|
|
11552
|
+
}) || it));
|
|
11553
|
+
}
|
|
11554
|
+
els.preview.addEventListener("pointerdown", (e) => {
|
|
11555
|
+
if (e.button !== 0 || isSourceMode()) return;
|
|
11556
|
+
const c = getClip(state.selId);
|
|
11557
|
+
if (!shapeEditActive(c)) return;
|
|
11558
|
+
const W = els.preview.width, H = els.preview.height, pt = canvasPt(e), grab = Math.max(8, W / 110);
|
|
11559
|
+
const near = (p) => Math.hypot(p.x - pt.x, p.y - pt.y) <= grab;
|
|
11560
|
+
if (shapeUi.tool === "pen") {
|
|
11561
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11562
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
11563
|
+
const L = shapeLayerMatrix(c, W, H), lp = L.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
|
|
11564
|
+
const P = (shapeUi.pen ||= { pts: [] }).pts;
|
|
11565
|
+
if (P.length >= 3 && near(L.transformPoint(new DOMPoint(P[0][0], P[0][1])))) { finishShapePen(c, true); return; }
|
|
11566
|
+
if (P.length >= Motion.MAX_POINTS) { toast(`A path has at most ${Motion.MAX_POINTS} points`); return; }
|
|
11567
|
+
P.push([lp.x, lp.y, 0, 0, 0, 0]);
|
|
11568
|
+
shapeUi.pen.redo = []; shapeUi.pen.dragging = true;
|
|
11569
|
+
shapeUi.drag = { tool: true, L };
|
|
11570
|
+
els.preview.setPointerCapture(e.pointerId);
|
|
11571
|
+
state.dirtyTimeline = true;
|
|
11572
|
+
return;
|
|
11573
|
+
}
|
|
11574
|
+
const h = shapeHandles(c, W, H);
|
|
11575
|
+
if (!h) return;
|
|
11576
|
+
const it = h.it, sp = h.editable ? h.A[shapeUi.pt] : null;
|
|
11577
|
+
const lp = h.I.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
|
|
11578
|
+
let drag = null;
|
|
11579
|
+
if (near(h.knob)) drag = { mode: "rotate", a0: Math.atan2(pt.y - h.centre.y, pt.x - h.centre.x) };
|
|
11580
|
+
else if (sp && near(sp.o)) drag = { mode: "handle", side: "o" };
|
|
11581
|
+
else if (sp && near(sp.i)) drag = { mode: "handle", side: "i" };
|
|
11582
|
+
else if (h.editable && h.A.some((p) => near(p.a))) { shapeUi.pt = h.A.findIndex((p) => near(p.a)); drag = { mode: e.altKey ? "pull" : "point" }; }
|
|
11583
|
+
else if (h.corners.some(near)) { const q = h.corners.find(near); drag = { mode: "size", lx0: q.lx, ly0: q.ly, d0: Math.hypot(pt.x - h.centre.x, pt.y - h.centre.y) || 1 }; }
|
|
11584
|
+
else if (it.type === "path" && (e.ctrlKey || e.metaKey)) {
|
|
11585
|
+
if (it.points.length >= Motion.MAX_POINTS) { toast(`A path has at most ${Motion.MAX_POINTS} points`); e.stopImmediatePropagation(); return; }
|
|
11586
|
+
if (isGroupLocked(c)) { toastLocked(); e.stopImmediatePropagation(); return; }
|
|
11587
|
+
const r = nearestShapeSegment(h, pt);
|
|
11588
|
+
if (!r || r.d > grab * 3) return;
|
|
11589
|
+
pushUndo(); writeShapeTopology(c, shapeUi.sel, (pts) => Mask.splitPoints(pts, r.i, r.t)); scheduleSave();
|
|
11590
|
+
shapeUi.pt = r.i + 1;
|
|
11591
|
+
drag = { mode: "point", pushed: true };
|
|
11592
|
+
} else {
|
|
11593
|
+
const xs = h.corners.map((q) => q.lx), ys = h.corners.map((q) => q.ly);
|
|
11594
|
+
if (lp.x >= Math.min(...xs) && lp.x <= Math.max(...xs) && lp.y >= Math.min(...ys) && lp.y <= Math.max(...ys)) drag = { mode: "move" };
|
|
11595
|
+
}
|
|
11596
|
+
if (!drag) return; // outside the item: the monitor's usual clicks
|
|
11597
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11598
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
11599
|
+
if (!drag.pushed) pushUndo();
|
|
11600
|
+
shapeUi.drag = { ...drag, start: pt, it0: JSON.parse(JSON.stringify(shapeNow(c))), F: h.F, I: h.I };
|
|
11601
|
+
els.preview.setPointerCapture(e.pointerId);
|
|
11602
|
+
state.dirtyTimeline = true;
|
|
11603
|
+
}, true);
|
|
11604
|
+
els.preview.addEventListener("pointermove", (e) => {
|
|
11605
|
+
const d = shapeUi.drag;
|
|
11606
|
+
if (!d) return;
|
|
11607
|
+
e.stopImmediatePropagation();
|
|
11608
|
+
const c = getClip(state.selId);
|
|
11609
|
+
if (!c) return;
|
|
11610
|
+
const pt = canvasPt(e);
|
|
11611
|
+
if (d.tool) {
|
|
11612
|
+
const P = shapeUi.pen && shapeUi.pen.pts, p = P && P[P.length - 1];
|
|
11613
|
+
if (p && shapeUi.pen.dragging) {
|
|
11614
|
+
const q = d.L.inverse().transformPoint(new DOMPoint(pt.x, pt.y)), ox = q.x - p[0], oy = q.y - p[1];
|
|
11615
|
+
if (Math.hypot(ox, oy) > 3) { p[2] = -ox; p[3] = -oy; p[4] = ox; p[5] = oy; }
|
|
11616
|
+
}
|
|
11617
|
+
state.dirtyTimeline = true;
|
|
11618
|
+
return;
|
|
11619
|
+
}
|
|
11620
|
+
const it0 = d.it0, r3 = (v) => +v.toFixed(3);
|
|
11621
|
+
let set = null;
|
|
11622
|
+
if (d.mode === "move") {
|
|
11623
|
+
const Fi = d.F.inverse(), a = Fi.transformPoint(new DOMPoint(d.start.x, d.start.y)), b = Fi.transformPoint(new DOMPoint(pt.x, pt.y));
|
|
11624
|
+
set = { x: Math.round(it0.x + b.x - a.x), y: Math.round(it0.y + b.y - a.y) };
|
|
11625
|
+
} else if (d.mode === "rotate") {
|
|
11626
|
+
const c0 = d.I.transformPoint(new DOMPoint(0, 0));
|
|
11627
|
+
let r = it0.rotation + (Math.atan2(pt.y - c0.y, pt.x - c0.x) - d.a0) * 180 / Math.PI;
|
|
11628
|
+
if (e.shiftKey) r = Math.round(r / 15) * 15;
|
|
11629
|
+
set = { rotation: r3(r) };
|
|
11630
|
+
} else if (d.mode === "size") {
|
|
11631
|
+
const l = d.I.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
|
|
11632
|
+
if (Motion.OWN[it0.type].includes("w")) {
|
|
11633
|
+
let kx = Math.abs(l.x) / Math.max(1e-6, Math.abs(d.lx0)), ky = Math.abs(l.y) / Math.max(1e-6, Math.abs(d.ly0));
|
|
11634
|
+
if (e.shiftKey) kx = ky = Math.max(kx, ky);
|
|
11635
|
+
set = { w: Math.round(clamp(it0.w * kx, 1, 20000)), h: Math.round(clamp(it0.h * ky, 1, 20000)) };
|
|
11636
|
+
} else {
|
|
11637
|
+
const c0 = d.I.transformPoint(new DOMPoint(0, 0));
|
|
11638
|
+
set = { scale: +clamp(it0.scale * Math.hypot(pt.x - c0.x, pt.y - c0.y) / d.d0, 0.01, 100).toFixed(4) };
|
|
11639
|
+
}
|
|
11640
|
+
} else if (it0.points) {
|
|
11641
|
+
const pts = it0.points.map((p) => [p[0], p[1], p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0]), p = pts[shapeUi.pt];
|
|
11642
|
+
if (!p) return;
|
|
11643
|
+
const l = d.I.inverse().transformPoint(new DOMPoint(pt.x, pt.y));
|
|
11644
|
+
if (d.mode === "point") { p[0] = l.x; p[1] = l.y; }
|
|
11645
|
+
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; }
|
|
11646
|
+
else if (d.mode === "handle") {
|
|
11647
|
+
const hx = l.x - p[0], hy = l.y - p[1], o = d.side === "o" ? 4 : 2, q = d.side === "o" ? 2 : 4;
|
|
11648
|
+
p[o] = hx; p[o + 1] = hy;
|
|
11649
|
+
if (!e.altKey) { // keep the pair smooth: the other handle mirrors the direction, keeps its length
|
|
11650
|
+
const len = Math.hypot(p[q], p[q + 1]) || Math.hypot(hx, hy), k = Math.hypot(hx, hy) || 1;
|
|
11651
|
+
p[q] = -hx / k * len; p[q + 1] = -hy / k * len;
|
|
11652
|
+
}
|
|
11653
|
+
}
|
|
11654
|
+
set = { points: pts.map((r) => r.map(r3)) };
|
|
11655
|
+
}
|
|
11656
|
+
if (set) { writeShapeSet(c, shapeUi.sel, set); syncShapeInspector(c); }
|
|
11657
|
+
}, true);
|
|
11658
|
+
const endShapeDrag = (e) => {
|
|
11659
|
+
const d = shapeUi.drag;
|
|
11660
|
+
if (!d) return;
|
|
11661
|
+
e.stopImmediatePropagation();
|
|
11662
|
+
shapeUi.drag = null;
|
|
11663
|
+
if (d.tool) { if (shapeUi.pen) shapeUi.pen.dragging = false; state.dirtyTimeline = true; return; }
|
|
11664
|
+
scheduleSave(); renderInspector();
|
|
11665
|
+
};
|
|
11666
|
+
els.preview.addEventListener("pointerup", endShapeDrag, true);
|
|
11667
|
+
els.preview.addEventListener("pointercancel", endShapeDrag, true);
|
|
11668
|
+
els.preview.addEventListener("dblclick", (e) => {
|
|
11669
|
+
const c = getClip(state.selId);
|
|
11670
|
+
if (!shapeEditActive(c) || shapeUi.tool) return;
|
|
11671
|
+
const h = shapeHandles(c, els.preview.width, els.preview.height), pt = canvasPt(e), grab = Math.max(8, els.preview.width / 110);
|
|
11672
|
+
if (!h || h.it.type !== "path") return;
|
|
11673
|
+
const i = h.A.findIndex((p) => Math.hypot(p.a.x - pt.x, p.a.y - pt.y) <= grab);
|
|
11674
|
+
if (i < 0) return;
|
|
11675
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11676
|
+
if (h.it.points.length <= (h.it.closed === false ? 2 : 3)) { toast("The path needs the points it has"); return; }
|
|
11677
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
11678
|
+
pushUndo();
|
|
11679
|
+
writeShapeTopology(c, shapeUi.sel, (pts) => pts.filter((_, j) => j !== i));
|
|
11680
|
+
shapeUi.pt = -1; scheduleSave(); renderInspector();
|
|
11681
|
+
}, true);
|
|
11682
|
+
window.addEventListener("keydown", (e) => {
|
|
11683
|
+
if (e.key === "Escape" && runtime.anchorTool && !shapeUi.sel && !shapeUi.tool) {
|
|
11684
|
+
const tg0 = e.target;
|
|
11685
|
+
if (!(tg0 && (tg0.tagName === "INPUT" || tg0.tagName === "TEXTAREA" || tg0.tagName === "SELECT"))) {
|
|
11686
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11687
|
+
runtime.anchorTool = false; state.dirtyTimeline = true; renderInspector();
|
|
11688
|
+
return;
|
|
11689
|
+
}
|
|
11690
|
+
}
|
|
11691
|
+
if (!shapeUi.clip || (!shapeUi.sel && !shapeUi.tool)) return;
|
|
11692
|
+
const tg = e.target;
|
|
11693
|
+
if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.tagName === "SELECT" || tg.isContentEditable)) return;
|
|
11694
|
+
const c = getClip(state.selId);
|
|
11695
|
+
if (!c || c.id !== shapeUi.clip) return;
|
|
11696
|
+
if (e.key === "Escape") {
|
|
11697
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11698
|
+
if (shapeUi.tool) { shapeUi.tool = null; shapeUi.pen = null; }
|
|
11699
|
+
else if (shapeUi.pt >= 0) shapeUi.pt = -1;
|
|
11700
|
+
else shapeUi.sel = null;
|
|
11701
|
+
state.dirtyTimeline = true; renderInspector();
|
|
11702
|
+
} else if (e.key === "Enter" && shapeUi.tool === "pen") {
|
|
11703
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11704
|
+
finishShapePen(c, false);
|
|
11705
|
+
} else if (shapeUi.tool === "pen" && shapeUi.pen && !shapeUi.drag) {
|
|
11706
|
+
// while placing points the path is not in the project yet: undo / redo step through the points
|
|
11707
|
+
const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
|
|
11708
|
+
const back = (mod && k === "z" && !e.shiftKey) || ((e.key === "Backspace" || e.key === "Delete") && !mod);
|
|
11709
|
+
const fwd = mod && ((k === "z" && e.shiftKey) || k === "y");
|
|
11710
|
+
if (!back && !fwd) return;
|
|
11711
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
11712
|
+
const P = shapeUi.pen.pts, R = (shapeUi.pen.redo ||= []);
|
|
11713
|
+
if (back && P.length) R.push(P.pop());
|
|
11714
|
+
else if (fwd && R.length) P.push(R.pop());
|
|
11715
|
+
state.dirtyTimeline = true;
|
|
11716
|
+
}
|
|
11717
|
+
}, true);
|
|
11718
|
+
|
|
11719
|
+
/* ── Text on a path ──
|
|
11720
|
+
props.textPath is one shape item (motion.js): its outline is the baseline the
|
|
11721
|
+
title's letters sit on, one line, each letter turned with the path.
|
|
11722
|
+
props.pathOffset (%, keyable) slides the text along it; props.pathAlign says
|
|
11723
|
+
which end of the text sits at the offset. The path is edited on the monitor
|
|
11724
|
+
with the shape editor (its points, handles, move, turn, size and pen). */
|
|
11725
|
+
const TEXT_PATH_PRESETS = {
|
|
11726
|
+
line: { label: "Line", item: Motion.TEXT_PATH_PRESETS.line },
|
|
11727
|
+
arc: { label: "Arc", item: Motion.TEXT_PATH_PRESETS.arc },
|
|
11728
|
+
circle: { label: "Circle", item: Motion.TEXT_PATH_PRESETS.circle },
|
|
11729
|
+
wave: { label: "Wave", item: Motion.TEXT_PATH_PRESETS.wave },
|
|
11730
|
+
};
|
|
11731
|
+
const textPathNorm = new WeakMap();
|
|
11732
|
+
function clipTextPath(c) {
|
|
11733
|
+
const v = c && c.kind === "text" && c.props && c.props.textPath;
|
|
11734
|
+
if (!v || typeof v !== "object") return null;
|
|
11735
|
+
let n = textPathNorm.get(v);
|
|
11736
|
+
if (n === undefined) { n = Motion.normalizeTextPath(v); textPathNorm.set(v, n); }
|
|
11737
|
+
return n;
|
|
11738
|
+
}
|
|
11739
|
+
/** The title's path at timeline t (keys resolved). */
|
|
11740
|
+
function textPathAt(c, t) { const n = clipTextPath(c); return n ? Motion.itemAt(n, t - c.start) : null; }
|
|
11741
|
+
/** The box of the text on its path, in the title's own px (for the selection box and masks). */
|
|
11742
|
+
function textPathBox(c, p, t) {
|
|
11743
|
+
const it = textPathAt(c, t);
|
|
11744
|
+
if (!it) return null;
|
|
11745
|
+
const lines = Math.max(1, String(p.text || "").split("\n").length), size = +p.fontSize || 72, lh = size * lineHeightOf(p);
|
|
11746
|
+
const { A, closed } = Motion.outline(it), m = Motion.itemMatrix(it);
|
|
11747
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
11748
|
+
for (const off of lines > 1 ? [0, (lines - 1) * lh] : [0]) { // the first and the last line's tracks
|
|
11749
|
+
for (const [x, y] of Motion.pathSampler(A, closed, off).points) {
|
|
11750
|
+
const [px, py] = Motion.apply(m, x, y);
|
|
11751
|
+
x0 = Math.min(x0, px); y0 = Math.min(y0, py); x1 = Math.max(x1, px); y1 = Math.max(y1, py);
|
|
11752
|
+
}
|
|
11753
|
+
}
|
|
11754
|
+
if (!(x1 >= x0)) return null;
|
|
11755
|
+
const pad = size * 0.8, s = Motion.contentScale(c, p);
|
|
11756
|
+
return { ox: (x0 + x1) / 2 * s, oy: (y0 + y1) / 2 * s, hw: ((x1 - x0) / 2 + pad) * s, hh: ((y1 - y0) / 2 + pad) * s };
|
|
11757
|
+
}
|
|
11758
|
+
function drawTextOnPath(c, p, local) {
|
|
11759
|
+
const it = textPathAt(c, c.start + local);
|
|
11760
|
+
if (!it) return;
|
|
11761
|
+
const sc = +p.scale || 1, size = +p.fontSize || 72, lh = size * lineHeightOf(p);
|
|
11762
|
+
ctx2d.scale(sc, sc);
|
|
11763
|
+
const m = Motion.itemMatrix(it);
|
|
11764
|
+
ctx2d.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
|
11765
|
+
const anim = TEXT_ANIMS.includes(p.textAnim) ? p.textAnim : "none";
|
|
11766
|
+
const rate = clamp(+p.wordRate || 0.15, 0.03, 2);
|
|
11767
|
+
let fam = p.font || "Segoe UI";
|
|
11768
|
+
if (anim === "font-cut") { // swap the typeface rhythmically, then settle
|
|
11769
|
+
const setF = (Array.isArray(p.fontCutSet) && p.fontCutSet.length) ? p.fontCutSet : FONT_CUT_DEFAULT;
|
|
11770
|
+
setF.forEach(ensureFont);
|
|
11771
|
+
if (local < 0.6) fam = setF[Math.floor(local / 0.06) % setF.length];
|
|
11772
|
+
}
|
|
11773
|
+
setTextFont(ctx2d, { ...p, font: fam }, size);
|
|
11774
|
+
try { ctx2d.letterSpacing = "0px"; } catch { } // spacing is laid out by hand along the path
|
|
11775
|
+
ctx2d.textBaseline = "alphabetic"; ctx2d.textAlign = "center";
|
|
11776
|
+
const { A, closed } = Motion.outline(it), ls = +p.letterSpacing || 0;
|
|
11777
|
+
const tracks = []; // line i runs on a parallel curve i line-heights below the path
|
|
11778
|
+
const track = (i) => tracks[i] || (tracks[i] = Motion.pathSampler(A, closed, i * lh));
|
|
11779
|
+
const shadowBlur = (p.textShadow === 0 ? 0 : (+p.textShadow || 12)) * size / 100;
|
|
11780
|
+
const fill = () => {
|
|
11781
|
+
if (!p.color2) return p.color || "#fff";
|
|
11782
|
+
const g = ctx2d.createLinearGradient(0, -size * 0.8, 0, size * 0.1);
|
|
11783
|
+
g.addColorStop(0, p.color || "#fff"); g.addColorStop(1, p.color2);
|
|
11784
|
+
return g;
|
|
11785
|
+
};
|
|
11786
|
+
// Lay out every line along the path (line i sits i line-heights below it), glyph by glyph,
|
|
11787
|
+
// remembering the glyph's word and line so the animations can group them.
|
|
11788
|
+
const lines = textSourceString(p).split("\n");
|
|
11789
|
+
const glyphs = []; // {g, w, d (centre along the path), line, word, ci (letter index), gi}
|
|
11790
|
+
const lineRuns = []; // {from, to} along the path, per line
|
|
11791
|
+
let wi = 0, ci = 0, gi = 0;
|
|
11792
|
+
lines.forEach((ln, line) => {
|
|
11793
|
+
const gs = graphemeSegments(ln), ws = gs.map((g) => ctx2d.measureText(g).width);
|
|
11794
|
+
const total = ws.reduce((a, b) => a + b, 0) + ls * Math.max(0, gs.length - 1);
|
|
11795
|
+
const base = (+p.pathOffset || 0) / 100 * track(line).length;
|
|
11796
|
+
let d = p.pathAlign === "center" ? base - total / 2 : p.pathAlign === "end" ? base - total : base;
|
|
11797
|
+
lineRuns.push({ from: d, to: d + total });
|
|
11798
|
+
let inWord = false;
|
|
11799
|
+
gs.forEach((g, j) => {
|
|
11800
|
+
const space = !g.trim();
|
|
11801
|
+
if (!space && !inWord) { inWord = true; } else if (space && inWord) { inWord = false; wi++; }
|
|
11802
|
+
glyphs.push({ g, w: ws[j], d: d + ws[j] / 2, line, word: wi, ci: space ? -1 : ci, gi, space });
|
|
11803
|
+
if (!space) ci++;
|
|
11804
|
+
gi++;
|
|
11805
|
+
d += ws[j] + ls;
|
|
11806
|
+
});
|
|
11807
|
+
if (inWord) wi++;
|
|
11808
|
+
});
|
|
11809
|
+
// group centres (for word-pop / zoom-in, which scale a word or a line about its middle)
|
|
11810
|
+
const centre = (key) => {
|
|
11811
|
+
const out = new Map();
|
|
11812
|
+
for (const q of glyphs) { if (q.space) continue; const k = q[key], o = out.get(k) || { a: Infinity, b: -Infinity }; o.a = Math.min(o.a, q.d - q.w / 2); o.b = Math.max(o.b, q.d + q.w / 2); out.set(k, o); }
|
|
11813
|
+
return out;
|
|
11814
|
+
};
|
|
11815
|
+
const words = anim === "word-pop" ? centre("word") : null, lineBox = anim === "zoom-in" ? centre("line") : null;
|
|
11816
|
+
const shown = anim === "typewriter" ? Math.floor(local / (rate / 4)) : Infinity;
|
|
11817
|
+
const ease = EASE["ease-out"];
|
|
11818
|
+
for (const q of glyphs) {
|
|
11819
|
+
if (q.space || q.gi >= shown) continue;
|
|
11820
|
+
let d = q.d, scale = 1, alpha = 1, dx = 0, dy = 0, clipL = null, clipR = null, rise = null;
|
|
11821
|
+
const uw = clamp((local - q.word * rate) / 0.25, 0, 1); // per word
|
|
11822
|
+
const ul = (dur) => clamp((local - q.line * rate) / dur, 0, 1); // per line
|
|
11823
|
+
if (anim === "letter-pop") {
|
|
11824
|
+
const u = clamp((local - q.ci * rate / 3) / 0.18, 0, 1);
|
|
11825
|
+
if (u <= 0) continue;
|
|
11826
|
+
scale = Math.max(0.001, backOut(u)); alpha = Math.min(1, u * 2.5);
|
|
11827
|
+
} else if (anim === "wave") dy += Math.sin(local * 4 + q.ci * 0.55) * size * 0.12;
|
|
11828
|
+
else if (anim === "word-pop") {
|
|
11829
|
+
if (uw <= 0) continue;
|
|
11830
|
+
scale = Math.max(0.001, backOut(uw)); alpha = Math.min(1, uw * 2.5);
|
|
11831
|
+
const o = words.get(q.word), mid = (o.a + o.b) / 2;
|
|
11832
|
+
d = mid + (d - mid) * scale;
|
|
11833
|
+
} else if (anim === "word-slide") {
|
|
11834
|
+
if (uw <= 0) continue;
|
|
11835
|
+
dy += (1 - ease(uw)) * size * 0.7; alpha = uw;
|
|
11836
|
+
} else if (anim === "bounce") dy -= Math.abs(Math.sin(local * 3.2 + q.word * 0.9)) * size * 0.18;
|
|
11837
|
+
else if (anim === "shake") { dx += Math.sin(local * 31 + q.word * 7.3) * size * 0.035; dy += Math.cos(local * 27 + q.word * 3.1) * size * 0.035; }
|
|
11838
|
+
else if (anim === "karaoke") alpha = uw >= 1 ? 1 : 0.3 + uw * 0.7;
|
|
11839
|
+
else if (anim === "clip-reveal") { // a wipe runs along each line in reading order
|
|
11840
|
+
const u = ul(0.5);
|
|
11841
|
+
if (u <= 0) continue;
|
|
11842
|
+
const run = lineRuns[q.line], edge = run.from + (run.to - run.from) * ease(u);
|
|
11843
|
+
if (edge <= d - q.w / 2) continue;
|
|
11844
|
+
if (edge < d + q.w / 2) clipR = edge - d;
|
|
11845
|
+
} else if (anim === "zoom-in") {
|
|
11846
|
+
const u = ul(0.45);
|
|
11847
|
+
if (u <= 0) continue;
|
|
11848
|
+
scale = 1.35 - 0.35 * ease(u); alpha = Math.min(1, u * 1.6);
|
|
11849
|
+
const o = lineBox.get(q.line), mid = (o.a + o.b) / 2;
|
|
11850
|
+
d = mid + (d - mid) * scale;
|
|
11851
|
+
} else if (anim === "rise-mask") { // each line rises from behind its own baseline
|
|
11852
|
+
const u = ul(0.5);
|
|
11853
|
+
if (u <= 0) continue;
|
|
11854
|
+
rise = (1 - ease(u)) * lh;
|
|
11855
|
+
}
|
|
11856
|
+
const at = track(q.line).at(d);
|
|
11857
|
+
ctx2d.save();
|
|
11858
|
+
ctx2d.translate(at.x, at.y);
|
|
11859
|
+
ctx2d.rotate(at.angle * Math.PI / 180);
|
|
11860
|
+
ctx2d.translate(dx, dy);
|
|
11861
|
+
if (clipL != null || clipR != null || rise != null) {
|
|
11862
|
+
ctx2d.beginPath();
|
|
11863
|
+
const x0 = clipL ?? -q.w / 2 - size, x1 = clipR ?? q.w / 2 + size;
|
|
11864
|
+
ctx2d.rect(x0, -size * 1.1, x1 - x0, size * 1.4); // the glyph's line box: ascenders to descenders
|
|
11865
|
+
ctx2d.clip();
|
|
11866
|
+
if (rise != null) ctx2d.translate(0, rise);
|
|
11867
|
+
}
|
|
11868
|
+
if (scale !== 1) ctx2d.scale(scale, scale);
|
|
11869
|
+
const keep = ctx2d.globalAlpha;
|
|
11870
|
+
ctx2d.globalAlpha = keep * clamp(alpha, 0, 1);
|
|
11871
|
+
if (p.strokeWidth > 0) {
|
|
11872
|
+
ctx2d.lineJoin = "round"; ctx2d.miterLimit = 2; ctx2d.lineWidth = p.strokeWidth; ctx2d.strokeStyle = p.strokeColor || "#000";
|
|
11873
|
+
ctx2d.strokeText(q.g, 0, 0);
|
|
11874
|
+
}
|
|
11875
|
+
if (p.glow > 0) { ctx2d.shadowColor = p.glowColor || p.color || "#fff"; ctx2d.shadowBlur = (+p.glow) * size / 40; }
|
|
11876
|
+
else if (shadowBlur > 0) { ctx2d.shadowColor = "rgba(0,0,0,.7)"; ctx2d.shadowBlur = shadowBlur; ctx2d.shadowOffsetY = shadowBlur / 3; }
|
|
11877
|
+
ctx2d.fillStyle = fill();
|
|
11878
|
+
ctx2d.fillText(q.g, 0, 0);
|
|
11879
|
+
if (p.glow > 0) ctx2d.fillText(q.g, 0, 0);
|
|
11880
|
+
ctx2d.globalAlpha = keep;
|
|
11881
|
+
ctx2d.restore();
|
|
11882
|
+
}
|
|
11883
|
+
}
|
|
11884
|
+
function textPathSectionHtml(c, p, row) {
|
|
11885
|
+
const has = !!clipTextPath(c), editing = has && shapeUi.clip === c.id && samePath(shapeUi.sel, [0]);
|
|
11886
|
+
const btn = (attr, label, title, on, dis) => `<button type="button" class="btn tiny${on ? " toggle on" : ""}" ${attr} title="${title}" ${dis ? "disabled" : ""}>${label}</button>`;
|
|
11887
|
+
return `<div class="insp-section" data-textpath-section><h3>Text path</h3>
|
|
11888
|
+
<div class="cl-row">${Object.entries(TEXT_PATH_PRESETS).map(([k, pre]) => btn(`data-tpath="${k}"`, pre.label, `Set the title on a ${pre.label.toLowerCase()}`)).join("")}
|
|
11889
|
+
${btn('data-tpath="pen"', "Pen", "Draw the path on the monitor: click points (drag to curve), Enter to finish, click the first point to close it", shapeUi.clip === c.id && shapeUi.tool === "pen")}</div>
|
|
11890
|
+
${has ? `<div class="cl-row">${btn('data-tpath="edit"', "Edit path", "Show the path's points and handles on the monitor", editing)}${btn('data-tpath="off"', "Remove", "Back to a normal title")}</div>
|
|
11891
|
+
${row("Offset", `<input type="range" data-k="pathOffset" min="-100" max="200" step="0.5" value="${fmtInspNum(p.pathOffset, 0.5)}" title="Slide the text along the path, % of its length — key it to make the text travel">
|
|
11892
|
+
<span class="val" data-val="pathOffset" data-unit="%">${fmtInspNum(p.pathOffset, 0.5)}%</span>`, "pathOffset")}
|
|
11893
|
+
${row("Align", `<select data-k="pathAlign">${["start", "center", "end"].map((v) => `<option value="${v}" ${(p.pathAlign || "start") === v ? "selected" : ""}>${v}</option>`).join("")}</select>`, "pathAlign")}
|
|
11894
|
+
<p class="cl-note">Each line runs along the path, the next one a line below it. Every text animation plays along the path.</p>`
|
|
11895
|
+
: `<p class="cl-note">Set the title along a line, an arc, a circle, a wave or a path you draw.</p>`}
|
|
11896
|
+
</div>`;
|
|
11897
|
+
}
|
|
11898
|
+
function bindTextPathControls(c) {
|
|
11899
|
+
for (const b of els.inspector.querySelectorAll("[data-tpath]")) b.addEventListener("click", () => {
|
|
11900
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
11901
|
+
const k = b.dataset.tpath;
|
|
11902
|
+
if (k === "edit") {
|
|
11903
|
+
const on = shapeUi.clip === c.id && samePath(shapeUi.sel, [0]);
|
|
11904
|
+
Object.assign(shapeUi, { clip: c.id, sel: on ? null : [0], pt: -1, tool: null, pen: null });
|
|
11905
|
+
if (!on) { maskUi.sel = -1; maskUi.tool = null; }
|
|
11906
|
+
state.dirtyTimeline = true; renderInspector();
|
|
11907
|
+
return;
|
|
11908
|
+
}
|
|
11909
|
+
if (k === "pen") {
|
|
11910
|
+
const on = shapeUi.clip === c.id && shapeUi.tool === "pen";
|
|
11911
|
+
Object.assign(shapeUi, { clip: c.id, sel: null, pt: -1, tool: on ? null : "pen", pen: null });
|
|
11912
|
+
if (!on && state.playing) pause();
|
|
11913
|
+
if (!on && !activeAt(c, state.time)) toast("Move the playhead over the title, then draw on the monitor");
|
|
11914
|
+
state.dirtyTimeline = true; renderInspector();
|
|
11915
|
+
return;
|
|
11916
|
+
}
|
|
11917
|
+
pushUndo();
|
|
11918
|
+
if (k === "off") { delete c.props.textPath; shapeUi.sel = null; }
|
|
11919
|
+
else {
|
|
11920
|
+
c.props.textPath = Motion.normalizeTextPath(TEXT_PATH_PRESETS[k].item);
|
|
11921
|
+
Object.assign(shapeUi, { clip: c.id, sel: [0], pt: -1, tool: null, pen: null });
|
|
11922
|
+
}
|
|
11923
|
+
state.dirtyTimeline = true; inspPropGen++;
|
|
11924
|
+
scheduleSave(); renderInspector();
|
|
11925
|
+
});
|
|
11926
|
+
}
|
|
11927
|
+
/* ── Motion paths on the monitor ──
|
|
11928
|
+
With two or more position keys (x and y keyed at the same time) the selected
|
|
11929
|
+
clip's path shows over the picture: the curve, a dot per frame (their spacing
|
|
11930
|
+
is the speed), a square per key and the keys' curve handles. Drag a key to move
|
|
11931
|
+
it, its handles to bend the path (Alt breaks the pair), Alt-drag a key to pull
|
|
11932
|
+
new handles, double-click a key to make it a corner again. */
|
|
11933
|
+
const pathUi = { clip: null, key: -1, drag: null };
|
|
11934
|
+
function motionPathActive(c) {
|
|
11935
|
+
return !!c && isVisualClip(c) && !state.playing && !state.exporting && !runtime.anchorTool && !shapeUi.tool &&
|
|
11936
|
+
Motion.posKeys(c).length >= 2 && activeAt(c, state.time);
|
|
11937
|
+
}
|
|
11938
|
+
/** Screen geometry of the path: the curve, frame dots and keys with their handles, in canvas px. */
|
|
11939
|
+
function motionPathGeom(c, W, H, t) {
|
|
11940
|
+
const P = Motion.posKeys(c);
|
|
11941
|
+
const M = parentScreenMatrix(c, W, H, t);
|
|
11942
|
+
const scr = (x, y) => { const q = M.transformPoint(new DOMPoint(x, y)); return { x: q.x, y: q.y }; };
|
|
11943
|
+
const t0 = P[0].t, t1 = P[P.length - 1].t, step = 1 / (projectFps() || 30);
|
|
11944
|
+
const dots = [];
|
|
11945
|
+
for (let u = t0; u <= t1 + 1e-9; u += step) { const q = Motion.positionAt(c, u); dots.push(scr(q.x, q.y)); }
|
|
11946
|
+
const keys = P.map((k) => ({ k, a: scr(k.x, k.y), i: scr(k.x + k.ix, k.y + k.iy), o: scr(k.x + k.ox, k.y + k.oy), hasIn: !!(k.ix || k.iy), hasOut: !!(k.ox || k.oy) }));
|
|
11947
|
+
return { P, M, dots, keys };
|
|
11948
|
+
}
|
|
11949
|
+
function drawMotionPathOverlay(c, W, H, t) {
|
|
11950
|
+
if (!motionPathActive(c)) return;
|
|
11951
|
+
if (pathUi.clip !== c.id) Object.assign(pathUi, { clip: c.id, key: -1, drag: null });
|
|
11952
|
+
const g = motionPathGeom(c, W, H, t), lw = Math.max(1.5, W / 800), hs = Math.max(5, W / 170);
|
|
11953
|
+
ctx2d.save();
|
|
11954
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
11955
|
+
ctx2d.globalAlpha = 1; ctx2d.globalCompositeOperation = "source-over"; ctx2d.filter = "none";
|
|
11956
|
+
ctx2d.lineJoin = "round";
|
|
11957
|
+
const line = g.dots.map((q) => [q.x, q.y]);
|
|
11958
|
+
ctx2d.lineWidth = lw * 2.4; ctx2d.strokeStyle = "rgba(0,0,0,0.5)"; strokeOutline(ctx2d, line, false);
|
|
11959
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#c084fc"; strokeOutline(ctx2d, line, false);
|
|
11960
|
+
ctx2d.fillStyle = "#e9d5ff";
|
|
11961
|
+
for (const q of g.dots) { ctx2d.beginPath(); ctx2d.arc(q.x, q.y, lw * 1.1, 0, Math.PI * 2); ctx2d.fill(); }
|
|
11962
|
+
const dot = (p, r, fill, square) => {
|
|
11963
|
+
ctx2d.beginPath();
|
|
11964
|
+
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);
|
|
11965
|
+
ctx2d.fillStyle = fill; ctx2d.fill(); ctx2d.lineWidth = lw; ctx2d.strokeStyle = "rgba(0,0,0,0.75)"; ctx2d.stroke();
|
|
11966
|
+
};
|
|
11967
|
+
g.keys.forEach((k, j) => {
|
|
11968
|
+
const show = j === pathUi.key || k.hasIn || k.hasOut;
|
|
11969
|
+
if (show) {
|
|
11970
|
+
ctx2d.lineWidth = lw; ctx2d.strokeStyle = "#c084fc";
|
|
11971
|
+
for (const [q, on] of [[k.i, j > 0], [k.o, j < g.keys.length - 1]]) {
|
|
11972
|
+
if (!on || (!(j === pathUi.key) && !(q === k.i ? k.hasIn : k.hasOut))) continue;
|
|
11973
|
+
ctx2d.beginPath(); ctx2d.moveTo(k.a.x, k.a.y); ctx2d.lineTo(q.x, q.y); ctx2d.stroke();
|
|
11974
|
+
dot(q, hs * 0.55, "#c084fc");
|
|
11975
|
+
}
|
|
11976
|
+
}
|
|
11977
|
+
dot(k.a, hs * 0.7, j === pathUi.key ? "#c084fc" : "#ffffff", true);
|
|
11978
|
+
});
|
|
11979
|
+
ctx2d.restore();
|
|
11980
|
+
}
|
|
11981
|
+
/** Write a position key's value / handles (pin / pout on the x and y keys). */
|
|
11982
|
+
function setPathKey(k, set) {
|
|
11983
|
+
const put = (kf, prop, v) => { if (v == null || Math.abs(v) < 1e-3) delete kf[prop]; else kf[prop] = +v.toFixed(3); };
|
|
11984
|
+
if ("x" in set) k.xk.v = +set.x.toFixed(3);
|
|
11985
|
+
if ("y" in set) k.yk.v = +set.y.toFixed(3);
|
|
11986
|
+
if ("ix" in set) { put(k.xk, "pin", set.ix); put(k.yk, "pin", set.iy); }
|
|
11987
|
+
if ("ox" in set) { put(k.xk, "pout", set.ox); put(k.yk, "pout", set.oy); }
|
|
11988
|
+
inspPropGen++; state.dirtyTimeline = true;
|
|
11989
|
+
}
|
|
11990
|
+
els.preview.addEventListener("pointerdown", (e) => {
|
|
11991
|
+
if (e.button !== 0 || isSourceMode()) return;
|
|
11992
|
+
const c = getClip(state.selId);
|
|
11993
|
+
if (!motionPathActive(c) || maskEditActive(c) || shapeEditActive(c)) return;
|
|
11994
|
+
const W = els.preview.width, H = els.preview.height, pt = canvasPt(e), grab = Math.max(8, W / 110);
|
|
11995
|
+
const g = motionPathGeom(c, W, H, state.time);
|
|
11996
|
+
const near = (q) => Math.hypot(q.x - pt.x, q.y - pt.y) <= grab;
|
|
11997
|
+
let drag = null;
|
|
11998
|
+
// handles first (the picked key's, then any shown), then the keys themselves
|
|
11999
|
+
const last = g.keys.length - 1, order = [pathUi.key, ...g.keys.map((_, j) => j).filter((j) => j !== pathUi.key)].filter((j) => j >= 0);
|
|
12000
|
+
for (const j of order) {
|
|
12001
|
+
const k = g.keys[j], shown = (has) => j === pathUi.key || has;
|
|
12002
|
+
if (j < last && shown(k.hasOut) && near(k.o)) { drag = { mode: "handle", side: "o", j }; break; }
|
|
12003
|
+
if (j > 0 && shown(k.hasIn) && near(k.i)) { drag = { mode: "handle", side: "i", j }; break; }
|
|
12004
|
+
}
|
|
12005
|
+
if (!drag) {
|
|
12006
|
+
const j = g.keys.findIndex((k) => near(k.a));
|
|
12007
|
+
if (j >= 0) drag = { mode: e.altKey ? "pull" : "key", j };
|
|
12008
|
+
}
|
|
12009
|
+
if (!drag) return; // not on the path: the monitor's usual clicks
|
|
12010
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
12011
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
12012
|
+
pushUndo();
|
|
12013
|
+
pathUi.key = drag.j;
|
|
12014
|
+
const k = g.P[drag.j];
|
|
12015
|
+
pathUi.drag = { ...drag, inv: g.M.inverse(), k0: { x: k.x, y: k.y, ix: k.ix, iy: k.iy, ox: k.ox, oy: k.oy }, start: pt };
|
|
12016
|
+
els.preview.setPointerCapture(e.pointerId);
|
|
12017
|
+
state.dirtyTimeline = true;
|
|
12018
|
+
}, true);
|
|
12019
|
+
els.preview.addEventListener("pointermove", (e) => {
|
|
12020
|
+
const d = pathUi.drag;
|
|
12021
|
+
if (!d) return;
|
|
12022
|
+
e.stopImmediatePropagation();
|
|
12023
|
+
const c = getClip(state.selId), k = c && Motion.posKeys(c)[d.j];
|
|
12024
|
+
if (!k) return;
|
|
12025
|
+
const pt = canvasPt(e), q = d.inv.transformPoint(new DOMPoint(pt.x, pt.y)), k0 = d.k0;
|
|
12026
|
+
if (d.mode === "key") {
|
|
12027
|
+
const a = d.inv.transformPoint(new DOMPoint(d.start.x, d.start.y));
|
|
12028
|
+
setPathKey(k, { x: k0.x + q.x - a.x, y: k0.y + q.y - a.y });
|
|
12029
|
+
} else if (d.mode === "pull") {
|
|
12030
|
+
const ox = q.x - k0.x, oy = q.y - k0.y;
|
|
12031
|
+
if (Math.hypot(ox, oy) > 2) setPathKey(k, { ox, oy, ix: -ox, iy: -oy });
|
|
12032
|
+
} else {
|
|
12033
|
+
const hx = q.x - k0.x, hy = q.y - k0.y;
|
|
12034
|
+
const mine = d.side === "o" ? { ox: hx, oy: hy } : { ix: hx, iy: hy };
|
|
12035
|
+
if (!e.altKey) { // keep the key smooth: the other handle mirrors the direction, keeps its length
|
|
12036
|
+
const ol = d.side === "o" ? Math.hypot(k0.ix, k0.iy) : Math.hypot(k0.ox, k0.oy);
|
|
12037
|
+
const len = ol || Math.hypot(hx, hy), m = Math.hypot(hx, hy) || 1;
|
|
12038
|
+
if (d.side === "o") Object.assign(mine, { ix: -hx / m * len, iy: -hy / m * len });
|
|
12039
|
+
else Object.assign(mine, { ox: -hx / m * len, oy: -hy / m * len });
|
|
12040
|
+
} else if (d.side === "o") Object.assign(mine, { ix: k0.ix, iy: k0.iy });
|
|
12041
|
+
else Object.assign(mine, { ox: k0.ox, oy: k0.oy });
|
|
12042
|
+
const P = Motion.posKeys(c);
|
|
12043
|
+
if (d.j === 0) { mine.ix = 0; mine.iy = 0; }
|
|
12044
|
+
if (d.j === P.length - 1) { mine.ox = 0; mine.oy = 0; }
|
|
12045
|
+
setPathKey(k, { ix: mine.ix ?? k0.ix, iy: mine.iy ?? k0.iy, ox: mine.ox ?? k0.ox, oy: mine.oy ?? k0.oy });
|
|
12046
|
+
}
|
|
12047
|
+
syncInspectorPlayhead();
|
|
12048
|
+
}, true);
|
|
12049
|
+
const endPathDrag = (e) => {
|
|
12050
|
+
if (!pathUi.drag) return;
|
|
12051
|
+
e.stopImmediatePropagation();
|
|
12052
|
+
pathUi.drag = null;
|
|
12053
|
+
scheduleSave(); renderInspector();
|
|
12054
|
+
};
|
|
12055
|
+
els.preview.addEventListener("pointerup", endPathDrag, true);
|
|
12056
|
+
els.preview.addEventListener("pointercancel", endPathDrag, true);
|
|
12057
|
+
els.preview.addEventListener("dblclick", (e) => {
|
|
12058
|
+
const c = getClip(state.selId);
|
|
12059
|
+
if (!motionPathActive(c) || maskEditActive(c) || shapeEditActive(c)) return;
|
|
12060
|
+
const W = els.preview.width, H = els.preview.height, pt = canvasPt(e), grab = Math.max(8, W / 110);
|
|
12061
|
+
const g = motionPathGeom(c, W, H, state.time), j = g.keys.findIndex((k) => Math.hypot(k.a.x - pt.x, k.a.y - pt.y) <= grab);
|
|
12062
|
+
if (j < 0) return;
|
|
12063
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
12064
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
12065
|
+
pushUndo();
|
|
12066
|
+
setPathKey(g.P[j], { ix: 0, iy: 0, ox: 0, oy: 0 });
|
|
12067
|
+
scheduleSave(); renderInspector();
|
|
12068
|
+
}, true);
|
|
12069
|
+
/** The Inspector's Motion section: motion path, auto-orient and motion blur. */
|
|
12070
|
+
function motionSectionHtml(c, p, row) {
|
|
12071
|
+
const keys = Motion.posKeys(c).length, curved = Motion.hasPathHandles(c);
|
|
12072
|
+
const btn = (attr, label, title, dis) => `<button type="button" class="btn tiny" ${attr} title="${title}" ${dis ? "disabled" : ""}>${label}</button>`;
|
|
12073
|
+
return `<div class="insp-section" data-motion-section><h3>Motion</h3>
|
|
12074
|
+
<div class="insp-row"><label title="Position keys (X and Y keyed at the same time) make a path you can bend on the monitor">Path</label><span class="insp-ctrls">
|
|
12075
|
+
${btn("data-path-act=\"smooth\"", "Smooth", "Curve the path smoothly through every position key", keys < 2)}
|
|
12076
|
+
${btn("data-path-act=\"straight\"", "Straight", "Straight lines between the position keys", !curved)}
|
|
12077
|
+
<span class="cl-note">${keys >= 2 ? `${keys} position keys${curved ? ", curved" : ""}` : "key X and Y at two times"}</span></span></div>
|
|
12078
|
+
${row("Auto-orient", `<input type="checkbox" data-k="autoOrient" ${p.autoOrient ? "checked" : ""} title="Turn the clip with its direction of travel (its Rotation adds on top)">`, "autoOrient")}
|
|
12079
|
+
${c.kind !== "null" ? row("Motion blur", `<input type="range" data-k="motionBlur" min="0" max="720" step="15" value="${+p.motionBlur || 0}" title="Shutter angle — 180° is the film look, 0 is off">
|
|
12080
|
+
<span class="val" data-val="motionBlur" data-unit="°">${+p.motionBlur || 0}°</span>`, "motionBlur")
|
|
12081
|
+
+ row("Blur samples", `<input type="range" data-k="motionBlurSamples" min="2" max="32" step="1" value="${+p.motionBlurSamples || 8}" title="Sub-frame samples per frame — more is smoother and slower (playback uses as many as fit in the frame time; a paused frame and export use them all)">
|
|
12082
|
+
<span class="val" data-val="motionBlurSamples" data-unit="">${+p.motionBlurSamples || 8}</span>`, "motionBlurSamples") : ""}
|
|
12083
|
+
${presetRowsHtml(c, row)}
|
|
12084
|
+
</div>`;
|
|
12085
|
+
}
|
|
12086
|
+
function bindMotionControls(c) {
|
|
12087
|
+
for (const b of els.inspector.querySelectorAll("[data-path-act]")) b.addEventListener("click", () => {
|
|
12088
|
+
if (isGroupLocked(c)) { toastLocked(); return; }
|
|
12089
|
+
pushUndo();
|
|
12090
|
+
c.keyframes = b.dataset.pathAct === "smooth" ? Motion.smoothPath(c.keyframes) : Motion.straightPath(c.keyframes);
|
|
12091
|
+
inspPropGen++; state.dirtyTimeline = true;
|
|
12092
|
+
scheduleSave(); renderInspector();
|
|
12093
|
+
});
|
|
12094
|
+
}
|
|
12095
|
+
|
|
10291
12096
|
/* ── Text direction (LTR / RTL) — per-line when direction is "auto".
|
|
10292
12097
|
Auto-detect uses a DOM probe + getComputedStyle; cache by line text so
|
|
10293
12098
|
preview/export frames don't re-resolve style every tick. ── */
|
|
@@ -10499,6 +12304,7 @@ function beginTextBoxDrag(c, pt, W, H) {
|
|
|
10499
12304
|
};
|
|
10500
12305
|
}
|
|
10501
12306
|
function drawText(c, p, local) {
|
|
12307
|
+
if (p.textPath && clipTextPath(c)) { drawTextOnPath(c, p, local); return; }
|
|
10502
12308
|
const useBox = hasTextBox(p);
|
|
10503
12309
|
const boxW = +p.boxW, boxH = +p.boxH;
|
|
10504
12310
|
const scaleToFit = useBox && !!p.boxFit;
|
|
@@ -10878,7 +12684,7 @@ function ensureFont(name) {
|
|
|
10878
12684
|
an export that frame is final. Bounded so a dead network can't stall it. */
|
|
10879
12685
|
async function loadProjectFonts() {
|
|
10880
12686
|
const faces = new Map(); // "italic 700 64px "Anton"" -> "Anton"
|
|
10881
|
-
for (const c of
|
|
12687
|
+
for (const c of mediaClips()) {
|
|
10882
12688
|
if (c.kind !== "text" || c.disabled) continue;
|
|
10883
12689
|
const p = c.props || {};
|
|
10884
12690
|
const fams = [p.font || "Segoe UI"];
|
|
@@ -11386,7 +13192,7 @@ function waitMediaEl(el, ms = 2500) {
|
|
|
11386
13192
|
* compositor. Preview leaves them no-cors so a stream without ACAO still plays. */
|
|
11387
13193
|
async function armExportCors() {
|
|
11388
13194
|
const jobs = [];
|
|
11389
|
-
for (const c of
|
|
13195
|
+
for (const c of mediaClips()) {
|
|
11390
13196
|
if (c.kind !== "video" || !clipRenders(c)) continue;
|
|
11391
13197
|
const m = getMedia(c.mediaId);
|
|
11392
13198
|
if (!m || !isCrossOriginSrc(m.src)) continue;
|
|
@@ -11409,7 +13215,7 @@ async function armExportCors() {
|
|
|
11409
13215
|
}
|
|
11410
13216
|
await Promise.all(jobs);
|
|
11411
13217
|
const missing = [];
|
|
11412
|
-
for (const c of
|
|
13218
|
+
for (const c of mediaClips()) {
|
|
11413
13219
|
if (c.kind !== "video" || !clipRenders(c)) continue;
|
|
11414
13220
|
const m = getMedia(c.mediaId);
|
|
11415
13221
|
if (!m || !isCrossOriginSrc(m.src)) continue;
|
|
@@ -11542,7 +13348,16 @@ function videoRangeBuffered(el, from, to) {
|
|
|
11542
13348
|
return el.readyState >= 3;
|
|
11543
13349
|
}
|
|
11544
13350
|
function notePresented(el, mediaTime) {
|
|
11545
|
-
if (Number.isFinite(mediaTime))
|
|
13351
|
+
if (!Number.isFinite(mediaTime)) return;
|
|
13352
|
+
// learn the source's frame duration from consecutive presented frames (the smallest
|
|
13353
|
+
// step seen: playback can drop frames, never split one)
|
|
13354
|
+
const prev = el._fcLastShown;
|
|
13355
|
+
if (prev != null) {
|
|
13356
|
+
const d = mediaTime - prev;
|
|
13357
|
+
if (d > 1 / 240 && d < 0.1) { el._fcFrameDur = Math.min(el._fcFrameDur || Infinity, d); el._fcFrameSeen = (el._fcFrameSeen || 0) + 1; }
|
|
13358
|
+
}
|
|
13359
|
+
el._fcLastShown = mediaTime;
|
|
13360
|
+
el._fcPresentedTime = mediaTime;
|
|
11546
13361
|
}
|
|
11547
13362
|
function presentedClose(el, mt, eps) {
|
|
11548
13363
|
return el._fcPresentedTime != null && Math.abs(el._fcPresentedTime - mt) <= eps;
|
|
@@ -11579,15 +13394,32 @@ function assignVideoTime(el, mt, accurate) {
|
|
|
11579
13394
|
}
|
|
11580
13395
|
el.currentTime = mt;
|
|
11581
13396
|
}
|
|
11582
|
-
|
|
13397
|
+
/** A finished seek to mt still shows the frame presented at `shown`: mt lies inside that one
|
|
13398
|
+
* source frame, by the frame duration learned from presented frames (unknown → no). */
|
|
13399
|
+
function sameSourceFrame(el, mt, shown) {
|
|
13400
|
+
const dur = el._fcFrameDur;
|
|
13401
|
+
if (shown == null || !(el._fcFrameSeen >= 3) || !(dur > 0)) return false;
|
|
13402
|
+
return el.readyState >= 2 && !el.seeking && Math.abs(el.currentTime - mt) < 0.002 &&
|
|
13403
|
+
mt >= shown - 1e-4 && mt < shown + dur - 1e-4;
|
|
13404
|
+
}
|
|
13405
|
+
function hardSeekVideo(el, mt, attempt = 0, shown = el._fcPresentedTime) {
|
|
11583
13406
|
const hasRvfc = typeof el.requestVideoFrameCallback === "function";
|
|
11584
13407
|
const maxAttempts = 3;
|
|
11585
13408
|
return new Promise((res, rej) => {
|
|
11586
|
-
|
|
13409
|
+
// a busy frame (grades, masks, blur) can present late: each retry waits longer
|
|
13410
|
+
const after = () => waitForPresentedFrame(el, [80, 250, 600][attempt] || 600)
|
|
11587
13411
|
.then(res)
|
|
11588
13412
|
.catch((err) => {
|
|
11589
|
-
|
|
11590
|
-
|
|
13413
|
+
// Slow motion lands several output frames on one source frame: seeking within the
|
|
13414
|
+
// frame already on screen presents nothing new, so no frame callback ever comes.
|
|
13415
|
+
// The seek finished on mt and the frame shown just before is within one source frame
|
|
13416
|
+
// of it (≥ 20 fps footage): that frame is the right picture.
|
|
13417
|
+
if (sameSourceFrame(el, mt, shown)) { notePresented(el, shown); res(); return; }
|
|
13418
|
+
if (attempt + 1 < maxAttempts) { hardSeekVideo(el, mt, attempt + 1, shown).then(res, rej); return; }
|
|
13419
|
+
// Last try: the seek finished on mt with the frame's data loaded, so the element holds
|
|
13420
|
+
// that frame even though no callback reported it. Better than failing the whole export.
|
|
13421
|
+
if (el.readyState >= 2 && !el.seeking && Math.abs(el.currentTime - mt) < 0.002) { notePresented(el, mt); res(); return; }
|
|
13422
|
+
rej(err);
|
|
11591
13423
|
});
|
|
11592
13424
|
if (presentedClose(el, mt, 0.002) && el.readyState >= 2) {
|
|
11593
13425
|
res();
|
|
@@ -11700,7 +13532,7 @@ function playAdvanceVideo(el, mt, eps, rate, { keepPlaying } = {}) {
|
|
|
11700
13532
|
}
|
|
11701
13533
|
const EXPORT_PREFETCH_S = 1.25;
|
|
11702
13534
|
function prefetchExportVideos(t) {
|
|
11703
|
-
for (const c of
|
|
13535
|
+
for (const c of mediaClips()) {
|
|
11704
13536
|
if (c.kind !== "video" || !clipRenders(c)) continue;
|
|
11705
13537
|
if (activeAt(c, t)) continue;
|
|
11706
13538
|
if (c.start > t + EXPORT_PREFETCH_S || clipEnd(c) <= t) continue;
|
|
@@ -11725,7 +13557,7 @@ async function seekVideosTo(t, { queueBusy } = {}) {
|
|
|
11725
13557
|
const keepPlaying = fast && !behind && !queueBusy;
|
|
11726
13558
|
const waits = [];
|
|
11727
13559
|
const restoreGain = [];
|
|
11728
|
-
for (const c of
|
|
13560
|
+
for (const c of mediaClips()) {
|
|
11729
13561
|
if (c.kind !== "video") continue;
|
|
11730
13562
|
if (!clipRenders(c)) continue;
|
|
11731
13563
|
const el = getClipEl(c); if (!el) continue;
|
|
@@ -11806,7 +13638,7 @@ function encodeWAV(buf) {
|
|
|
11806
13638
|
const FX_AUTOMATION_STEP = 0.02; // export automation resolution, seconds
|
|
11807
13639
|
async function renderAudioMix(t0, t1) {
|
|
11808
13640
|
const jobs = [];
|
|
11809
|
-
for (const c of
|
|
13641
|
+
for (const c of mediaClips()) {
|
|
11810
13642
|
if (c.kind !== "audio" && c.kind !== "video") continue;
|
|
11811
13643
|
if (!clipRenders(c)) continue;
|
|
11812
13644
|
const m = getMedia(c.mediaId); if (!m) continue;
|
|
@@ -11888,7 +13720,7 @@ async function renderAudioMix(t0, t1) {
|
|
|
11888
13720
|
/* Frame-exact asset prep for the fast exporter: rasterize the SVG frame for
|
|
11889
13721
|
this exact time, and refresh AI person masks synchronously. */
|
|
11890
13722
|
async function prepareFrameAssets(t) {
|
|
11891
|
-
for (const c of
|
|
13723
|
+
for (const c of mediaClips()) {
|
|
11892
13724
|
if (!activeAt(c, t) || !clipRenders(c)) continue;
|
|
11893
13725
|
if (c.kind === "svg") await prepareSvgFrame(c, t);
|
|
11894
13726
|
if (c.props?.bgRemove && (c.kind === "video" || c.kind === "image")) {
|
|
@@ -12196,7 +14028,7 @@ async function runTrackJob(ticket, fail) {
|
|
|
12196
14028
|
stays where it is. */
|
|
12197
14029
|
async function renderStillAt(t) {
|
|
12198
14030
|
const waits = [];
|
|
12199
|
-
for (const c of
|
|
14031
|
+
for (const c of mediaClips()) {
|
|
12200
14032
|
if (!clipRenders(c) || !activeAt(c, t)) continue;
|
|
12201
14033
|
if (c.kind === "video") {
|
|
12202
14034
|
const el = getClipEl(c);
|
|
@@ -12553,6 +14385,7 @@ els.importUrlOverlay?.addEventListener("click", (e) => {
|
|
|
12553
14385
|
});
|
|
12554
14386
|
$("btnTitle").addEventListener("click", addTitle);
|
|
12555
14387
|
$("btnAdjust").addEventListener("click", addAdjust);
|
|
14388
|
+
|
|
12556
14389
|
$("btnSplit").addEventListener("click", splitAtPlayhead);
|
|
12557
14390
|
$("btnCloseGap").addEventListener("click", closeGapAtPlayhead);
|
|
12558
14391
|
$("btnLift").addEventListener("click", () => liftExtract(false));
|
|
@@ -12642,8 +14475,37 @@ function startSourceScrub(e) {
|
|
|
12642
14475
|
window.addEventListener("pointerup", onUp);
|
|
12643
14476
|
}
|
|
12644
14477
|
els.sourceScrubTrack?.addEventListener("pointerdown", startSourceScrub);
|
|
12645
|
-
$("btnHelp").addEventListener("click", () =>
|
|
14478
|
+
$("btnHelp").addEventListener("click", () => {
|
|
14479
|
+
$("helpOverlay").classList.remove("hidden");
|
|
14480
|
+
$("helpFilter").focus();
|
|
14481
|
+
});
|
|
12646
14482
|
$("btnCloseHelp").addEventListener("click", () => $("helpOverlay").classList.add("hidden"));
|
|
14483
|
+
/** Keyboard shortcuts filter: keeps the rows (and whole sections, by heading) that match every typed word. */
|
|
14484
|
+
function filterHelp() {
|
|
14485
|
+
const words = $("helpFilter").value.toLowerCase().split(/\s+/).filter(Boolean);
|
|
14486
|
+
let any = false;
|
|
14487
|
+
for (const sec of $("helpKeys").querySelectorAll(".keys-sec")) {
|
|
14488
|
+
const head = sec.querySelector("h3").textContent.toLowerCase();
|
|
14489
|
+
let shown = 0;
|
|
14490
|
+
for (const tr of sec.querySelectorAll("tr")) {
|
|
14491
|
+
const text = head + " " + tr.textContent.toLowerCase();
|
|
14492
|
+
const hit = words.every((w) => text.includes(w));
|
|
14493
|
+
tr.classList.toggle("hidden", !hit);
|
|
14494
|
+
if (hit) shown++;
|
|
14495
|
+
}
|
|
14496
|
+
sec.classList.toggle("hidden", !shown);
|
|
14497
|
+
if (shown) any = true;
|
|
14498
|
+
}
|
|
14499
|
+
$("helpEmpty").classList.toggle("hidden", any);
|
|
14500
|
+
}
|
|
14501
|
+
$("helpFilter").addEventListener("input", filterHelp);
|
|
14502
|
+
$("helpFilter").addEventListener("keydown", (e) => {
|
|
14503
|
+
if (e.key !== "Escape") return;
|
|
14504
|
+
e.preventDefault();
|
|
14505
|
+
e.stopPropagation();
|
|
14506
|
+
if ($("helpFilter").value) { $("helpFilter").value = ""; filterHelp(); }
|
|
14507
|
+
else $("helpOverlay").classList.add("hidden");
|
|
14508
|
+
});
|
|
12647
14509
|
function settingsFocusables() {
|
|
12648
14510
|
const root = $("settingsDialog");
|
|
12649
14511
|
if (!root) return [];
|
|
@@ -12733,6 +14595,16 @@ function openProjectTabMenu(clientX, clientY) {
|
|
|
12733
14595
|
addFolder(null);
|
|
12734
14596
|
});
|
|
12735
14597
|
menu.appendChild(item);
|
|
14598
|
+
const compItem = document.createElement("div");
|
|
14599
|
+
compItem.className = "ctx-opt";
|
|
14600
|
+
compItem.textContent = "New composition";
|
|
14601
|
+
compItem.addEventListener("click", () => {
|
|
14602
|
+
closeBinCtxMenu();
|
|
14603
|
+
if (state.binTab !== "project") setBinTab("project");
|
|
14604
|
+
const comp = addNewComp();
|
|
14605
|
+
enterComp(comp.id);
|
|
14606
|
+
});
|
|
14607
|
+
menu.appendChild(compItem);
|
|
12736
14608
|
document.body.appendChild(menu);
|
|
12737
14609
|
const pad = 6;
|
|
12738
14610
|
const w = menu.offsetWidth, h = menu.offsetHeight;
|
|
@@ -12768,17 +14640,7 @@ function syncFpsSel() {
|
|
|
12768
14640
|
}
|
|
12769
14641
|
els.aspectSel.addEventListener("change", () => {
|
|
12770
14642
|
const a = ASPECT_PRESETS[+els.aspectSel.value];
|
|
12771
|
-
if (
|
|
12772
|
-
project.width = a.w; project.height = a.h;
|
|
12773
|
-
els.preview.width = a.w; els.preview.height = a.h;
|
|
12774
|
-
if (project.exportFrame)
|
|
12775
|
-
project.exportFrame = normalizeExportFrame(project.exportFrame, a.w, a.h);
|
|
12776
|
-
updateMonitorRes();
|
|
12777
|
-
syncAspectSel();
|
|
12778
|
-
syncExportFrameSel();
|
|
12779
|
-
updateExportFrameOverlay();
|
|
12780
|
-
seekMediaWhilePaused();
|
|
12781
|
-
scheduleSave();
|
|
14643
|
+
if (a) setCanvasSize(a.w, a.h);
|
|
12782
14644
|
});
|
|
12783
14645
|
els.fpsSel?.addEventListener("change", () => {
|
|
12784
14646
|
const v = FPS_PRESETS[+els.fpsSel.value];
|
|
@@ -13468,7 +15330,7 @@ try {
|
|
|
13468
15330
|
if (Array.isArray(s) && s.length === 2 && s.every((k) => SCOPE_KINDS[k])) scopes.slots = s;
|
|
13469
15331
|
} catch { }
|
|
13470
15332
|
|
|
13471
|
-
function isGradable(c) { return !!c && (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust"); }
|
|
15333
|
+
function isGradable(c) { return !!c && (c.kind === "video" || c.kind === "image" || c.kind === "svg" || c.kind === "adjust" || c.kind === "comp"); }
|
|
13472
15334
|
function setWorkspace(ws) {
|
|
13473
15335
|
colorState.ws = ws === "color" ? "color" : "edit";
|
|
13474
15336
|
$("app").classList.toggle("ws-color", colorState.ws === "color");
|