fablecut 0.0.0-stage → 1.12.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 +1198 -0
- package/LICENSE +21 -0
- package/README.md +571 -2
- package/analyze.js +289 -0
- package/app.js +13339 -0
- package/audio-fx.js +241 -0
- package/cli.js +31 -0
- package/color.js +1043 -0
- package/denoise.js +120 -0
- package/ducking.js +127 -0
- package/edit-ops.js +962 -0
- package/encode-profiles.js +302 -0
- package/encoding-profiles.json +68 -0
- package/export-jobs.js +163 -0
- package/favicon.svg +14 -0
- package/fx-worklet.js +162 -0
- package/icons/favicon-32.png +0 -0
- package/import-url.js +293 -0
- package/index.html +420 -0
- package/library/README.md +15 -0
- package/library/elements/.gitkeep +0 -0
- package/library/elements/bokeh-drift.svg +19 -0
- package/library/elements/light-leak-cool.svg +16 -0
- package/library/elements/light-leak-warm.svg +22 -0
- package/library/elements/vhs-scanlines.svg +18 -0
- package/library/elements/vignette-frame.svg +14 -0
- package/library/fonts/.gitkeep +0 -0
- package/library/fonts/Abril Fatface.woff2 +0 -0
- package/library/fonts/Anton.woff2 +0 -0
- package/library/fonts/Archivo Black.woff2 +0 -0
- package/library/fonts/Bangers.woff2 +0 -0
- package/library/fonts/Barlow.woff2 +0 -0
- package/library/fonts/Bebas Neue.woff2 +0 -0
- package/library/fonts/Bungee.woff2 +0 -0
- package/library/fonts/Caveat.woff2 +0 -0
- package/library/fonts/DM Sans.woff2 +0 -0
- package/library/fonts/Inter.woff2 +0 -0
- package/library/fonts/LICENSES.md +31 -0
- package/library/fonts/Lato.woff2 +0 -0
- package/library/fonts/Montserrat.woff2 +0 -0
- package/library/fonts/Oswald.woff2 +0 -0
- package/library/fonts/Pacifico.woff2 +0 -0
- package/library/fonts/Playfair Display.woff2 +0 -0
- package/library/fonts/Poppins.woff2 +0 -0
- package/library/fonts/Righteous.woff2 +0 -0
- package/library/fonts/Roboto.woff2 +0 -0
- package/library/fonts/Rubik.woff2 +0 -0
- package/library/fonts/Teko.woff2 +0 -0
- package/library/sfx/README.md +18 -0
- package/library/svg/ai-star.svg +17 -0
- package/library/svg/arrow-bounce.svg +18 -0
- package/library/svg/arrow-scribble.svg +13 -0
- package/library/svg/burst-lines.svg +30 -0
- package/library/svg/checkmark-success.svg +19 -0
- package/library/svg/confetti-burst.svg +23 -0
- package/library/svg/countdown-ring.svg +92 -0
- package/library/svg/equalizer.svg +18 -0
- package/library/svg/glow-orbs.svg +29 -0
- package/library/svg/heart-pop.svg +28 -0
- package/library/svg/loading-dots.svg +16 -0
- package/library/svg/location-pin.svg +25 -0
- package/library/svg/lower-third.svg +17 -0
- package/library/svg/new-badge.svg +18 -0
- package/library/svg/progress-bar.svg +16 -0
- package/library/svg/radial-pulse.svg +19 -0
- package/library/svg/rating-stars.svg +25 -0
- package/library/svg/rec-indicator.svg +41 -0
- package/library/svg/sparkles.svg +17 -0
- package/library/svg/spawning-hearts.svg +39 -0
- package/library/svg/speech-bubble.svg +23 -0
- package/library/svg/subscribe-bell.svg +29 -0
- package/library/svg/subscribe-pill.svg +22 -0
- package/library/svg/swipe-up.svg +46 -0
- package/library/svg/underline-swoosh.svg +15 -0
- package/loudness.js +135 -0
- package/mcp-server.js +1421 -0
- package/meter-worklet.js +229 -0
- package/package.json +45 -4
- package/paths.js +106 -0
- package/ruler-worker.js +120 -0
- package/server.js +775 -0
- package/style.css +3563 -0
package/edit-ops.js
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/* FableCut timeline edit operations — split, ripple delete, close gap,
|
|
2
|
+
lift / extract, insert / overwrite, ripple / roll / slip / slide trims and
|
|
3
|
+
crossfades. Zero dependencies. Loaded by the editor as a plain script
|
|
4
|
+
(global `FableCutEdit`) and required by the MCP server, so an agent's patch
|
|
5
|
+
op and the editor's keyboard shortcut run the very same code.
|
|
6
|
+
|
|
7
|
+
The ops mutate a project document in place and know nothing about the DOM,
|
|
8
|
+
undo or selection — the editor wraps them with those. `create(env)` binds
|
|
9
|
+
them to a timeline:
|
|
10
|
+
doc() → the project document (read on every call; may be swapped)
|
|
11
|
+
tracks() → live lanes, [{id, kind}] top→bottom
|
|
12
|
+
trackLocked(id) · trackTargeted(id)
|
|
13
|
+
uid() → short random id
|
|
14
|
+
mediaTimeAt(c,t) → source time at timeline t (optional; default handles speed ramps)
|
|
15
|
+
onRemove(c) → a clip left the timeline (optional — the editor frees its media element)
|
|
16
|
+
onPlace(m, c) → Source clips landed (optional — waveforms, channel stems)
|
|
17
|
+
defaultProps() → props for newly placed clips (optional, default {})
|
|
18
|
+
ignoreLocks → treat nothing as locked (an agent's force:true)
|
|
19
|
+
`forDoc(doc, opts)` builds the env from the document itself (its tracks,
|
|
20
|
+
lockedTracks and untargetedTracks) for headless callers. */
|
|
21
|
+
(function (root, factory) {
|
|
22
|
+
const api = factory();
|
|
23
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
24
|
+
else root.FableCutEdit = api;
|
|
25
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
26
|
+
"use strict";
|
|
27
|
+
|
|
28
|
+
const MIN_DUR = 0.05;
|
|
29
|
+
const MIN_TRANS_DUR = 0.1;
|
|
30
|
+
const CROSSFADE_DUR = 1;
|
|
31
|
+
const GAP_EPS = 1e-4;
|
|
32
|
+
const TRIM_EPS = 1e-3;
|
|
33
|
+
const XF_EPS = 0.02;
|
|
34
|
+
const TRIM_LOCKED = "Locked — unlock the clip or its track to edit";
|
|
35
|
+
const DEFAULT_TRACKS = [
|
|
36
|
+
{ id: "V3", kind: "video" }, { id: "V2", kind: "video" }, { id: "V1", kind: "video" },
|
|
37
|
+
{ id: "A1", kind: "audio" }, { id: "A2", kind: "audio" }, { id: "A3", kind: "audio" }, { id: "A4", kind: "audio" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/* ── Pure clip helpers ── */
|
|
41
|
+
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
42
|
+
const clipEnd = (c) => c.start + c.duration;
|
|
43
|
+
const clipSpeed = (c) => clamp(+(c.props?.speed) || 1, 0.1, 8);
|
|
44
|
+
const isMediaClip = (c) => c.kind === "video" || c.kind === "audio";
|
|
45
|
+
const EASE = {
|
|
46
|
+
linear: (u) => u,
|
|
47
|
+
"ease-in": (u) => u * u,
|
|
48
|
+
"ease-out": (u) => 1 - (1 - u) * (1 - u),
|
|
49
|
+
"ease-in-out": (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2),
|
|
50
|
+
};
|
|
51
|
+
function kfChannel(c, key, local, fallback) {
|
|
52
|
+
const kfs = c.keyframes?.[key];
|
|
53
|
+
if (!Array.isArray(kfs) || !kfs.length) return fallback;
|
|
54
|
+
if (local <= kfs[0].t) return kfs[0].v;
|
|
55
|
+
if (local >= kfs[kfs.length - 1].t) return kfs[kfs.length - 1].v;
|
|
56
|
+
for (let i = 0; i < kfs.length - 1; i++) {
|
|
57
|
+
const a = kfs[i], b = kfs[i + 1];
|
|
58
|
+
if (local >= a.t && local <= b.t) {
|
|
59
|
+
const u = (local - a.t) / Math.max(1e-6, b.t - a.t);
|
|
60
|
+
const ez = EASE[b.ease || "ease-in-out"] || EASE.linear;
|
|
61
|
+
return a.v + (b.v - a.v) * ez(u);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
function hasSpeedRamp(c) {
|
|
67
|
+
return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0;
|
|
68
|
+
}
|
|
69
|
+
/** Source time at timeline time t: in + ∫speed (trapezoid, 1/120 s steps). */
|
|
70
|
+
function mediaTimeAt(c, t) {
|
|
71
|
+
const base = clipSpeed(c);
|
|
72
|
+
const local = clamp(t - c.start, 0, c.duration);
|
|
73
|
+
if (!hasSpeedRamp(c)) return c.in + local * base;
|
|
74
|
+
const step = 1 / 120;
|
|
75
|
+
let sum = 0, prev = clamp(kfChannel(c, "speed", 0, base), 0.1, 8);
|
|
76
|
+
for (let lt = step; lt < local + step; lt += step) {
|
|
77
|
+
const at = Math.min(local, lt);
|
|
78
|
+
const cur = clamp(kfChannel(c, "speed", at, base), 0.1, 8);
|
|
79
|
+
sum += ((prev + cur) / 2) * (at - (lt - step));
|
|
80
|
+
prev = cur;
|
|
81
|
+
}
|
|
82
|
+
return c.in + sum;
|
|
83
|
+
}
|
|
84
|
+
/* Rebase clip-local keyframe times by -offset, dropping ones outside [0, dur] */
|
|
85
|
+
function shiftKF(kfs, offset, dur) {
|
|
86
|
+
if (!kfs) return undefined;
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [k, arr] of Object.entries(kfs)) {
|
|
89
|
+
const a = arr.map((kf) => ({ ...kf, t: +(kf.t - offset).toFixed(4) }))
|
|
90
|
+
.filter((kf) => kf.t >= -1e-3 && kf.t <= dur + 1e-3);
|
|
91
|
+
if (a.length) out[k] = a;
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(out).length ? out : undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
|
|
96
|
+
function defaultPanForChannel(ch) {
|
|
97
|
+
if (ch === 0) return -1;
|
|
98
|
+
if (ch === 1) return 1;
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function create(env) {
|
|
103
|
+
const P = () => env.doc();
|
|
104
|
+
const uid = env.uid || (() => Math.random().toString(36).slice(2, 9));
|
|
105
|
+
const timeAt = env.mediaTimeAt || mediaTimeAt;
|
|
106
|
+
const removed = (c) => { if (env.onRemove) env.onRemove(c); };
|
|
107
|
+
const getMedia = (id) => P().media.find((m) => m.id === id);
|
|
108
|
+
const getClip = (id) => P().clips.find((c) => c.id === id) || null;
|
|
109
|
+
const dropClips = (pred) => {
|
|
110
|
+
const project = P();
|
|
111
|
+
const keep = [];
|
|
112
|
+
for (const x of project.clips) { if (pred(x)) removed(x); else keep.push(x); }
|
|
113
|
+
project.clips = keep;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/* ── Targeting, locks, links ── */
|
|
117
|
+
const isTrackLocked = (id) => !env.ignoreLocks && env.trackLocked(id);
|
|
118
|
+
const isEditTarget = (id) => env.trackTargeted(id) && !isTrackLocked(id);
|
|
119
|
+
const isClipLocked = (c) => !!c && !env.ignoreLocks && (c.locked === true || isTrackLocked(c.track));
|
|
120
|
+
const isGroupLocked = (c) => withLinked([c]).some(isClipLocked);
|
|
121
|
+
/** Drop every clip whose linked group is locked. */
|
|
122
|
+
const withoutLocked = (clips) => clips.filter((c) => !isGroupLocked(c));
|
|
123
|
+
const editTargetTracks = () => env.tracks().filter((tr) => isEditTarget(tr.id));
|
|
124
|
+
|
|
125
|
+
/* Expand a clip list so each AV-linked partner is included once.
|
|
126
|
+
Supports N-way `linkGroup` (video + per-channel stems) and legacy pairwise `linkedId`. */
|
|
127
|
+
function withLinked(clips) {
|
|
128
|
+
const out = new Map();
|
|
129
|
+
const groups = new Set();
|
|
130
|
+
for (const c of clips) {
|
|
131
|
+
out.set(c.id, c);
|
|
132
|
+
if (c.linkGroup) groups.add(c.linkGroup);
|
|
133
|
+
else {
|
|
134
|
+
const L = c.linkedId ? getClip(c.linkedId) : null;
|
|
135
|
+
if (L) out.set(L.id, L);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (groups.size) {
|
|
139
|
+
for (const x of P().clips) {
|
|
140
|
+
if (x.linkGroup && groups.has(x.linkGroup)) out.set(x.id, x);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [...out.values()];
|
|
144
|
+
}
|
|
145
|
+
/* Rebuild AV linkGroups: video + audio clips that share mediaId and the
|
|
146
|
+
same start/in/duration belong together (e.g. picture + L/R/C stems from
|
|
147
|
+
one file). A clip the user unlinked carries `unlinked: true` and is never
|
|
148
|
+
re-paired. Legacy pairwise linkedId is cleared in favor of linkGroup. */
|
|
149
|
+
function relinkClips() {
|
|
150
|
+
const project = P();
|
|
151
|
+
const near = (a, b) => Math.abs((+a || 0) - (+b || 0)) < 1e-3;
|
|
152
|
+
// A denoised copy of a file (media.derivedFrom) still pairs with its picture.
|
|
153
|
+
const baseOf = (id) => getMedia(id)?.derivedFrom || id;
|
|
154
|
+
for (const c of project.clips) {
|
|
155
|
+
delete c.linkGroup;
|
|
156
|
+
delete c.linkedId;
|
|
157
|
+
}
|
|
158
|
+
const audios = project.clips.filter((c) => c.kind === "audio" && c.mediaId && c.unlinked !== true);
|
|
159
|
+
const used = new Set();
|
|
160
|
+
for (const v of project.clips) {
|
|
161
|
+
if (v.kind !== "video" || !v.mediaId || v.unlinked === true) continue;
|
|
162
|
+
const partners = audios.filter((a) =>
|
|
163
|
+
!used.has(a.id) &&
|
|
164
|
+
baseOf(a.mediaId) === v.mediaId &&
|
|
165
|
+
near(a.start, v.start) &&
|
|
166
|
+
near(a.in, v.in) &&
|
|
167
|
+
near(a.duration, v.duration)
|
|
168
|
+
);
|
|
169
|
+
if (!partners.length) continue;
|
|
170
|
+
const lg = "lg_" + uid();
|
|
171
|
+
v.linkGroup = lg;
|
|
172
|
+
for (const a of partners) {
|
|
173
|
+
a.linkGroup = lg;
|
|
174
|
+
used.add(a.id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── Split ── */
|
|
180
|
+
/* Cut a clip at timeline time t (must fall strictly inside the clip). Leaves
|
|
181
|
+
the left piece as `c` and appends the right piece. */
|
|
182
|
+
function splitClipAt(c, t) {
|
|
183
|
+
if (!(t > c.start + MIN_DUR && t < clipEnd(c) - MIN_DUR)) return null;
|
|
184
|
+
const cut = t - c.start;
|
|
185
|
+
const right = {
|
|
186
|
+
...c, id: "c_" + uid(), props: { ...c.props },
|
|
187
|
+
start: t, in: +(timeAt(c, t)).toFixed(4), duration: clipEnd(c) - t,
|
|
188
|
+
keyframes: shiftKF(c.keyframes, cut, clipEnd(c) - t),
|
|
189
|
+
transitionIn: undefined,
|
|
190
|
+
linkedId: undefined,
|
|
191
|
+
linkGroup: undefined,
|
|
192
|
+
};
|
|
193
|
+
c.duration = cut;
|
|
194
|
+
c.keyframes = shiftKF(c.keyframes, 0, cut);
|
|
195
|
+
c.transitionOut = undefined;
|
|
196
|
+
P().clips.push(right);
|
|
197
|
+
return right;
|
|
198
|
+
}
|
|
199
|
+
/* After splitting a set of clips, wire each new right half to its partner's right half. */
|
|
200
|
+
function relinkSplitRights(targets, newLink) {
|
|
201
|
+
const newGroups = new Map(); // old linkGroup -> new linkGroup for right halves
|
|
202
|
+
for (const c of targets) {
|
|
203
|
+
const right = newLink.get(c.id);
|
|
204
|
+
if (!right) continue;
|
|
205
|
+
if (c.linkGroup) {
|
|
206
|
+
if (!newGroups.has(c.linkGroup)) newGroups.set(c.linkGroup, "lg_" + uid());
|
|
207
|
+
right.linkGroup = newGroups.get(c.linkGroup);
|
|
208
|
+
} else {
|
|
209
|
+
const partner = c.linkedId ? newLink.get(c.linkedId) : null;
|
|
210
|
+
if (partner) {
|
|
211
|
+
right.linkedId = partner.id;
|
|
212
|
+
partner.linkedId = right.id;
|
|
213
|
+
} else {
|
|
214
|
+
delete right.linkedId;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Split `pool` (default: clips on the targeted tracks) at t, linked
|
|
220
|
+
* partners included. Returns {split, blocked}: right halves made, and
|
|
221
|
+
* whether locked clips under t were left whole. */
|
|
222
|
+
function splitAt(t, pool) {
|
|
223
|
+
const project = P();
|
|
224
|
+
pool = pool || project.clips.filter((c) => isEditTarget(c.track));
|
|
225
|
+
const straddlers = pool.filter((c) => t > c.start + MIN_DUR && t < clipEnd(c) - MIN_DUR);
|
|
226
|
+
const targets = withoutLocked(withLinked(straddlers));
|
|
227
|
+
const newLink = new Map(); // oldClipId -> newRight
|
|
228
|
+
for (const c of targets) {
|
|
229
|
+
const right = splitClipAt(c, t);
|
|
230
|
+
if (right) newLink.set(c.id, right);
|
|
231
|
+
}
|
|
232
|
+
relinkSplitRights(targets, newLink);
|
|
233
|
+
return { split: newLink.size, blocked: targets.length < withLinked(straddlers).length, rights: [...newLink.values()] };
|
|
234
|
+
}
|
|
235
|
+
/** Split the targeted tracks at several times (IN and OUT), right to left
|
|
236
|
+
* so each cut still lands on the left-hand piece. Returns pieces made. */
|
|
237
|
+
function splitAtTimes(times) {
|
|
238
|
+
const project = P();
|
|
239
|
+
const hit = (c) => times.some((t) => t > c.start + MIN_DUR && t < clipEnd(c) - MIN_DUR);
|
|
240
|
+
const targets = withoutLocked(withLinked(project.clips.filter((c) => isEditTarget(c.track) && hit(c))));
|
|
241
|
+
let n = 0;
|
|
242
|
+
for (const t of times.slice().sort((a, b) => b - a)) {
|
|
243
|
+
const atT = targets.filter((c) => t > c.start + MIN_DUR && t < clipEnd(c) - MIN_DUR);
|
|
244
|
+
const newLink = new Map();
|
|
245
|
+
for (const c of atT) {
|
|
246
|
+
const right = splitClipAt(c, t);
|
|
247
|
+
if (right) newLink.set(c.id, right);
|
|
248
|
+
}
|
|
249
|
+
relinkSplitRights(atT, newLink);
|
|
250
|
+
n += newLink.size;
|
|
251
|
+
}
|
|
252
|
+
return n;
|
|
253
|
+
}
|
|
254
|
+
/** Discard clip heads before t0 and tails after t1 on the targeted tracks
|
|
255
|
+
* (either may be null); linked partners get the identical trim. Clips
|
|
256
|
+
* wholly outside go. Returns how many clips changed or went. */
|
|
257
|
+
function trimToRange(t0In, t1In) {
|
|
258
|
+
const project = P();
|
|
259
|
+
const onTrack = (c) => isEditTarget(c.track) && !isGroupLocked(c);
|
|
260
|
+
const doomed = new Set();
|
|
261
|
+
let changed = 0;
|
|
262
|
+
// Sync lock: linked partners ride along even on untargeted tracks.
|
|
263
|
+
const targets = withoutLocked(withLinked(project.clips.filter((c) => onTrack(c))));
|
|
264
|
+
for (const c of targets) {
|
|
265
|
+
const start = c.start, end = clipEnd(c);
|
|
266
|
+
let t0 = start, t1 = end;
|
|
267
|
+
if (t0In != null) t0 = Math.max(t0, t0In);
|
|
268
|
+
if (t1In != null) t1 = Math.min(t1, t1In);
|
|
269
|
+
if (t1 - t0 < MIN_DUR) { doomed.add(c.id); continue; }
|
|
270
|
+
const dIn = t0 - start;
|
|
271
|
+
if (dIn > 1e-6) {
|
|
272
|
+
c.start = t0;
|
|
273
|
+
if (isMediaClip(c)) c.in += dIn * clipSpeed(c);
|
|
274
|
+
else c.in = 0;
|
|
275
|
+
c.duration -= dIn;
|
|
276
|
+
c.keyframes = shiftKF(c.keyframes, dIn, c.duration);
|
|
277
|
+
c.transitionIn = undefined;
|
|
278
|
+
changed++;
|
|
279
|
+
}
|
|
280
|
+
if (clipEnd(c) - t1 > 1e-6) {
|
|
281
|
+
c.duration = Math.max(MIN_DUR, t1 - c.start);
|
|
282
|
+
c.keyframes = shiftKF(c.keyframes, 0, c.duration);
|
|
283
|
+
c.transitionOut = undefined;
|
|
284
|
+
changed++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (doomed.size) dropClips((c) => doomed.has(c.id));
|
|
288
|
+
return changed + doomed.size;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ── Delete ── */
|
|
292
|
+
/** Remove clips (their linked partners too). Locked groups stay.
|
|
293
|
+
* Returns {removed, blocked}. */
|
|
294
|
+
function removeClips(clips) {
|
|
295
|
+
const picked = withLinked(clips);
|
|
296
|
+
const doomed = withoutLocked(picked);
|
|
297
|
+
const ids = new Set(doomed.map((c) => c.id));
|
|
298
|
+
if (ids.size) dropClips((x) => ids.has(x.id));
|
|
299
|
+
return { removed: doomed, blocked: doomed.length < picked.length };
|
|
300
|
+
}
|
|
301
|
+
/** Remove clips and pull later clips left on each targeted track to close
|
|
302
|
+
* the hole (per-track ripple). Returns {removed, blocked}. */
|
|
303
|
+
function rippleDelete(clips) {
|
|
304
|
+
const project = P();
|
|
305
|
+
const picked = withLinked(clips);
|
|
306
|
+
const doomed = withoutLocked(picked);
|
|
307
|
+
const result = { removed: doomed, blocked: doomed.length < picked.length };
|
|
308
|
+
if (!doomed.length) return result;
|
|
309
|
+
const byTrack = new Map();
|
|
310
|
+
for (const c of doomed) {
|
|
311
|
+
if (!byTrack.has(c.track)) byTrack.set(c.track, []);
|
|
312
|
+
byTrack.get(c.track).push(c);
|
|
313
|
+
}
|
|
314
|
+
const ids = new Set(doomed.map((c) => c.id));
|
|
315
|
+
dropClips((x) => ids.has(x.id));
|
|
316
|
+
const eps = 1e-6;
|
|
317
|
+
// Merged removed ranges per targeted, unlocked track.
|
|
318
|
+
const rangesByTrack = new Map();
|
|
319
|
+
for (const [trackId, gone] of byTrack) {
|
|
320
|
+
if (!isEditTarget(trackId)) continue;
|
|
321
|
+
const ranges = gone.map((c) => [c.start, clipEnd(c)]).sort((a, b) => a[0] - b[0]);
|
|
322
|
+
const merged = [];
|
|
323
|
+
for (const [s, e] of ranges) {
|
|
324
|
+
const last = merged[merged.length - 1];
|
|
325
|
+
if (last && s <= last[1] + eps) last[1] = Math.max(last[1], e);
|
|
326
|
+
else merged.push([s, e]);
|
|
327
|
+
}
|
|
328
|
+
rangesByTrack.set(trackId, merged);
|
|
329
|
+
}
|
|
330
|
+
const shiftFor = (ranges, p) => {
|
|
331
|
+
let d = 0;
|
|
332
|
+
for (const [s, e] of ranges) if (e <= p + eps) d += e - s;
|
|
333
|
+
return d;
|
|
334
|
+
};
|
|
335
|
+
// Sync lock: a linked group shifts ONCE by the union of its member tracks'
|
|
336
|
+
// ranges (partners on untargeted tracks ride along) — never once per track.
|
|
337
|
+
// Unlinked clips use their own track's ranges.
|
|
338
|
+
const grouped = new Map(), groups = [], solo = [];
|
|
339
|
+
for (const c of project.clips) {
|
|
340
|
+
if (grouped.has(c.id)) continue;
|
|
341
|
+
const members = withLinked([c]);
|
|
342
|
+
if (members.length === 1) { solo.push(c); continue; }
|
|
343
|
+
const g = { tracks: new Set(members.map((x) => x.track)), clips: members };
|
|
344
|
+
groups.push(g);
|
|
345
|
+
for (const x of members) grouped.set(x.id, g);
|
|
346
|
+
}
|
|
347
|
+
for (const c of solo) {
|
|
348
|
+
if (isClipLocked(c)) continue; // locked clips stay put
|
|
349
|
+
const d = shiftFor(rangesByTrack.get(c.track) || [], c.start);
|
|
350
|
+
if (d > 0) c.start = Math.max(0, +(c.start - d).toFixed(4));
|
|
351
|
+
}
|
|
352
|
+
for (const g of groups) {
|
|
353
|
+
if (g.clips.some(isClipLocked)) continue; // a locked member pins the whole group
|
|
354
|
+
const all = [];
|
|
355
|
+
for (const tid of g.tracks) {
|
|
356
|
+
const r = rangesByTrack.get(tid);
|
|
357
|
+
if (r) all.push(...r);
|
|
358
|
+
}
|
|
359
|
+
if (!all.length) continue;
|
|
360
|
+
all.sort((a, b) => a[0] - b[0]);
|
|
361
|
+
const merged = [];
|
|
362
|
+
for (const [s, e] of all) {
|
|
363
|
+
const last = merged[merged.length - 1];
|
|
364
|
+
if (last && s <= last[1] + eps) last[1] = Math.max(last[1], e);
|
|
365
|
+
else merged.push([s, e]);
|
|
366
|
+
}
|
|
367
|
+
for (const c of g.clips) {
|
|
368
|
+
const d = shiftFor(merged, c.start);
|
|
369
|
+
if (d > 0) c.start = Math.max(0, +(c.start - d).toFixed(4));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* ── Gaps ── */
|
|
376
|
+
/* Gap under t on one track, or null if a clip covers t.
|
|
377
|
+
Empty track → [0, +Infinity]. Trailing void → gapEnd = +Infinity. */
|
|
378
|
+
function gapAt(trackId, t) {
|
|
379
|
+
const clips = P().clips.filter((c) => c.track === trackId).sort((a, b) => a.start - b.start);
|
|
380
|
+
for (const c of clips) {
|
|
381
|
+
if (c.start <= t && t < clipEnd(c)) return null;
|
|
382
|
+
}
|
|
383
|
+
let gapStart = 0;
|
|
384
|
+
for (const c of clips) {
|
|
385
|
+
if (clipEnd(c) <= t) gapStart = Math.max(gapStart, clipEnd(c));
|
|
386
|
+
}
|
|
387
|
+
let gapEnd = Infinity;
|
|
388
|
+
for (const c of clips) {
|
|
389
|
+
if (c.start > t) gapEnd = Math.min(gapEnd, c.start);
|
|
390
|
+
}
|
|
391
|
+
return { gapStart, gapEnd };
|
|
392
|
+
}
|
|
393
|
+
/* Sync-safe close: every targeted track must have a gap at t; close the
|
|
394
|
+
intersection of those gaps by shifting later clips on those tracks.
|
|
395
|
+
Locked tracks are not targets, and locked clips stay put. Returns the
|
|
396
|
+
seconds closed, or a string saying why nothing was. */
|
|
397
|
+
function closeGapAt(t) {
|
|
398
|
+
const enabled = editTargetTracks();
|
|
399
|
+
if (!enabled.length) return "No targeted tracks — click a track name to target it";
|
|
400
|
+
let L = 0, R = Infinity;
|
|
401
|
+
for (const tr of enabled) {
|
|
402
|
+
const g = gapAt(tr.id, t);
|
|
403
|
+
if (!g) return `No gap on ${tr.id} — untarget the track or move the playhead`;
|
|
404
|
+
L = Math.max(L, g.gapStart);
|
|
405
|
+
R = Math.min(R, g.gapEnd);
|
|
406
|
+
}
|
|
407
|
+
const G = R - L;
|
|
408
|
+
if (!isFinite(R) || G <= GAP_EPS) return "Nothing to close at playhead";
|
|
409
|
+
// Sync lock: linked partners ride along even on untargeted tracks.
|
|
410
|
+
const movers = withoutLocked(withLinked(P().clips.filter((c) => isEditTarget(c.track) && c.start >= R - GAP_EPS)));
|
|
411
|
+
if (!movers.length) return "Nothing to close at playhead";
|
|
412
|
+
for (const c of movers) c.start = Math.max(0, c.start - G);
|
|
413
|
+
return G;
|
|
414
|
+
}
|
|
415
|
+
/* Aligned gaps on targeted tracks inside [t0, t1] — same notion as closeGapAt. */
|
|
416
|
+
function listAlignedGaps(t0, t1) {
|
|
417
|
+
const enabled = editTargetTracks();
|
|
418
|
+
if (!enabled.length || t1 - t0 <= GAP_EPS) return [];
|
|
419
|
+
const edges = new Set([t0, t1]);
|
|
420
|
+
for (const c of P().clips) {
|
|
421
|
+
if (!isEditTarget(c.track)) continue;
|
|
422
|
+
const s = c.start, e = clipEnd(c);
|
|
423
|
+
if (s > t0 && s < t1) edges.add(s);
|
|
424
|
+
if (e > t0 && e < t1) edges.add(e);
|
|
425
|
+
}
|
|
426
|
+
const ts = [...edges].sort((a, b) => a - b);
|
|
427
|
+
const gaps = [];
|
|
428
|
+
const seen = new Set();
|
|
429
|
+
for (let i = 0; i < ts.length - 1; i++) {
|
|
430
|
+
const a = ts[i], b = ts[i + 1];
|
|
431
|
+
if (b - a <= GAP_EPS) continue;
|
|
432
|
+
const mid = (a + b) / 2;
|
|
433
|
+
let L = -Infinity, R = Infinity;
|
|
434
|
+
let ok = true;
|
|
435
|
+
for (const tr of enabled) {
|
|
436
|
+
const g = gapAt(tr.id, mid);
|
|
437
|
+
if (!g) { ok = false; break; }
|
|
438
|
+
L = Math.max(L, g.gapStart);
|
|
439
|
+
R = Math.min(R, g.gapEnd);
|
|
440
|
+
}
|
|
441
|
+
if (!ok) continue;
|
|
442
|
+
L = Math.max(L, t0);
|
|
443
|
+
R = Math.min(isFinite(R) ? R : t1, t1);
|
|
444
|
+
if (R - L <= GAP_EPS) continue;
|
|
445
|
+
const key = L.toFixed(5) + ":" + R.toFixed(5);
|
|
446
|
+
if (seen.has(key)) continue;
|
|
447
|
+
seen.add(key);
|
|
448
|
+
gaps.push({ L, R });
|
|
449
|
+
}
|
|
450
|
+
gaps.sort((a, b) => a.L - b.L);
|
|
451
|
+
return gaps;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* ── Insert / overwrite (three-point editing) ── */
|
|
455
|
+
/** Lanes a Source edit of media `m` lands on: [picture, ...stems] for video.
|
|
456
|
+
* Source patching follows track targeting — the preferred lane (V1 / A1 / V3
|
|
457
|
+
* for SVG) when it is targeted and unlocked, else the lowest-numbered lane
|
|
458
|
+
* that is. Resolved against the live track list, since any lane can be
|
|
459
|
+
* removed. For video, `picture` is null when no video lane is a target (the
|
|
460
|
+
* edit lands audio-only); for other media an empty list means nowhere to go. */
|
|
461
|
+
function sourceEditTracks(m, stems = 2) {
|
|
462
|
+
const byNum = (x, y) => (parseInt(x.slice(1), 10) || 0) - (parseInt(y.slice(1), 10) || 0);
|
|
463
|
+
const targets = (kind) => env.tracks().filter((t) => t.kind === kind && isEditTarget(t.id))
|
|
464
|
+
.map((t) => t.id).sort(byNum);
|
|
465
|
+
const lanes = targets(m.kind === "audio" ? "audio" : "video");
|
|
466
|
+
const preferred = m.kind === "audio" ? "A1" : m.kind === "svg" ? "V3" : "V1";
|
|
467
|
+
const pictureTrack = lanes.includes(preferred) ? preferred : lanes[0] || null;
|
|
468
|
+
if (m.kind === "video") return [pictureTrack, ...targets("audio").slice(0, stems)];
|
|
469
|
+
return pictureTrack ? [pictureTrack] : [];
|
|
470
|
+
}
|
|
471
|
+
/** Tracks punched by Replace: placement lanes, plus any overlapping linked
|
|
472
|
+
* AV stems on A3+ (`audioChannel` set). Leaves V2/V3 and standalone music alone. */
|
|
473
|
+
function sourceReplacePunchTracks(m, t0, t1, stems) {
|
|
474
|
+
const eps = 1e-6;
|
|
475
|
+
const tracks = new Set(sourceEditTracks(m, stems).filter(Boolean));
|
|
476
|
+
if (m.kind === "video") {
|
|
477
|
+
for (const c of P().clips) {
|
|
478
|
+
if (c.kind !== "audio" || c.props?.audioChannel == null) continue;
|
|
479
|
+
if (clipEnd(c) <= t0 + eps || c.start >= t1 - eps) continue;
|
|
480
|
+
if (isEditTarget(c.track)) tracks.add(c.track);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return [...tracks];
|
|
484
|
+
}
|
|
485
|
+
/** Place Source window clips at `at` (no timeline surgery) on the lanes
|
|
486
|
+
* sourceEditTracks picked. With no targeted video lane the picture is
|
|
487
|
+
* dropped and only the stems land. Returns the main clip, or the first
|
|
488
|
+
* stem when only audio landed, or null. */
|
|
489
|
+
function placeSourceWindowClips(m, inn, duration, at, stems) {
|
|
490
|
+
const project = P();
|
|
491
|
+
const [pictureTrack, ...stemTracks] = sourceEditTracks(m, stems);
|
|
492
|
+
const base = () => ({ ...(env.defaultProps ? env.defaultProps() : {}) });
|
|
493
|
+
const name = String(m.name || "").replace(/\.[^.]+$/, "");
|
|
494
|
+
const start = +at.toFixed(4);
|
|
495
|
+
const innR = +inn.toFixed(4);
|
|
496
|
+
const durR = +duration.toFixed(4);
|
|
497
|
+
const lg = "lg_" + uid();
|
|
498
|
+
let c = null;
|
|
499
|
+
if (pictureTrack) {
|
|
500
|
+
c = {
|
|
501
|
+
id: "c_" + uid(), mediaId: m.id, kind: m.kind, track: pictureTrack,
|
|
502
|
+
start, in: innR, duration: durR, name,
|
|
503
|
+
props: base(),
|
|
504
|
+
};
|
|
505
|
+
project.clips.push(c);
|
|
506
|
+
}
|
|
507
|
+
if (m.kind === "video") {
|
|
508
|
+
if (c) { c.props.volume = 0; c.linkGroup = lg; }
|
|
509
|
+
let firstStem = null;
|
|
510
|
+
for (let ch = 0; ch < stemTracks.length; ch++) {
|
|
511
|
+
const stem = {
|
|
512
|
+
id: "c_" + uid(), mediaId: m.id, kind: "audio", track: stemTracks[ch],
|
|
513
|
+
start, in: innR, duration: durR, name,
|
|
514
|
+
props: { ...base(), audioChannel: ch, pan: defaultPanForChannel(ch) },
|
|
515
|
+
linkGroup: lg,
|
|
516
|
+
};
|
|
517
|
+
project.clips.push(stem);
|
|
518
|
+
firstStem = firstStem || stem;
|
|
519
|
+
}
|
|
520
|
+
if (env.onPlace) env.onPlace(m, c);
|
|
521
|
+
return c || firstStem;
|
|
522
|
+
}
|
|
523
|
+
if (env.onPlace) env.onPlace(m, c);
|
|
524
|
+
return c;
|
|
525
|
+
}
|
|
526
|
+
/** Open a hole on one track over [t0, t1) — trim / split / delete overlaps.
|
|
527
|
+
* Sync lock: linked partners are punched too, even on untargeted tracks.
|
|
528
|
+
* Locked groups are left whole (the new clip overlaps them).
|
|
529
|
+
* Caller must relinkClips() afterwards — split pieces lose their linkGroup. */
|
|
530
|
+
function punchTrackRange(trackId, t0, t1) {
|
|
531
|
+
const project = P();
|
|
532
|
+
const eps = 1e-6;
|
|
533
|
+
if (!(t1 > t0 + eps)) return;
|
|
534
|
+
const drop = (x) => { removed(x); project.clips = project.clips.filter((y) => y !== x); };
|
|
535
|
+
const victims = withLinked(project.clips.filter((x) => x.track === trackId));
|
|
536
|
+
for (const c of victims) {
|
|
537
|
+
if (!project.clips.includes(c)) continue; // already removed this pass
|
|
538
|
+
if (isGroupLocked(c)) continue; // locked clips are never trimmed or removed
|
|
539
|
+
const end = clipEnd(c);
|
|
540
|
+
if (end <= t0 + eps || c.start >= t1 - eps) continue;
|
|
541
|
+
// Fully inside the replace window
|
|
542
|
+
if (c.start >= t0 - eps && end <= t1 + eps) { drop(c); continue; }
|
|
543
|
+
// Spans both edges → keep head + tail, drop middle
|
|
544
|
+
if (c.start < t0 - eps && end > t1 + eps) {
|
|
545
|
+
const right = splitClipAt(c, t0);
|
|
546
|
+
if (!right) {
|
|
547
|
+
// Split refused — an edge sits within MIN_DUR of t0. Don't leave the
|
|
548
|
+
// clip covering the punched range; keep the substantial side.
|
|
549
|
+
if (t0 - c.start > MIN_DUR) {
|
|
550
|
+
// Tail past t0 is the stub → keep the head, end it at t0.
|
|
551
|
+
c.duration = +(t0 - c.start).toFixed(4);
|
|
552
|
+
c.transitionOut = undefined;
|
|
553
|
+
c.keyframes = shiftKF(c.keyframes, 0, c.duration);
|
|
554
|
+
} else if (end - t1 >= MIN_DUR) {
|
|
555
|
+
// Head is the stub → keep the tail, start it at t1.
|
|
556
|
+
const oldStart = c.start;
|
|
557
|
+
c.in = +(timeAt(c, t1)).toFixed(4);
|
|
558
|
+
c.duration = +(end - t1).toFixed(4);
|
|
559
|
+
c.start = +t1.toFixed(4);
|
|
560
|
+
c.transitionIn = undefined;
|
|
561
|
+
c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
|
|
562
|
+
} else {
|
|
563
|
+
// Stubs on both sides → effectively inside the window.
|
|
564
|
+
drop(c);
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (clipEnd(right) <= t1 + eps) {
|
|
569
|
+
drop(right);
|
|
570
|
+
} else {
|
|
571
|
+
const tail = splitClipAt(right, t1);
|
|
572
|
+
if (tail) {
|
|
573
|
+
drop(right);
|
|
574
|
+
} else {
|
|
575
|
+
right.duration = +(t1 - right.start).toFixed(4);
|
|
576
|
+
right.transitionOut = undefined;
|
|
577
|
+
right.keyframes = shiftKF(right.keyframes, 0, right.duration);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
// Head overhang: ends inside the window → trim Out to t0
|
|
583
|
+
if (c.start < t0 - eps && end > t0 + eps) {
|
|
584
|
+
const cut = t0 - c.start;
|
|
585
|
+
c.duration = +cut.toFixed(4);
|
|
586
|
+
c.transitionOut = undefined;
|
|
587
|
+
c.keyframes = shiftKF(c.keyframes, 0, c.duration);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// Tail overhang: starts inside the window → trim In to t1
|
|
591
|
+
if (c.start < t1 - eps && end > t1 + eps) {
|
|
592
|
+
const oldStart = c.start;
|
|
593
|
+
c.in = +(timeAt(c, t1)).toFixed(4);
|
|
594
|
+
c.duration = +(end - t1).toFixed(4);
|
|
595
|
+
c.start = +t1.toFixed(4);
|
|
596
|
+
c.transitionIn = undefined;
|
|
597
|
+
c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Premiere-style Insert: place media `m` (source in → in + duration) at
|
|
602
|
+
* timeline `at` and ripple later clips on the targeted tracks right.
|
|
603
|
+
* Straddling clips on those tracks are split first (linked partners too);
|
|
604
|
+
* an `at` inside the unsplittable MIN_DUR zone snaps to the nearby cut.
|
|
605
|
+
* Returns {clip, at} or a string saying why it could not. */
|
|
606
|
+
function insertAt(m, inn, duration, at, stems) {
|
|
607
|
+
const project = P();
|
|
608
|
+
if (!sourceEditTracks(m, stems).some(Boolean))
|
|
609
|
+
return "No targeted track for this media — click a track name to target it (and unlock it)";
|
|
610
|
+
at = Math.max(0, at);
|
|
611
|
+
const onTrack = (c) => isEditTarget(c.track);
|
|
612
|
+
const eps = 1e-6;
|
|
613
|
+
for (const c of project.clips) {
|
|
614
|
+
if (!onTrack(c)) continue;
|
|
615
|
+
if (at > c.start - eps && at <= c.start + MIN_DUR) { at = c.start; break; }
|
|
616
|
+
if (at >= clipEnd(c) - MIN_DUR && at < clipEnd(c) + eps) { at = clipEnd(c); break; }
|
|
617
|
+
}
|
|
618
|
+
// Open a seam at `at` so the ripple can push the right halves.
|
|
619
|
+
const toSplit = withoutLocked(withLinked(project.clips.filter((c) =>
|
|
620
|
+
onTrack(c) && at > c.start + MIN_DUR && at < clipEnd(c) - MIN_DUR
|
|
621
|
+
)));
|
|
622
|
+
if (toSplit.length) {
|
|
623
|
+
const newLink = new Map();
|
|
624
|
+
for (const c of toSplit) {
|
|
625
|
+
const right = splitClipAt(c, at);
|
|
626
|
+
if (right) newLink.set(c.id, right);
|
|
627
|
+
}
|
|
628
|
+
relinkSplitRights(toSplit, newLink);
|
|
629
|
+
}
|
|
630
|
+
// Sync lock: linked partners ride along even on untargeted tracks; locked
|
|
631
|
+
// groups stay put.
|
|
632
|
+
const movers = withoutLocked(withLinked(project.clips.filter((c) => onTrack(c) && c.start >= at - eps)));
|
|
633
|
+
for (const c of movers) c.start = +(c.start + duration).toFixed(4);
|
|
634
|
+
return { clip: placeSourceWindowClips(m, inn, duration, at, stems), at };
|
|
635
|
+
}
|
|
636
|
+
/** Premiere-style Overwrite: place media at `at`, punching the destination
|
|
637
|
+
* tracks over [at, at + duration) — no ripple. Returns {clip, at} or why not. */
|
|
638
|
+
function overwriteAt(m, inn, duration, at, stems) {
|
|
639
|
+
if (!sourceEditTracks(m, stems).some(Boolean))
|
|
640
|
+
return "No targeted track for this media — click a track name to target it (and unlock it)";
|
|
641
|
+
at = Math.max(0, at);
|
|
642
|
+
const t1 = at + duration;
|
|
643
|
+
for (const tid of sourceReplacePunchTracks(m, at, t1, stems)) punchTrackRange(tid, at, t1);
|
|
644
|
+
relinkClips(); // re-pair head/tail pieces across tracks after punch splits
|
|
645
|
+
return { clip: placeSourceWindowClips(m, inn, duration, at, stems), at };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/* ── Trim tools: ripple, roll, slip, slide · range lift / extract ──
|
|
649
|
+
Each op edits from the current state by `delta` seconds of timeline time and
|
|
650
|
+
returns the delta it actually applied — clamped to the source media, MIN_DUR
|
|
651
|
+
and the free room on the track — or a string saying why it refuses (a lock,
|
|
652
|
+
a clip with no source time). The editor's drag gesture restores its
|
|
653
|
+
start-of-drag snapshot before every call, so the ops stay stateless and a
|
|
654
|
+
drag can swing back and forth freely. Sync lock as everywhere else: linked
|
|
655
|
+
partners take the identical trim, ripples shift the targeted tracks (plus
|
|
656
|
+
the edited clip's own), and locked groups never move. */
|
|
657
|
+
|
|
658
|
+
/** Longest this clip may run before it runs out of source media. */
|
|
659
|
+
function maxClipDur(c) {
|
|
660
|
+
if (!isMediaClip(c)) return Infinity;
|
|
661
|
+
const m = getMedia(c.mediaId);
|
|
662
|
+
if (!m || !(m.duration > 0)) return Infinity;
|
|
663
|
+
return Math.max(MIN_DUR, (m.duration - c.in) / clipSpeed(c));
|
|
664
|
+
}
|
|
665
|
+
/* A linked group shares its timing, but a partner may run at a different
|
|
666
|
+
speed (links are inferred from timing alone), so every source limit is the
|
|
667
|
+
tightest across the whole group, never just the clip that was grabbed. */
|
|
668
|
+
/** Longest c's linked group may run before any member runs out of source. */
|
|
669
|
+
function groupMaxDur(c) {
|
|
670
|
+
return Math.min(...withLinked([c]).map(maxClipDur));
|
|
671
|
+
}
|
|
672
|
+
/** How far c's group can pull its head back before any member reaches source 0. */
|
|
673
|
+
function groupHeadRoom(c) {
|
|
674
|
+
return Math.min(...withLinked([c]).map((x) => (isMediaClip(x) ? x.in / clipSpeed(x) : Infinity)));
|
|
675
|
+
}
|
|
676
|
+
/** The clip butting against c's head ("in") or tail ("out") on its track. */
|
|
677
|
+
function adjacentClip(c, side) {
|
|
678
|
+
const t = side === "in" ? c.start : clipEnd(c);
|
|
679
|
+
return P().clips.find((x) => x !== c && x.track === c.track &&
|
|
680
|
+
Math.abs((side === "in" ? clipEnd(x) : x.start) - t) < TRIM_EPS) || null;
|
|
681
|
+
}
|
|
682
|
+
/** Empty room on c's track before its head / after its tail. */
|
|
683
|
+
function freeRoom(c, side) {
|
|
684
|
+
let room = side === "in" ? c.start : Infinity;
|
|
685
|
+
for (const x of P().clips) {
|
|
686
|
+
if (x === c || x.track !== c.track) continue;
|
|
687
|
+
if (side === "in" && clipEnd(x) <= c.start + TRIM_EPS) room = Math.min(room, c.start - clipEnd(x));
|
|
688
|
+
if (side === "out" && x.start >= clipEnd(c) - TRIM_EPS) room = Math.min(room, x.start - clipEnd(c));
|
|
689
|
+
}
|
|
690
|
+
return Math.max(0, room);
|
|
691
|
+
}
|
|
692
|
+
function clampTransitions(c) {
|
|
693
|
+
for (const k of ["transitionIn", "transitionOut"])
|
|
694
|
+
if (c[k] && c[k].duration > c.duration) c[k] = { ...c[k], duration: +c.duration.toFixed(3) };
|
|
695
|
+
}
|
|
696
|
+
/** Set the tail of c and its linked partners (same timing, so same math). */
|
|
697
|
+
function trimGroupTail(c, dur) {
|
|
698
|
+
for (const x of withLinked([c])) {
|
|
699
|
+
x.duration = +dur.toFixed(4);
|
|
700
|
+
x.keyframes = shiftKF(x.keyframes, 0, x.duration);
|
|
701
|
+
clampTransitions(x);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/** Cut `d` seconds off the head of c and its partners (d < 0 extends it).
|
|
705
|
+
* moveStart: the head edge moves on the timeline (roll / slide); otherwise
|
|
706
|
+
* the clip keeps its start and only its source In advances (ripple). */
|
|
707
|
+
function trimGroupHead(c, d, moveStart) {
|
|
708
|
+
for (const x of withLinked([c])) {
|
|
709
|
+
if (moveStart) x.start = +(x.start + d).toFixed(4);
|
|
710
|
+
if (isMediaClip(x)) x.in = +(x.in + d * clipSpeed(x)).toFixed(4);
|
|
711
|
+
x.duration = +(x.duration - d).toFixed(4);
|
|
712
|
+
x.keyframes = shiftKF(x.keyframes, d, x.duration);
|
|
713
|
+
clampTransitions(x);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const clampD = (d, lo, hi) => Math.min(hi, Math.max(lo, d));
|
|
717
|
+
|
|
718
|
+
/** Ripple trim: move c's head or tail and shift everything after it, so
|
|
719
|
+
* no gap opens or closes. Tail: delta > 0 lengthens the clip. Head: delta > 0
|
|
720
|
+
* cuts material off the head — the clip keeps its start and the rest of the
|
|
721
|
+
* timeline pulls left by the same amount. */
|
|
722
|
+
function rippleTrim(c, side, delta) {
|
|
723
|
+
const project = P();
|
|
724
|
+
const group = withLinked([c]);
|
|
725
|
+
if (group.some(isClipLocked)) return TRIM_LOCKED;
|
|
726
|
+
const oldEnd = clipEnd(c);
|
|
727
|
+
let lo, hi;
|
|
728
|
+
if (side === "out") { lo = MIN_DUR - c.duration; hi = groupMaxDur(c) - c.duration; }
|
|
729
|
+
else { lo = -groupHeadRoom(c); hi = c.duration - MIN_DUR; }
|
|
730
|
+
const groupIds = new Set(group.map((x) => x.id));
|
|
731
|
+
const lanes = new Set(group.map((x) => x.track));
|
|
732
|
+
const movers = withoutLocked(withLinked(project.clips.filter((x) => !groupIds.has(x.id) &&
|
|
733
|
+
(isEditTarget(x.track) || lanes.has(x.track)) && !isTrackLocked(x.track) &&
|
|
734
|
+
x.start >= oldEnd - TRIM_EPS)));
|
|
735
|
+
// Pulling left can't push a lane's first mover into what sits before it.
|
|
736
|
+
const moverIds = new Set(movers.map((x) => x.id));
|
|
737
|
+
const first = new Map();
|
|
738
|
+
for (const x of movers) first.set(x.track, Math.min(first.get(x.track) ?? Infinity, x.start));
|
|
739
|
+
let leftRoom = Infinity;
|
|
740
|
+
for (const [tr, f] of first) {
|
|
741
|
+
let blockEnd = 0;
|
|
742
|
+
for (const x of project.clips)
|
|
743
|
+
if (x.track === tr && !moverIds.has(x.id) && !groupIds.has(x.id) && x.start < f - TRIM_EPS)
|
|
744
|
+
blockEnd = Math.max(blockEnd, clipEnd(x));
|
|
745
|
+
leftRoom = Math.min(leftRoom, Math.max(0, f - blockEnd));
|
|
746
|
+
}
|
|
747
|
+
if (side === "out") lo = Math.max(lo, -leftRoom); else hi = Math.min(hi, leftRoom);
|
|
748
|
+
const d = clampD(delta, lo, hi);
|
|
749
|
+
if (Math.abs(d) < 1e-9) return 0;
|
|
750
|
+
if (side === "out") trimGroupTail(c, c.duration + d); else trimGroupHead(c, d, false);
|
|
751
|
+
const shift = side === "out" ? d : -d;
|
|
752
|
+
for (const x of movers) x.start = +(x.start + shift).toFixed(4);
|
|
753
|
+
return d;
|
|
754
|
+
}
|
|
755
|
+
/** Roll edit: move the cut on c's head or tail. The clip on the other side
|
|
756
|
+
* gives or takes the same amount, so nothing downstream moves. With no clip
|
|
757
|
+
* butting against that edge, it trims into the free room only. */
|
|
758
|
+
function rollEdit(c, side, delta) {
|
|
759
|
+
const left = side === "out" ? c : adjacentClip(c, "in");
|
|
760
|
+
const right = side === "out" ? adjacentClip(c, "out") : c;
|
|
761
|
+
if ([left, right].some((x) => x && isGroupLocked(x))) return TRIM_LOCKED;
|
|
762
|
+
let lo = -Infinity, hi = Infinity;
|
|
763
|
+
if (left) { lo = MIN_DUR - left.duration; hi = groupMaxDur(left) - left.duration; }
|
|
764
|
+
else lo = -freeRoom(right, "in");
|
|
765
|
+
if (right) {
|
|
766
|
+
hi = Math.min(hi, right.duration - MIN_DUR);
|
|
767
|
+
lo = Math.max(lo, -groupHeadRoom(right));
|
|
768
|
+
} else hi = Math.min(hi, freeRoom(left, "out"));
|
|
769
|
+
const d = clampD(delta, lo, hi);
|
|
770
|
+
if (Math.abs(d) < 1e-9) return 0;
|
|
771
|
+
if (left) trimGroupTail(left, left.duration + d);
|
|
772
|
+
if (right) trimGroupHead(right, d, true);
|
|
773
|
+
return d;
|
|
774
|
+
}
|
|
775
|
+
/** Slip: show a different part of the source through the same window.
|
|
776
|
+
* delta > 0 drags the picture right, i.e. earlier source. Position and
|
|
777
|
+
* length stay; clip-local keyframes stay put on the timeline. */
|
|
778
|
+
function slipClip(c, delta) {
|
|
779
|
+
if (!isMediaClip(c)) return "Slip needs a video or audio clip — stills and titles have no source time";
|
|
780
|
+
if (isGroupLocked(c)) return TRIM_LOCKED;
|
|
781
|
+
const sp = clipSpeed(c);
|
|
782
|
+
const group = withLinked([c]);
|
|
783
|
+
// The shared In may go no later than the tightest member allows: each one
|
|
784
|
+
// consumes its own span of source (speed can differ across the group).
|
|
785
|
+
let maxIn = Infinity;
|
|
786
|
+
for (const x of group) {
|
|
787
|
+
if (!isMediaClip(x)) continue;
|
|
788
|
+
const m = getMedia(x.mediaId);
|
|
789
|
+
if (m && m.duration > 0) maxIn = Math.min(maxIn, Math.max(0, m.duration - (timeAt(x, clipEnd(x)) - x.in)));
|
|
790
|
+
}
|
|
791
|
+
const newIn = clampD(c.in - delta * sp, 0, maxIn);
|
|
792
|
+
const d = (c.in - newIn) / sp;
|
|
793
|
+
if (Math.abs(d) < 1e-9) return 0;
|
|
794
|
+
for (const x of group) x.in = +newIn.toFixed(4);
|
|
795
|
+
return d;
|
|
796
|
+
}
|
|
797
|
+
/** Slide: move c along its track; the clip before it lengthens or
|
|
798
|
+
* shortens its tail and the clip after it its head, so c's content and the
|
|
799
|
+
* sequence length stay. Without a neighbor, c slides into the free room. */
|
|
800
|
+
function slideClip(c, delta) {
|
|
801
|
+
const prev = adjacentClip(c, "in"), next = adjacentClip(c, "out");
|
|
802
|
+
if ([c, prev, next].some((x) => x && isGroupLocked(x))) return TRIM_LOCKED;
|
|
803
|
+
let lo = prev ? MIN_DUR - prev.duration : -freeRoom(c, "in");
|
|
804
|
+
let hi = prev ? groupMaxDur(prev) - prev.duration : Infinity;
|
|
805
|
+
if (next) {
|
|
806
|
+
hi = Math.min(hi, next.duration - MIN_DUR);
|
|
807
|
+
lo = Math.max(lo, -groupHeadRoom(next));
|
|
808
|
+
} else hi = Math.min(hi, freeRoom(c, "out"));
|
|
809
|
+
const d = clampD(delta, lo, hi);
|
|
810
|
+
if (Math.abs(d) < 1e-9) return 0;
|
|
811
|
+
if (prev) trimGroupTail(prev, prev.duration + d);
|
|
812
|
+
if (next) trimGroupHead(next, d, true);
|
|
813
|
+
for (const x of withLinked([c])) x.start = +(x.start + d).toFixed(4);
|
|
814
|
+
return d;
|
|
815
|
+
}
|
|
816
|
+
/** Lift removes t0→t1 on the targeted tracks and leaves the gap; extract
|
|
817
|
+
* removes it and closes the gap. Linked partners are cut too and locked
|
|
818
|
+
* clips stay whole. Returns null, or a string saying why nothing happened. */
|
|
819
|
+
function liftRange(t0, t1, extract) {
|
|
820
|
+
const project = P();
|
|
821
|
+
if (t0 == null || t1 == null || !(t1 - t0 > MIN_DUR))
|
|
822
|
+
return `Set IN and OUT first (I / O) to ${extract ? "extract" : "lift"} that range`;
|
|
823
|
+
const lanes = editTargetTracks().map((t) => t.id);
|
|
824
|
+
if (!lanes.length) return "No targeted tracks — click a track name to target it";
|
|
825
|
+
const inside = (c) => clipEnd(c) > t0 + 1e-6 && c.start < t1 - 1e-6;
|
|
826
|
+
if (!extract && !project.clips.some((c) => lanes.includes(c.track) && inside(c) && !isGroupLocked(c)))
|
|
827
|
+
return "Nothing to lift between IN and OUT";
|
|
828
|
+
for (const id of lanes) punchTrackRange(id, t0, t1);
|
|
829
|
+
relinkClips(); // re-pair head / tail pieces across tracks
|
|
830
|
+
if (extract) {
|
|
831
|
+
const gap = t1 - t0;
|
|
832
|
+
const movers = withoutLocked(withLinked(P().clips.filter((c) =>
|
|
833
|
+
isEditTarget(c.track) && c.start >= t1 - 1e-6)));
|
|
834
|
+
for (const c of movers) c.start = Math.max(0, +(c.start - gap).toFixed(4));
|
|
835
|
+
}
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/* ── Audio crossfade. A crossfade here is the same-track overlap idiom: A
|
|
840
|
+
runs on past the cut, B starts before it, A fades out and B fades in over
|
|
841
|
+
the overlap with constant-power curves. The overlap comes from spare
|
|
842
|
+
media beyond each clip's edge, split around the cut; picture linked to B
|
|
843
|
+
dissolves in over A (only B fades, so the frame never dips to black). */
|
|
844
|
+
/** Audio cuts touching `clips`: each pair is {a, b} with a before b on one
|
|
845
|
+
* track, b starting at (or before) a's end. */
|
|
846
|
+
function crossfadeCuts(clips) {
|
|
847
|
+
const pairs = new Map();
|
|
848
|
+
const audio = (x) => x.kind === "audio";
|
|
849
|
+
for (const c of clips.filter(audio)) {
|
|
850
|
+
const lane = P().clips.filter((x) => audio(x) && x !== c && x.track === c.track);
|
|
851
|
+
const next = lane.filter((x) => x.start > c.start + XF_EPS && x.start <= clipEnd(c) + XF_EPS &&
|
|
852
|
+
clipEnd(x) > clipEnd(c)).sort((x, y) => x.start - y.start)[0];
|
|
853
|
+
const prev = lane.filter((x) => x.start < c.start - XF_EPS && clipEnd(x) >= c.start - XF_EPS &&
|
|
854
|
+
clipEnd(x) < clipEnd(c)).sort((x, y) => clipEnd(y) - clipEnd(x))[0];
|
|
855
|
+
if (next) pairs.set(c.id + ">" + next.id, { a: c, b: next });
|
|
856
|
+
if (prev) pairs.set(prev.id + ">" + c.id, { a: prev, b: c });
|
|
857
|
+
}
|
|
858
|
+
return [...pairs.values()];
|
|
859
|
+
}
|
|
860
|
+
/** Spare source after a clip's out / before its in, in timeline seconds. */
|
|
861
|
+
function tailRoom(x) {
|
|
862
|
+
const m = getMedia(x.mediaId), sp = clipSpeed(x);
|
|
863
|
+
if (!(m?.duration > 0)) return 0;
|
|
864
|
+
return Math.max(0, (m.duration - (x.in + x.duration * sp)) / sp);
|
|
865
|
+
}
|
|
866
|
+
function headRoom(x) { return Math.max(0, Math.min(x.in / clipSpeed(x), x.start)); }
|
|
867
|
+
/** Crossfade one cut. Returns null, or why it could not. */
|
|
868
|
+
function crossfadeCut(a, b, dur = CROSSFADE_DUR) {
|
|
869
|
+
if (isGroupLocked(a) || isGroupLocked(b)) return "locked";
|
|
870
|
+
const ga = withLinked([a]), gb = withLinked([b]);
|
|
871
|
+
let d = clipEnd(a) - b.start; // already overlapping: fade across what is there
|
|
872
|
+
if (d < MIN_TRANS_DUR - 1e-6) {
|
|
873
|
+
const roomA = Math.min(...ga.map(tailRoom)), roomB = Math.min(...gb.map(headRoom));
|
|
874
|
+
const gap = Math.max(0, b.start - clipEnd(a)); // a hair of gap at the cut is closed too
|
|
875
|
+
const want = dur + gap;
|
|
876
|
+
let e1 = Math.min(want / 2, roomA);
|
|
877
|
+
const e2 = Math.min(want - e1, roomB);
|
|
878
|
+
e1 = Math.min(want - e2, roomA);
|
|
879
|
+
d = e1 + e2 - gap;
|
|
880
|
+
if (d < MIN_TRANS_DUR - 1e-6) return "no spare media beyond the cut";
|
|
881
|
+
for (const x of ga) x.duration = +(x.duration + e1).toFixed(4);
|
|
882
|
+
for (const x of gb) {
|
|
883
|
+
const sp = clipSpeed(x);
|
|
884
|
+
x.start = +(x.start - e2).toFixed(4);
|
|
885
|
+
x.in = +Math.max(0, x.in - e2 * sp).toFixed(4);
|
|
886
|
+
x.duration = +(x.duration + e2).toFixed(4);
|
|
887
|
+
x.keyframes = shiftKF(x.keyframes, -e2, x.duration);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
d = +Math.min(d, a.duration, b.duration).toFixed(3);
|
|
891
|
+
for (const x of ga) if (x.kind === "audio") x.transitionOut = { type: "fade", duration: d, curve: "power" };
|
|
892
|
+
for (const x of gb) {
|
|
893
|
+
if (x.kind === "audio") x.transitionIn = { type: "fade", duration: d, curve: "power" };
|
|
894
|
+
else if (x.kind === "video") x.transitionIn = { type: "fade", duration: d };
|
|
895
|
+
}
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
/** Crossfade every cut in `pairs`, once per linked pair. Returns {done, why}. */
|
|
899
|
+
function crossfadePairs(pairs, dur = CROSSFADE_DUR) {
|
|
900
|
+
let done = 0;
|
|
901
|
+
const why = new Set();
|
|
902
|
+
const seen = new Set();
|
|
903
|
+
for (const { a, b } of pairs) {
|
|
904
|
+
const key = (a.linkGroup || a.id) + ">" + (b.linkGroup || b.id); // one stereo pair = one cut
|
|
905
|
+
if (seen.has(key)) continue;
|
|
906
|
+
seen.add(key);
|
|
907
|
+
const r = crossfadeCut(a, b, dur);
|
|
908
|
+
if (r) why.add(r); else done++;
|
|
909
|
+
}
|
|
910
|
+
return { done, why: [...why] };
|
|
911
|
+
}
|
|
912
|
+
/** The audio cut nearest t (within 0.5 s) on each targeted audio track. */
|
|
913
|
+
function crossfadeCutsNear(t) {
|
|
914
|
+
const pairs = [];
|
|
915
|
+
for (const tr of env.tracks()) {
|
|
916
|
+
if (tr.kind !== "audio" || !isEditTarget(tr.id)) continue;
|
|
917
|
+
const near = crossfadeCuts(P().clips.filter((x) => x.track === tr.id))
|
|
918
|
+
.map((p) => ({ ...p, dist: Math.abs(p.b.start - t) }))
|
|
919
|
+
.filter((p) => p.dist <= 0.5).sort((x, y) => x.dist - y.dist)[0];
|
|
920
|
+
if (near) pairs.push(near);
|
|
921
|
+
}
|
|
922
|
+
return pairs;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return {
|
|
926
|
+
isTrackLocked, isEditTarget, isClipLocked, isGroupLocked, withoutLocked, editTargetTracks,
|
|
927
|
+
withLinked, relinkClips,
|
|
928
|
+
splitClipAt, relinkSplitRights, splitAt, splitAtTimes, trimToRange,
|
|
929
|
+
removeClips, rippleDelete,
|
|
930
|
+
gapAt, closeGapAt, listAlignedGaps,
|
|
931
|
+
sourceEditTracks, sourceReplacePunchTracks, placeSourceWindowClips, punchTrackRange, insertAt, overwriteAt,
|
|
932
|
+
maxClipDur, groupMaxDur, groupHeadRoom, adjacentClip, freeRoom,
|
|
933
|
+
rippleTrim, rollEdit, slipClip, slideClip, liftRange,
|
|
934
|
+
crossfadeCuts, crossfadeCut, crossfadePairs, crossfadeCutsNear,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Bind the ops to a bare project document: lanes from doc.tracks (or the
|
|
939
|
+
* default V3…V1 + A1…A4), locks and targeting from the document's own
|
|
940
|
+
* lockedTracks / untargetedTracks. opts: {tracks?: lanes to target instead,
|
|
941
|
+
* force?: ignore locks, uid?}. */
|
|
942
|
+
function forDoc(doc, opts = {}) {
|
|
943
|
+
const lanes = () => (Array.isArray(doc.tracks) && doc.tracks.length ? doc.tracks : DEFAULT_TRACKS)
|
|
944
|
+
.map((t) => ({ id: t.id, kind: t.kind || (String(t.id)[0] === "A" ? "audio" : "video") }));
|
|
945
|
+
const only = Array.isArray(opts.tracks) && opts.tracks.length ? new Set(opts.tracks) : null;
|
|
946
|
+
return create({
|
|
947
|
+
doc: () => doc,
|
|
948
|
+
tracks: lanes,
|
|
949
|
+
trackLocked: (id) => Array.isArray(doc.lockedTracks) && doc.lockedTracks.includes(id),
|
|
950
|
+
trackTargeted: (id) => only ? only.has(id)
|
|
951
|
+
: !(Array.isArray(doc.untargetedTracks) && doc.untargetedTracks.includes(id)),
|
|
952
|
+
uid: opts.uid,
|
|
953
|
+
ignoreLocks: opts.force === true,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
MIN_DUR, MIN_TRANS_DUR, CROSSFADE_DUR, GAP_EPS, TRIM_LOCKED, DEFAULT_TRACKS,
|
|
959
|
+
clipEnd, clipSpeed, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, defaultPanForChannel,
|
|
960
|
+
create, forDoc,
|
|
961
|
+
};
|
|
962
|
+
});
|