@aicut/core 0.4.3 → 0.6.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/README.md +178 -1
- package/dist/chunk-H6AY6NW4.js +123 -0
- package/dist/chunk-H6AY6NW4.js.map +1 -0
- package/dist/chunk-WTCK3XQ6.js +93 -0
- package/dist/chunk-WTCK3XQ6.js.map +1 -0
- package/dist/i18n-B24k4XVG.d.cts +84 -0
- package/dist/i18n-B24k4XVG.d.ts +84 -0
- package/dist/index.cjs +2383 -205
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +523 -6
- package/dist/index.d.ts +523 -6
- package/dist/index.js +2187 -140
- package/dist/index.js.map +1 -1
- package/dist/lighting/index.cjs +24 -6
- package/dist/lighting/index.cjs.map +1 -1
- package/dist/lighting/index.d.cts +2 -1
- package/dist/lighting/index.d.ts +2 -1
- package/dist/lighting/index.js +1 -1
- package/dist/playback/webcodecs/index.cjs +10679 -0
- package/dist/playback/webcodecs/index.cjs.map +1 -0
- package/dist/playback/webcodecs/index.d.cts +125 -0
- package/dist/playback/webcodecs/index.d.ts +125 -0
- package/dist/playback/webcodecs/index.js +10608 -0
- package/dist/playback/webcodecs/index.js.map +1 -0
- package/dist/types-BbZjOQLz.d.ts +108 -0
- package/dist/types-CjvRUPtZ.d.cts +108 -0
- package/dist/types-CmS-UIEr.d.cts +137 -0
- package/dist/types-CmS-UIEr.d.ts +137 -0
- package/package.json +15 -4
- package/styles/theme.css +358 -2
- package/dist/chunk-CCDON7CU.js +0 -87
- package/dist/chunk-CCDON7CU.js.map +0 -1
- package/dist/types-C95koNwJ.d.cts +0 -120
- package/dist/types-C95koNwJ.d.ts +0 -120
package/dist/index.cjs
CHANGED
|
@@ -80,7 +80,110 @@ function createId(prefix = "id") {
|
|
|
80
80
|
return `${prefix}_${t}${rand}`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// src/keyframes/types.ts
|
|
84
|
+
var IDENTITY_TRANSFORM = {
|
|
85
|
+
panX: 0,
|
|
86
|
+
panY: 0,
|
|
87
|
+
scale: 1
|
|
88
|
+
};
|
|
89
|
+
function isIdentityTransform(t) {
|
|
90
|
+
return Math.abs(t.panX) < 1e-3 && Math.abs(t.panY) < 1e-3 && Math.abs(t.scale - 1) < 1e-4;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/keyframes/interpolate.ts
|
|
94
|
+
function applyEasing(t, easing) {
|
|
95
|
+
switch (easing) {
|
|
96
|
+
case "linear":
|
|
97
|
+
return t;
|
|
98
|
+
case "easeIn":
|
|
99
|
+
return t * t * t;
|
|
100
|
+
case "easeOut": {
|
|
101
|
+
const u = 1 - t;
|
|
102
|
+
return 1 - u * u * u;
|
|
103
|
+
}
|
|
104
|
+
case "easeInOut":
|
|
105
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function defaultFor(prop) {
|
|
109
|
+
return prop === "scale" ? 1 : 0;
|
|
110
|
+
}
|
|
111
|
+
function staticValue(clip, prop) {
|
|
112
|
+
const v = clip[prop];
|
|
113
|
+
return v ?? defaultFor(prop);
|
|
114
|
+
}
|
|
115
|
+
function keyframesForProp(kfs, prop) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const k of kfs) if (k.prop === prop) out.push(k);
|
|
118
|
+
out.sort((a, b) => a.time - b.time);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function hasKeyframesForProp(clip, prop) {
|
|
122
|
+
return clip.keyframes?.some((k) => k.prop === prop) ?? false;
|
|
123
|
+
}
|
|
124
|
+
function interpolateProp(clip, prop, localMs) {
|
|
125
|
+
if (!clip.keyframes || clip.keyframes.length === 0) {
|
|
126
|
+
return staticValue(clip, prop);
|
|
127
|
+
}
|
|
128
|
+
const arr = keyframesForProp(clip.keyframes, prop);
|
|
129
|
+
if (arr.length === 0) return staticValue(clip, prop);
|
|
130
|
+
if (arr.length === 1) return arr[0].value;
|
|
131
|
+
const first = arr[0];
|
|
132
|
+
const last = arr[arr.length - 1];
|
|
133
|
+
if (localMs <= first.time) return first.value;
|
|
134
|
+
if (localMs >= last.time) return last.value;
|
|
135
|
+
for (let i = 0; i < arr.length - 1; i += 1) {
|
|
136
|
+
const a = arr[i];
|
|
137
|
+
const b = arr[i + 1];
|
|
138
|
+
if (localMs >= a.time && localMs <= b.time) {
|
|
139
|
+
if (b.time === a.time) return a.value;
|
|
140
|
+
const rawT = (localMs - a.time) / (b.time - a.time);
|
|
141
|
+
const eased = applyEasing(rawT, a.easing ?? "linear");
|
|
142
|
+
return a.value + (b.value - a.value) * eased;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return last.value;
|
|
146
|
+
}
|
|
147
|
+
function getEffectiveTransform(clip, localMs) {
|
|
148
|
+
if ((!clip.keyframes || clip.keyframes.length === 0) && clip.panX === void 0 && clip.panY === void 0 && clip.scale === void 0) {
|
|
149
|
+
return IDENTITY_TRANSFORM;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
panX: interpolateProp(clip, "panX", localMs),
|
|
153
|
+
panY: interpolateProp(clip, "panY", localMs),
|
|
154
|
+
scale: interpolateProp(clip, "scale", localMs)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function getTransformAtTimelineTime(clip, timelineMs) {
|
|
158
|
+
return getEffectiveTransform(clip, timelineMs - clip.start);
|
|
159
|
+
}
|
|
160
|
+
function upsertKeyframe(kfs, prop, time, value, idFactory) {
|
|
161
|
+
const existing = kfs ?? [];
|
|
162
|
+
const idx = existing.findIndex(
|
|
163
|
+
(k) => k.prop === prop && Math.abs(k.time - time) < 16
|
|
164
|
+
);
|
|
165
|
+
if (idx >= 0) {
|
|
166
|
+
const next = existing.slice();
|
|
167
|
+
next[idx] = { ...next[idx], value };
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
return [...existing, { id: idFactory(), prop, time, value }];
|
|
171
|
+
}
|
|
172
|
+
|
|
83
173
|
// src/model.ts
|
|
174
|
+
var KEYFRAME_PROPS = ["panX", "panY", "scale"];
|
|
175
|
+
var DEFAULT_FPS = 30;
|
|
176
|
+
var BIG_FRAME_STEP = 10;
|
|
177
|
+
function projectFps(project) {
|
|
178
|
+
const f = project.fps;
|
|
179
|
+
return f != null && f > 0 ? f : DEFAULT_FPS;
|
|
180
|
+
}
|
|
181
|
+
function frameStepMs(project) {
|
|
182
|
+
return Math.max(1, Math.round(1e3 / projectFps(project)));
|
|
183
|
+
}
|
|
184
|
+
function bigFrameStepMs(project) {
|
|
185
|
+
return Math.max(1, Math.round(BIG_FRAME_STEP * 1e3 / projectFps(project)));
|
|
186
|
+
}
|
|
84
187
|
function createEmptyProject() {
|
|
85
188
|
return {
|
|
86
189
|
version: 1,
|
|
@@ -117,11 +220,58 @@ function findTrackOfClip(project, clipId) {
|
|
|
117
220
|
function normalizeProject(project) {
|
|
118
221
|
const sources = project.sources.map((s) => ({ ...s }));
|
|
119
222
|
const tracks = project.tracks.map((t) => {
|
|
120
|
-
const clips = t.clips.filter((c) => c.out > c.in).map((c) =>
|
|
223
|
+
const clips = t.clips.filter((c) => c.out > c.in).map((c) => {
|
|
224
|
+
const out = { ...c, id: c.id || createId("clip") };
|
|
225
|
+
if (c.keyframes && c.keyframes.length > 0) {
|
|
226
|
+
const duration = c.out - c.in;
|
|
227
|
+
out.keyframes = migrateKeyframes(c.keyframes).filter((kf) => kf.time >= 0 && kf.time <= duration).map((kf) => ({ ...kf, id: kf.id || createId("kf") })).sort((a, b) => {
|
|
228
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
229
|
+
return a.time - b.time;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}).sort((a, b) => a.start - b.start);
|
|
121
234
|
return { ...t, id: t.id || createId("track"), clips };
|
|
122
235
|
});
|
|
123
236
|
return { version: 1, sources, tracks };
|
|
124
237
|
}
|
|
238
|
+
function migrateKeyframes(raw) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const kf of raw) {
|
|
241
|
+
if ("prop" in kf && "value" in kf) {
|
|
242
|
+
out.push(kf);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const tuple = kf;
|
|
246
|
+
const id = tuple.id;
|
|
247
|
+
const t = tuple.time;
|
|
248
|
+
if (typeof tuple.x === "number") {
|
|
249
|
+
out.push({
|
|
250
|
+
id: id ? `${id}-px` : createId("kf"),
|
|
251
|
+
prop: "panX",
|
|
252
|
+
time: t,
|
|
253
|
+
value: tuple.x
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (typeof tuple.y === "number") {
|
|
257
|
+
out.push({
|
|
258
|
+
id: id ? `${id}-py` : createId("kf"),
|
|
259
|
+
prop: "panY",
|
|
260
|
+
time: t,
|
|
261
|
+
value: tuple.y
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (typeof tuple.scale === "number") {
|
|
265
|
+
out.push({
|
|
266
|
+
id: id ? `${id}-s` : createId("kf"),
|
|
267
|
+
prop: "scale",
|
|
268
|
+
time: t,
|
|
269
|
+
value: tuple.scale
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
125
275
|
function splitClipAt(clip, localOffset) {
|
|
126
276
|
const sourceLen = clip.out - clip.in;
|
|
127
277
|
if (localOffset <= 0 || localOffset >= sourceLen) return null;
|
|
@@ -132,6 +282,47 @@ function splitClipAt(clip, localOffset) {
|
|
|
132
282
|
in: clip.in + localOffset,
|
|
133
283
|
start: clip.start + localOffset
|
|
134
284
|
};
|
|
285
|
+
if (clip.keyframes && clip.keyframes.length > 0) {
|
|
286
|
+
const leftKf = [];
|
|
287
|
+
const rightKf = [];
|
|
288
|
+
for (const prop of KEYFRAME_PROPS) {
|
|
289
|
+
const propKfs = clip.keyframes.filter((k) => k.prop === prop);
|
|
290
|
+
if (propKfs.length === 0) continue;
|
|
291
|
+
const boundaryValue = interpolateProp(clip, prop, localOffset);
|
|
292
|
+
let leftSeamPresent = false;
|
|
293
|
+
let rightSeamPresent = false;
|
|
294
|
+
for (const kf of propKfs) {
|
|
295
|
+
if (kf.time < localOffset) {
|
|
296
|
+
leftKf.push(kf);
|
|
297
|
+
} else if (kf.time > localOffset) {
|
|
298
|
+
rightKf.push({ ...kf, id: createId("kf"), time: kf.time - localOffset });
|
|
299
|
+
} else {
|
|
300
|
+
leftKf.push(kf);
|
|
301
|
+
leftSeamPresent = true;
|
|
302
|
+
rightKf.push({ ...kf, id: createId("kf"), time: 0 });
|
|
303
|
+
rightSeamPresent = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!leftSeamPresent) {
|
|
307
|
+
leftKf.push({
|
|
308
|
+
id: createId("kf"),
|
|
309
|
+
prop,
|
|
310
|
+
time: localOffset,
|
|
311
|
+
value: boundaryValue
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (!rightSeamPresent) {
|
|
315
|
+
rightKf.push({
|
|
316
|
+
id: createId("kf"),
|
|
317
|
+
prop,
|
|
318
|
+
time: 0,
|
|
319
|
+
value: boundaryValue
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
left.keyframes = leftKf.length > 0 ? leftKf : void 0;
|
|
324
|
+
right.keyframes = rightKf.length > 0 ? rightKf : void 0;
|
|
325
|
+
}
|
|
135
326
|
return [left, right];
|
|
136
327
|
}
|
|
137
328
|
function projectDuration(project) {
|
|
@@ -144,18 +335,31 @@ function projectDuration(project) {
|
|
|
144
335
|
}
|
|
145
336
|
|
|
146
337
|
// src/timeline/layout.ts
|
|
147
|
-
|
|
148
|
-
|
|
338
|
+
exports.TRACK_HEIGHT = 56;
|
|
339
|
+
exports.RULER_HEIGHT = 24;
|
|
149
340
|
var HEADER_WIDTH = 96;
|
|
150
341
|
var HANDLE_PX = 8;
|
|
151
342
|
var CLIP_INSET = 6;
|
|
152
343
|
var SCALE_MIN = 10;
|
|
153
344
|
var SCALE_MAX = 400;
|
|
345
|
+
var TIMELINE_PAD_LEFT = 12;
|
|
346
|
+
var TIMELINE_PAD_RIGHT = 12;
|
|
347
|
+
function contentLeftX(showHeader) {
|
|
348
|
+
return (showHeader ? HEADER_WIDTH : 0) + TIMELINE_PAD_LEFT;
|
|
349
|
+
}
|
|
350
|
+
function setTimelineMetrics(opts) {
|
|
351
|
+
if (opts.trackHeight != null && opts.trackHeight > 0) {
|
|
352
|
+
exports.TRACK_HEIGHT = Math.round(opts.trackHeight);
|
|
353
|
+
}
|
|
354
|
+
if (opts.rulerHeight != null && opts.rulerHeight > 0) {
|
|
355
|
+
exports.RULER_HEIGHT = Math.round(opts.rulerHeight);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
154
358
|
var SCROLLBAR_THICKNESS = 10;
|
|
155
359
|
var SCROLLBAR_MIN_THUMB = 24;
|
|
156
360
|
var SCROLLBAR_INSET = 2;
|
|
157
361
|
function contentHeight(tracks, isDragging) {
|
|
158
|
-
return tracks.length * TRACK_HEIGHT + (isDragging ? TRACK_HEIGHT : 0);
|
|
362
|
+
return tracks.length * exports.TRACK_HEIGHT + (isDragging ? exports.TRACK_HEIGHT : 0);
|
|
159
363
|
}
|
|
160
364
|
function contentWidth(project, pxPerSec) {
|
|
161
365
|
let max = 0;
|
|
@@ -168,18 +372,20 @@ function contentWidth(project, pxPerSec) {
|
|
|
168
372
|
return max / 1e3 * pxPerSec;
|
|
169
373
|
}
|
|
170
374
|
function trackY(index) {
|
|
171
|
-
return RULER_HEIGHT + index * TRACK_HEIGHT;
|
|
375
|
+
return exports.RULER_HEIGHT + index * exports.TRACK_HEIGHT;
|
|
172
376
|
}
|
|
173
377
|
function trackIndexAt(y, trackCount, scrollTop = 0) {
|
|
174
|
-
if (y < RULER_HEIGHT) return -1;
|
|
175
|
-
const contentY = y - RULER_HEIGHT + scrollTop;
|
|
176
|
-
const idx = Math.floor(contentY / TRACK_HEIGHT);
|
|
378
|
+
if (y < exports.RULER_HEIGHT) return -1;
|
|
379
|
+
const contentY = y - exports.RULER_HEIGHT + scrollTop;
|
|
380
|
+
const idx = Math.floor(contentY / exports.TRACK_HEIGHT);
|
|
177
381
|
if (idx < 0 || idx >= trackCount) return -1;
|
|
178
382
|
return idx;
|
|
179
383
|
}
|
|
180
384
|
function xToMs(x, pxPerSec, scrollLeft, showHeader) {
|
|
181
|
-
|
|
182
|
-
|
|
385
|
+
return Math.max(
|
|
386
|
+
0,
|
|
387
|
+
(x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
|
|
388
|
+
);
|
|
183
389
|
}
|
|
184
390
|
function niceTickSeconds(targetSec) {
|
|
185
391
|
if (targetSec <= 0) return 1;
|
|
@@ -207,6 +413,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
|
|
|
207
413
|
for (const c of t.clips) {
|
|
208
414
|
if (c.id === ignoreClipId) continue;
|
|
209
415
|
arr.push(c.start, c.start + (c.out - c.in));
|
|
416
|
+
if (c.keyframes) {
|
|
417
|
+
for (const kf of c.keyframes) arr.push(c.start + kf.time);
|
|
418
|
+
}
|
|
210
419
|
}
|
|
211
420
|
}
|
|
212
421
|
return arr;
|
|
@@ -253,30 +462,40 @@ function uncoveredIntervals(project) {
|
|
|
253
462
|
return gaps;
|
|
254
463
|
}
|
|
255
464
|
|
|
256
|
-
// src/playback.ts
|
|
257
|
-
var
|
|
465
|
+
// src/playback/html-video.ts
|
|
466
|
+
var HtmlVideoEngine = class {
|
|
258
467
|
host;
|
|
259
468
|
mount;
|
|
260
|
-
|
|
469
|
+
sources = /* @__PURE__ */ new Map();
|
|
261
470
|
project;
|
|
262
471
|
currentClipId = null;
|
|
263
472
|
playing = false;
|
|
264
473
|
timeMs = 0;
|
|
265
474
|
rafHandle = null;
|
|
266
475
|
lastFrameTs = 0;
|
|
476
|
+
/** Permanent rAF that positions the active wrapper at the output
|
|
477
|
+
* rect + pushes keyframe transform onto the inner video via CSS. */
|
|
478
|
+
transformRaf = null;
|
|
267
479
|
/** Public event hooks — set by Editor. */
|
|
268
480
|
onTimeUpdate;
|
|
269
481
|
onEnded;
|
|
270
482
|
onError;
|
|
271
483
|
onReady;
|
|
272
484
|
onSourceMetadata;
|
|
273
|
-
constructor(
|
|
274
|
-
this.host = host;
|
|
275
|
-
this.project = project;
|
|
485
|
+
constructor(opts) {
|
|
486
|
+
this.host = opts.host;
|
|
487
|
+
this.project = opts.project;
|
|
276
488
|
this.mount = document.createElement("div");
|
|
277
489
|
this.mount.className = "aicut-preview";
|
|
490
|
+
Object.assign(this.mount.style, {
|
|
491
|
+
position: "absolute",
|
|
492
|
+
inset: "0",
|
|
493
|
+
width: "100%",
|
|
494
|
+
height: "100%"
|
|
495
|
+
});
|
|
278
496
|
this.host.appendChild(this.mount);
|
|
279
497
|
this.syncSources();
|
|
498
|
+
this.startTransformLoop();
|
|
280
499
|
}
|
|
281
500
|
setProject(next) {
|
|
282
501
|
this.project = next;
|
|
@@ -299,9 +518,9 @@ var PlaybackEngine = class {
|
|
|
299
518
|
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
300
519
|
this.activate(clip);
|
|
301
520
|
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
304
|
-
void
|
|
521
|
+
const s = this.sources.get(clip.sourceId);
|
|
522
|
+
if (!s) return;
|
|
523
|
+
void s.video.play().catch((err) => this.onError?.(err));
|
|
305
524
|
this.playing = true;
|
|
306
525
|
this.startTickLoop();
|
|
307
526
|
}
|
|
@@ -312,10 +531,414 @@ var PlaybackEngine = class {
|
|
|
312
531
|
if (this.currentClipId) {
|
|
313
532
|
const clip = this.clipById(this.currentClipId);
|
|
314
533
|
if (clip) {
|
|
315
|
-
|
|
316
|
-
|
|
534
|
+
this.sources.get(clip.sourceId)?.video.pause();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
isPlaying() {
|
|
539
|
+
return this.playing;
|
|
540
|
+
}
|
|
541
|
+
getTime() {
|
|
542
|
+
return this.timeMs;
|
|
543
|
+
}
|
|
544
|
+
seek(timeMs) {
|
|
545
|
+
const total = this.totalDuration();
|
|
546
|
+
if (total <= 0) {
|
|
547
|
+
this.timeMs = 0;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const clamped = Math.max(0, Math.min(timeMs, total));
|
|
551
|
+
this.timeMs = clamped;
|
|
552
|
+
const clip = this.clipAtTime(clamped);
|
|
553
|
+
if (clip) {
|
|
554
|
+
this.activate(clip);
|
|
555
|
+
this.seekVideoToClipOffset(clip, clamped - clip.start);
|
|
556
|
+
} else {
|
|
557
|
+
this.activate(null);
|
|
558
|
+
}
|
|
559
|
+
this.onTimeUpdate?.(clamped);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The OUTPUT frame — the fixed stage the rendered video is clipped
|
|
563
|
+
* to. Independent of the keyframe transform. Used by the overlay to
|
|
564
|
+
* draw the dashed border at a stable position.
|
|
565
|
+
*/
|
|
566
|
+
getOutputFrameRect() {
|
|
567
|
+
return this.baseFrameRect();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The CONTENT frame — where the transformed video pixels actually
|
|
571
|
+
* land. Equal to the output frame when transform is identity; may
|
|
572
|
+
* extend outside (zoom in) or fit inside (zoom out) when not.
|
|
573
|
+
*/
|
|
574
|
+
getFrameRect() {
|
|
575
|
+
const base = this.baseFrameRect();
|
|
576
|
+
if (!base) return null;
|
|
577
|
+
const clip = this.clipById(this.currentClipId);
|
|
578
|
+
if (!clip) return base;
|
|
579
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
580
|
+
const cx = base.x + base.w / 2 + t.panX;
|
|
581
|
+
const cy = base.y + base.h / 2 + t.panY;
|
|
582
|
+
const w = base.w * t.scale;
|
|
583
|
+
const h = base.h * t.scale;
|
|
584
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
585
|
+
}
|
|
586
|
+
/** Untransformed contain-letterbox rect — the OUTPUT frame. */
|
|
587
|
+
baseFrameRect() {
|
|
588
|
+
if (!this.currentClipId) return null;
|
|
589
|
+
const clip = this.clipById(this.currentClipId);
|
|
590
|
+
if (!clip) return null;
|
|
591
|
+
const s = this.sources.get(clip.sourceId);
|
|
592
|
+
if (!s) return null;
|
|
593
|
+
const v = s.video;
|
|
594
|
+
if (v.videoWidth === 0 || v.videoHeight === 0) return null;
|
|
595
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
596
|
+
const cw = hostRect.width;
|
|
597
|
+
const ch = hostRect.height;
|
|
598
|
+
if (cw === 0 || ch === 0) return null;
|
|
599
|
+
const scale = Math.min(cw / v.videoWidth, ch / v.videoHeight);
|
|
600
|
+
const w = v.videoWidth * scale;
|
|
601
|
+
const h = v.videoHeight * scale;
|
|
602
|
+
return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Permanent rAF that (a) sizes + positions the active wrapper to
|
|
606
|
+
* the output frame, and (b) writes the keyframe transform onto the
|
|
607
|
+
* inner video. Negligible cost — three style writes per frame max.
|
|
608
|
+
*/
|
|
609
|
+
startTransformLoop() {
|
|
610
|
+
const tick = () => {
|
|
611
|
+
this.applyTransforms();
|
|
612
|
+
this.transformRaf = requestAnimationFrame(tick);
|
|
613
|
+
};
|
|
614
|
+
this.transformRaf = requestAnimationFrame(tick);
|
|
615
|
+
}
|
|
616
|
+
applyTransforms() {
|
|
617
|
+
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
618
|
+
const outRect = this.baseFrameRect();
|
|
619
|
+
if (clip && outRect) {
|
|
620
|
+
const s = this.sources.get(clip.sourceId);
|
|
621
|
+
if (s) {
|
|
622
|
+
Object.assign(s.wrapper.style, {
|
|
623
|
+
left: `${outRect.x}px`,
|
|
624
|
+
top: `${outRect.y}px`,
|
|
625
|
+
width: `${outRect.w}px`,
|
|
626
|
+
height: `${outRect.h}px`
|
|
627
|
+
});
|
|
628
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
629
|
+
s.video.style.transform = `translate(${t.panX.toFixed(2)}px, ${t.panY.toFixed(2)}px) scale(${t.scale.toFixed(4)})`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
for (const [id, s] of this.sources) {
|
|
633
|
+
if (clip && id === clip.sourceId) continue;
|
|
634
|
+
if (s.video.style.transform) s.video.style.transform = "";
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
destroy() {
|
|
638
|
+
this.stopTickLoop();
|
|
639
|
+
if (this.transformRaf != null) {
|
|
640
|
+
cancelAnimationFrame(this.transformRaf);
|
|
641
|
+
this.transformRaf = null;
|
|
642
|
+
}
|
|
643
|
+
for (const s of this.sources.values()) {
|
|
644
|
+
s.video.pause();
|
|
645
|
+
s.video.removeAttribute("src");
|
|
646
|
+
s.video.load();
|
|
647
|
+
s.wrapper.remove();
|
|
648
|
+
}
|
|
649
|
+
this.sources.clear();
|
|
650
|
+
this.mount.remove();
|
|
651
|
+
}
|
|
652
|
+
// --- internals -------------------------------------------------------
|
|
653
|
+
syncSources() {
|
|
654
|
+
const wanted = new Set(this.project.sources.map((s) => s.id));
|
|
655
|
+
for (const [id, s] of this.sources) {
|
|
656
|
+
if (!wanted.has(id)) {
|
|
657
|
+
s.video.pause();
|
|
658
|
+
s.wrapper.remove();
|
|
659
|
+
this.sources.delete(id);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (const src of this.project.sources) {
|
|
663
|
+
if (src.kind !== "video") continue;
|
|
664
|
+
if (this.sources.has(src.id)) continue;
|
|
665
|
+
const wrapper = document.createElement("div");
|
|
666
|
+
wrapper.className = "aicut-preview-clip";
|
|
667
|
+
Object.assign(wrapper.style, {
|
|
668
|
+
position: "absolute",
|
|
669
|
+
overflow: "hidden",
|
|
670
|
+
visibility: "hidden",
|
|
671
|
+
// Initial bounds — applyTransforms overrides each frame.
|
|
672
|
+
left: "0",
|
|
673
|
+
top: "0",
|
|
674
|
+
width: "0",
|
|
675
|
+
height: "0"
|
|
676
|
+
});
|
|
677
|
+
const v = document.createElement("video");
|
|
678
|
+
v.preload = "auto";
|
|
679
|
+
v.playsInline = true;
|
|
680
|
+
v.muted = false;
|
|
681
|
+
v.src = src.url;
|
|
682
|
+
Object.assign(v.style, {
|
|
683
|
+
position: "absolute",
|
|
684
|
+
inset: "0",
|
|
685
|
+
width: "100%",
|
|
686
|
+
height: "100%",
|
|
687
|
+
objectFit: "fill",
|
|
688
|
+
// Transform origin at center so scale() scales around the
|
|
689
|
+
// video's centroid, not its top-left corner.
|
|
690
|
+
transformOrigin: "50% 50%"
|
|
691
|
+
});
|
|
692
|
+
const sourceId = src.id;
|
|
693
|
+
v.addEventListener(
|
|
694
|
+
"error",
|
|
695
|
+
() => this.onError?.(new Error(`Failed to load ${src.url}`))
|
|
696
|
+
);
|
|
697
|
+
v.addEventListener("loadedmetadata", () => {
|
|
698
|
+
this.onReady?.();
|
|
699
|
+
const durMs = Math.round(v.duration * 1e3);
|
|
700
|
+
if (Number.isFinite(durMs) && durMs > 0) {
|
|
701
|
+
this.onSourceMetadata?.(sourceId, durMs);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
wrapper.appendChild(v);
|
|
705
|
+
this.mount.appendChild(wrapper);
|
|
706
|
+
this.sources.set(src.id, { wrapper, video: v });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
activate(clip) {
|
|
710
|
+
if (clip?.id === this.currentClipId) return;
|
|
711
|
+
if (this.currentClipId) {
|
|
712
|
+
const prev = this.clipById(this.currentClipId);
|
|
713
|
+
if (prev) {
|
|
714
|
+
const s = this.sources.get(prev.sourceId);
|
|
715
|
+
if (s) {
|
|
716
|
+
s.video.pause();
|
|
717
|
+
s.wrapper.style.visibility = "hidden";
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
this.currentClipId = clip ? clip.id : null;
|
|
722
|
+
if (clip) {
|
|
723
|
+
const s = this.sources.get(clip.sourceId);
|
|
724
|
+
if (s) s.wrapper.style.visibility = "visible";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
seekVideoToClipOffset(clip, offsetMs) {
|
|
728
|
+
const s = this.sources.get(clip.sourceId);
|
|
729
|
+
if (!s) return;
|
|
730
|
+
const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
|
|
731
|
+
if (Math.abs(s.video.currentTime - target) > 0.05) {
|
|
732
|
+
s.video.currentTime = target;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
clipById(id) {
|
|
736
|
+
for (const t of this.project.tracks) {
|
|
737
|
+
for (const c of t.clips) if (c.id === id) return c;
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Find the clip whose timeline range contains `timeMs`, searching
|
|
743
|
+
* across ALL video tracks. If multiple tracks have a clip at this
|
|
744
|
+
* moment, the lowest-index track wins.
|
|
745
|
+
*/
|
|
746
|
+
clipAtTime(timeMs) {
|
|
747
|
+
for (const t of this.project.tracks) {
|
|
748
|
+
if (t.kind !== "video") continue;
|
|
749
|
+
for (const c of t.clips) {
|
|
750
|
+
if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
/** Earliest clip starting at-or-after `timeMs` across all video tracks. */
|
|
756
|
+
nextClipAfterTime(timeMs) {
|
|
757
|
+
let best = null;
|
|
758
|
+
for (const t of this.project.tracks) {
|
|
759
|
+
if (t.kind !== "video") continue;
|
|
760
|
+
for (const c of t.clips) {
|
|
761
|
+
if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return best;
|
|
765
|
+
}
|
|
766
|
+
/** Max clip end across all video tracks. */
|
|
767
|
+
totalDuration() {
|
|
768
|
+
let max = 0;
|
|
769
|
+
for (const t of this.project.tracks) {
|
|
770
|
+
if (t.kind !== "video") continue;
|
|
771
|
+
for (const c of t.clips) {
|
|
772
|
+
const e = c.start + (c.out - c.in);
|
|
773
|
+
if (e > max) max = e;
|
|
317
774
|
}
|
|
318
775
|
}
|
|
776
|
+
return max;
|
|
777
|
+
}
|
|
778
|
+
startTickLoop() {
|
|
779
|
+
this.lastFrameTs = performance.now();
|
|
780
|
+
const tick = (now) => {
|
|
781
|
+
if (!this.playing) return;
|
|
782
|
+
const dtMs = now - this.lastFrameTs;
|
|
783
|
+
this.lastFrameTs = now;
|
|
784
|
+
this.advance(dtMs);
|
|
785
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
786
|
+
};
|
|
787
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
788
|
+
}
|
|
789
|
+
stopTickLoop() {
|
|
790
|
+
if (this.rafHandle != null) {
|
|
791
|
+
cancelAnimationFrame(this.rafHandle);
|
|
792
|
+
this.rafHandle = null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
advance(dtMs) {
|
|
796
|
+
if (this.project.tracks.length === 0) return;
|
|
797
|
+
this.timeMs += dtMs;
|
|
798
|
+
const totalDur = this.totalDuration();
|
|
799
|
+
if (this.timeMs >= totalDur) {
|
|
800
|
+
this.timeMs = totalDur;
|
|
801
|
+
this.onTimeUpdate?.(this.timeMs);
|
|
802
|
+
this.pause();
|
|
803
|
+
this.onEnded?.();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const clip = this.clipAtTime(this.timeMs);
|
|
807
|
+
if (!clip) {
|
|
808
|
+
const next = this.nextClipAfterTime(this.timeMs);
|
|
809
|
+
if (next) {
|
|
810
|
+
this.timeMs = next.start;
|
|
811
|
+
this.activate(next);
|
|
812
|
+
this.seekVideoToClipOffset(next, 0);
|
|
813
|
+
const s = this.sources.get(next.sourceId);
|
|
814
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
815
|
+
} else {
|
|
816
|
+
this.pause();
|
|
817
|
+
this.onEnded?.();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
} else if (clip.id !== this.currentClipId) {
|
|
821
|
+
this.activate(clip);
|
|
822
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
823
|
+
const s = this.sources.get(clip.sourceId);
|
|
824
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
825
|
+
}
|
|
826
|
+
this.onTimeUpdate?.(this.timeMs);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
var htmlVideoEngineFactory = (opts) => new HtmlVideoEngine(opts);
|
|
830
|
+
|
|
831
|
+
// src/playback/canvas-compositor.ts
|
|
832
|
+
var CanvasCompositorEngine = class {
|
|
833
|
+
host;
|
|
834
|
+
mount;
|
|
835
|
+
canvas;
|
|
836
|
+
ctx;
|
|
837
|
+
/** Only created when constructed with `debug: true`. */
|
|
838
|
+
badge = null;
|
|
839
|
+
videos = /* @__PURE__ */ new Map();
|
|
840
|
+
project;
|
|
841
|
+
currentClipId = null;
|
|
842
|
+
playing = false;
|
|
843
|
+
timeMs = 0;
|
|
844
|
+
rafHandle = null;
|
|
845
|
+
lastFrameTs = 0;
|
|
846
|
+
paintedFrames = 0;
|
|
847
|
+
/** Output frame rect (no transform) — fixed bounds. */
|
|
848
|
+
lastOutputRect = null;
|
|
849
|
+
/** Post-transform content rect. */
|
|
850
|
+
lastFrameRect = null;
|
|
851
|
+
onTimeUpdate;
|
|
852
|
+
onEnded;
|
|
853
|
+
onError;
|
|
854
|
+
onReady;
|
|
855
|
+
onSourceMetadata;
|
|
856
|
+
constructor(opts) {
|
|
857
|
+
this.host = opts.host;
|
|
858
|
+
this.project = opts.project;
|
|
859
|
+
this.mount = document.createElement("div");
|
|
860
|
+
this.mount.className = "aicut-preview aicut-preview--canvas";
|
|
861
|
+
Object.assign(this.mount.style, {
|
|
862
|
+
position: "absolute",
|
|
863
|
+
inset: "0",
|
|
864
|
+
width: "100%",
|
|
865
|
+
height: "100%"
|
|
866
|
+
});
|
|
867
|
+
this.canvas = document.createElement("canvas");
|
|
868
|
+
Object.assign(this.canvas.style, {
|
|
869
|
+
position: "absolute",
|
|
870
|
+
inset: "0",
|
|
871
|
+
width: "100%",
|
|
872
|
+
height: "100%",
|
|
873
|
+
// Stretch with letterboxing handled by the draw loop.
|
|
874
|
+
objectFit: "contain",
|
|
875
|
+
// Black until the first frame is drawn so the swap from the
|
|
876
|
+
// previous engine doesn't flash the host background.
|
|
877
|
+
background: "#000"
|
|
878
|
+
});
|
|
879
|
+
this.mount.appendChild(this.canvas);
|
|
880
|
+
const ctx = this.canvas.getContext("2d");
|
|
881
|
+
if (!ctx) throw new Error("CanvasCompositorEngine: 2d context unavailable");
|
|
882
|
+
this.ctx = ctx;
|
|
883
|
+
if (opts.debug) {
|
|
884
|
+
const badge = document.createElement("div");
|
|
885
|
+
badge.className = "aicut-preview__badge";
|
|
886
|
+
Object.assign(badge.style, {
|
|
887
|
+
position: "absolute",
|
|
888
|
+
top: "8px",
|
|
889
|
+
left: "8px",
|
|
890
|
+
padding: "4px 8px",
|
|
891
|
+
borderRadius: "6px",
|
|
892
|
+
background: "rgba(0, 0, 0, 0.55)",
|
|
893
|
+
color: "rgba(255, 255, 255, 0.92)",
|
|
894
|
+
font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
895
|
+
pointerEvents: "none",
|
|
896
|
+
zIndex: "2",
|
|
897
|
+
letterSpacing: "0.02em"
|
|
898
|
+
});
|
|
899
|
+
badge.textContent = "engine: canvas compositor";
|
|
900
|
+
this.mount.appendChild(badge);
|
|
901
|
+
this.badge = badge;
|
|
902
|
+
}
|
|
903
|
+
this.host.appendChild(this.mount);
|
|
904
|
+
this.syncSources();
|
|
905
|
+
this.resizeCanvas();
|
|
906
|
+
this.startTickLoop();
|
|
907
|
+
}
|
|
908
|
+
setProject(next) {
|
|
909
|
+
this.project = next;
|
|
910
|
+
this.syncSources();
|
|
911
|
+
const clip = this.clipAtTime(this.timeMs);
|
|
912
|
+
if (!clip) {
|
|
913
|
+
this.timeMs = 0;
|
|
914
|
+
this.activate(null);
|
|
915
|
+
this.onTimeUpdate?.(0);
|
|
916
|
+
} else {
|
|
917
|
+
this.activate(clip);
|
|
918
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
play() {
|
|
922
|
+
if (this.playing) return;
|
|
923
|
+
if (this.totalDuration() <= 0) return;
|
|
924
|
+
const clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
|
|
925
|
+
if (!clip) return;
|
|
926
|
+
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
927
|
+
this.activate(clip);
|
|
928
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
929
|
+
const v = this.videos.get(clip.sourceId);
|
|
930
|
+
if (!v) return;
|
|
931
|
+
void v.play().catch((err) => this.onError?.(err));
|
|
932
|
+
this.playing = true;
|
|
933
|
+
this.lastFrameTs = performance.now();
|
|
934
|
+
}
|
|
935
|
+
pause() {
|
|
936
|
+
if (!this.playing) return;
|
|
937
|
+
this.playing = false;
|
|
938
|
+
if (this.currentClipId) {
|
|
939
|
+
const clip = this.clipById(this.currentClipId);
|
|
940
|
+
if (clip) this.videos.get(clip.sourceId)?.pause();
|
|
941
|
+
}
|
|
319
942
|
}
|
|
320
943
|
isPlaying() {
|
|
321
944
|
return this.playing;
|
|
@@ -346,7 +969,6 @@ var PlaybackEngine = class {
|
|
|
346
969
|
v.pause();
|
|
347
970
|
v.removeAttribute("src");
|
|
348
971
|
v.load();
|
|
349
|
-
v.remove();
|
|
350
972
|
}
|
|
351
973
|
this.videos.clear();
|
|
352
974
|
this.mount.remove();
|
|
@@ -357,7 +979,6 @@ var PlaybackEngine = class {
|
|
|
357
979
|
for (const [id, v] of this.videos) {
|
|
358
980
|
if (!wanted.has(id)) {
|
|
359
981
|
v.pause();
|
|
360
|
-
v.remove();
|
|
361
982
|
this.videos.delete(id);
|
|
362
983
|
}
|
|
363
984
|
}
|
|
@@ -369,12 +990,6 @@ var PlaybackEngine = class {
|
|
|
369
990
|
v.playsInline = true;
|
|
370
991
|
v.muted = false;
|
|
371
992
|
v.src = src.url;
|
|
372
|
-
v.style.position = "absolute";
|
|
373
|
-
v.style.inset = "0";
|
|
374
|
-
v.style.width = "100%";
|
|
375
|
-
v.style.height = "100%";
|
|
376
|
-
v.style.objectFit = "contain";
|
|
377
|
-
v.style.visibility = "hidden";
|
|
378
993
|
const sourceId = src.id;
|
|
379
994
|
v.addEventListener(
|
|
380
995
|
"error",
|
|
@@ -387,7 +1002,6 @@ var PlaybackEngine = class {
|
|
|
387
1002
|
this.onSourceMetadata?.(sourceId, durMs);
|
|
388
1003
|
}
|
|
389
1004
|
});
|
|
390
|
-
this.mount.appendChild(v);
|
|
391
1005
|
this.videos.set(src.id, v);
|
|
392
1006
|
}
|
|
393
1007
|
}
|
|
@@ -395,19 +1009,9 @@ var PlaybackEngine = class {
|
|
|
395
1009
|
if (clip?.id === this.currentClipId) return;
|
|
396
1010
|
if (this.currentClipId) {
|
|
397
1011
|
const prev = this.clipById(this.currentClipId);
|
|
398
|
-
if (prev)
|
|
399
|
-
const v = this.videos.get(prev.sourceId);
|
|
400
|
-
if (v) {
|
|
401
|
-
v.pause();
|
|
402
|
-
v.style.visibility = "hidden";
|
|
403
|
-
}
|
|
404
|
-
}
|
|
1012
|
+
if (prev) this.videos.get(prev.sourceId)?.pause();
|
|
405
1013
|
}
|
|
406
1014
|
this.currentClipId = clip ? clip.id : null;
|
|
407
|
-
if (clip) {
|
|
408
|
-
const v = this.videos.get(clip.sourceId);
|
|
409
|
-
if (v) v.style.visibility = "visible";
|
|
410
|
-
}
|
|
411
1015
|
}
|
|
412
1016
|
seekVideoToClipOffset(clip, offsetMs) {
|
|
413
1017
|
const v = this.videos.get(clip.sourceId);
|
|
@@ -423,14 +1027,6 @@ var PlaybackEngine = class {
|
|
|
423
1027
|
}
|
|
424
1028
|
return null;
|
|
425
1029
|
}
|
|
426
|
-
/**
|
|
427
|
-
* Find the clip whose timeline range contains `timeMs`, searching
|
|
428
|
-
* across ALL video tracks. If multiple tracks have a clip at this
|
|
429
|
-
* moment, the lowest-index track wins (matches the "Track 1 is
|
|
430
|
-
* background" convention used in the auto-split UX — overlapping
|
|
431
|
-
* placements would have created a new track on top, but here we
|
|
432
|
-
* fall back to the underlying clip).
|
|
433
|
-
*/
|
|
434
1030
|
clipAtTime(timeMs) {
|
|
435
1031
|
for (const t of this.project.tracks) {
|
|
436
1032
|
if (t.kind !== "video") continue;
|
|
@@ -440,7 +1036,6 @@ var PlaybackEngine = class {
|
|
|
440
1036
|
}
|
|
441
1037
|
return null;
|
|
442
1038
|
}
|
|
443
|
-
/** Earliest clip starting at-or-after `timeMs` across all video tracks. */
|
|
444
1039
|
nextClipAfterTime(timeMs) {
|
|
445
1040
|
let best = null;
|
|
446
1041
|
for (const t of this.project.tracks) {
|
|
@@ -451,7 +1046,6 @@ var PlaybackEngine = class {
|
|
|
451
1046
|
}
|
|
452
1047
|
return best;
|
|
453
1048
|
}
|
|
454
|
-
/** Max clip end across all video tracks. */
|
|
455
1049
|
totalDuration() {
|
|
456
1050
|
let max = 0;
|
|
457
1051
|
for (const t of this.project.tracks) {
|
|
@@ -463,13 +1057,26 @@ var PlaybackEngine = class {
|
|
|
463
1057
|
}
|
|
464
1058
|
return max;
|
|
465
1059
|
}
|
|
1060
|
+
resizeCanvas() {
|
|
1061
|
+
const rect = this.mount.getBoundingClientRect();
|
|
1062
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1063
|
+
const w = Math.max(1, Math.floor(rect.width * dpr));
|
|
1064
|
+
const h = Math.max(1, Math.floor(rect.height * dpr));
|
|
1065
|
+
if (this.canvas.width !== w || this.canvas.height !== h) {
|
|
1066
|
+
this.canvas.width = w;
|
|
1067
|
+
this.canvas.height = h;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
466
1070
|
startTickLoop() {
|
|
467
1071
|
this.lastFrameTs = performance.now();
|
|
468
1072
|
const tick = (now) => {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
1073
|
+
this.resizeCanvas();
|
|
1074
|
+
if (this.playing) {
|
|
1075
|
+
const dtMs = now - this.lastFrameTs;
|
|
1076
|
+
this.lastFrameTs = now;
|
|
1077
|
+
this.advance(dtMs);
|
|
1078
|
+
}
|
|
1079
|
+
this.paint();
|
|
473
1080
|
this.rafHandle = requestAnimationFrame(tick);
|
|
474
1081
|
};
|
|
475
1082
|
this.rafHandle = requestAnimationFrame(tick);
|
|
@@ -513,7 +1120,74 @@ var PlaybackEngine = class {
|
|
|
513
1120
|
}
|
|
514
1121
|
this.onTimeUpdate?.(this.timeMs);
|
|
515
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* One paint per rAF — clears the canvas, draws the current active
|
|
1125
|
+
* video frame letterboxed to fit, then refreshes the HUD. Done
|
|
1126
|
+
* unconditionally (not just on `playing`) so the HUD frame counter
|
|
1127
|
+
* and the seek preview both update when paused.
|
|
1128
|
+
*/
|
|
1129
|
+
paint() {
|
|
1130
|
+
const cw = this.canvas.width;
|
|
1131
|
+
const ch = this.canvas.height;
|
|
1132
|
+
this.ctx.clearRect(0, 0, cw, ch);
|
|
1133
|
+
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
1134
|
+
const v = clip ? this.videos.get(clip.sourceId) : null;
|
|
1135
|
+
if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
|
|
1136
|
+
const vw = v.videoWidth;
|
|
1137
|
+
const vh = v.videoHeight;
|
|
1138
|
+
const baseScale = Math.min(cw / vw, ch / vh);
|
|
1139
|
+
const dw = vw * baseScale;
|
|
1140
|
+
const dh = vh * baseScale;
|
|
1141
|
+
const cx = cw / 2;
|
|
1142
|
+
const cy = ch / 2;
|
|
1143
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1144
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
1145
|
+
const outX = (cw - dw) / 2;
|
|
1146
|
+
const outY = (ch - dh) / 2;
|
|
1147
|
+
this.ctx.save();
|
|
1148
|
+
this.ctx.beginPath();
|
|
1149
|
+
this.ctx.rect(outX, outY, dw, dh);
|
|
1150
|
+
this.ctx.clip();
|
|
1151
|
+
this.ctx.translate(cx + t.panX * dpr, cy + t.panY * dpr);
|
|
1152
|
+
this.ctx.scale(t.scale, t.scale);
|
|
1153
|
+
this.ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
|
|
1154
|
+
this.ctx.restore();
|
|
1155
|
+
this.paintedFrames += 1;
|
|
1156
|
+
this.lastOutputRect = {
|
|
1157
|
+
x: outX / dpr,
|
|
1158
|
+
y: outY / dpr,
|
|
1159
|
+
w: dw / dpr,
|
|
1160
|
+
h: dh / dpr
|
|
1161
|
+
};
|
|
1162
|
+
const cssCx = cw / (2 * dpr) + t.panX;
|
|
1163
|
+
const cssCy = ch / (2 * dpr) + t.panY;
|
|
1164
|
+
const cssW = dw * t.scale / dpr;
|
|
1165
|
+
const cssH = dh * t.scale / dpr;
|
|
1166
|
+
this.lastFrameRect = {
|
|
1167
|
+
x: cssCx - cssW / 2,
|
|
1168
|
+
y: cssCy - cssH / 2,
|
|
1169
|
+
w: cssW,
|
|
1170
|
+
h: cssH
|
|
1171
|
+
};
|
|
1172
|
+
} else {
|
|
1173
|
+
this.lastFrameRect = null;
|
|
1174
|
+
this.lastOutputRect = null;
|
|
1175
|
+
}
|
|
1176
|
+
this.updateBadge();
|
|
1177
|
+
}
|
|
1178
|
+
getOutputFrameRect() {
|
|
1179
|
+
return this.lastOutputRect;
|
|
1180
|
+
}
|
|
1181
|
+
getFrameRect() {
|
|
1182
|
+
return this.lastFrameRect;
|
|
1183
|
+
}
|
|
1184
|
+
updateBadge() {
|
|
1185
|
+
if (!this.badge) return;
|
|
1186
|
+
const sec = (this.timeMs / 1e3).toFixed(2);
|
|
1187
|
+
this.badge.textContent = `engine: canvas compositor \u2022 t=${sec}s \u2022 frames painted: ${this.paintedFrames}`;
|
|
1188
|
+
}
|
|
516
1189
|
};
|
|
1190
|
+
var canvasCompositorEngineFactory = (opts) => new CanvasCompositorEngine(opts);
|
|
517
1191
|
|
|
518
1192
|
// src/theme.ts
|
|
519
1193
|
var THEME_VARS = {
|
|
@@ -548,12 +1222,11 @@ function applyTheme(root, theme) {
|
|
|
548
1222
|
|
|
549
1223
|
// src/i18n.ts
|
|
550
1224
|
var localeEn = {
|
|
551
|
-
undo: "Undo",
|
|
552
|
-
redo: "Redo",
|
|
553
|
-
split: "Split",
|
|
554
|
-
trimLeft: "Trim left edge",
|
|
555
|
-
trimRight: "Trim right edge",
|
|
556
|
-
speedComingSoon: "Speed (coming soon)",
|
|
1225
|
+
undo: "Undo (\u2318Z)",
|
|
1226
|
+
redo: "Redo (\u21E7\u2318Z)",
|
|
1227
|
+
split: "Split (K)",
|
|
1228
|
+
trimLeft: "Trim left edge (Q)",
|
|
1229
|
+
trimRight: "Trim right edge (W)",
|
|
557
1230
|
playPause: "Play / Pause (Space)",
|
|
558
1231
|
fullscreen: "Fullscreen preview",
|
|
559
1232
|
snap: "Snap",
|
|
@@ -562,6 +1235,25 @@ var localeEn = {
|
|
|
562
1235
|
zoomOut: "Zoom out",
|
|
563
1236
|
zoomIn: "Zoom in",
|
|
564
1237
|
reset: "Reset edits (keep sources)",
|
|
1238
|
+
keyframeAdd: "Add keyframe at playhead",
|
|
1239
|
+
keyframeRemove: "Remove keyframe at playhead",
|
|
1240
|
+
seekClipStart: "Jump to clip start (I)",
|
|
1241
|
+
seekClipEnd: "Jump to clip end (O)",
|
|
1242
|
+
keyframePanelTitle: "Keyframe",
|
|
1243
|
+
keyframePanelLabelX: "X",
|
|
1244
|
+
keyframePanelLabelY: "Y",
|
|
1245
|
+
keyframePanelLabelScale: "Scale",
|
|
1246
|
+
keyframePanelLabelEasing: "Easing",
|
|
1247
|
+
keyframePanelReset: "Reset to 0 0 1",
|
|
1248
|
+
keyframePanelResetTitle: "Pin this keyframe to identity (panX=0, panY=0, scale=1)",
|
|
1249
|
+
keyframePanelBadgePinned: "Pinned at this moment",
|
|
1250
|
+
keyframePanelBadgeAnimated: "Animated \u2014 but not pinned at this exact moment",
|
|
1251
|
+
keyframePanelBadgeStatic: "Static value",
|
|
1252
|
+
keyframePanelTimeSuffix: "s",
|
|
1253
|
+
keyframeEasingLinear: "Linear",
|
|
1254
|
+
keyframeEasingEaseIn: "Ease in",
|
|
1255
|
+
keyframeEasingEaseOut: "Ease out",
|
|
1256
|
+
keyframeEasingEaseInOut: "Ease in-out",
|
|
565
1257
|
exitFullscreen: "Exit fullscreen",
|
|
566
1258
|
exitFullscreenTitle: "Exit fullscreen (Esc)",
|
|
567
1259
|
newTrack: "+ New track",
|
|
@@ -569,12 +1261,11 @@ var localeEn = {
|
|
|
569
1261
|
audioTrackLabel: "Audio {n}"
|
|
570
1262
|
};
|
|
571
1263
|
var localeZh = {
|
|
572
|
-
undo: "\u64A4\u9500",
|
|
573
|
-
redo: "\u91CD\u505A",
|
|
574
|
-
split: "\u5206\u5272",
|
|
575
|
-
trimLeft: "\u5411\u5DE6\u88C1\u526A",
|
|
576
|
-
trimRight: "\u5411\u53F3\u88C1\u526A",
|
|
577
|
-
speedComingSoon: "\u53D8\u901F\uFF08\u5373\u5C06\u5230\u6765\uFF09",
|
|
1264
|
+
undo: "\u64A4\u9500 (\u2318Z)",
|
|
1265
|
+
redo: "\u91CD\u505A (\u21E7\u2318Z)",
|
|
1266
|
+
split: "\u5206\u5272 (K)",
|
|
1267
|
+
trimLeft: "\u5411\u5DE6\u88C1\u526A (Q)",
|
|
1268
|
+
trimRight: "\u5411\u53F3\u88C1\u526A (W)",
|
|
578
1269
|
playPause: "\u64AD\u653E / \u6682\u505C (Space)",
|
|
579
1270
|
fullscreen: "\u5168\u5C4F\u9884\u89C8",
|
|
580
1271
|
snap: "\u5438\u9644",
|
|
@@ -583,6 +1274,25 @@ var localeZh = {
|
|
|
583
1274
|
zoomOut: "\u7F29\u5C0F",
|
|
584
1275
|
zoomIn: "\u653E\u5927",
|
|
585
1276
|
reset: "\u91CD\u7F6E\u7F16\u8F91\uFF08\u4FDD\u7559\u89C6\u9891\u6E90\uFF09",
|
|
1277
|
+
keyframeAdd: "\u6DFB\u52A0\u5173\u952E\u5E27",
|
|
1278
|
+
keyframeRemove: "\u5220\u9664\u5F53\u524D\u5173\u952E\u5E27",
|
|
1279
|
+
seekClipStart: "\u8DF3\u5230\u7247\u6BB5\u8D77\u70B9 (I)",
|
|
1280
|
+
seekClipEnd: "\u8DF3\u5230\u7247\u6BB5\u672B\u5C3E (O)",
|
|
1281
|
+
keyframePanelTitle: "\u5173\u952E\u5E27",
|
|
1282
|
+
keyframePanelLabelX: "X \u4F4D\u79FB",
|
|
1283
|
+
keyframePanelLabelY: "Y \u4F4D\u79FB",
|
|
1284
|
+
keyframePanelLabelScale: "\u7F29\u653E",
|
|
1285
|
+
keyframePanelLabelEasing: "\u7F13\u52A8",
|
|
1286
|
+
keyframePanelReset: "\u91CD\u7F6E\u4E3A 0 0 1",
|
|
1287
|
+
keyframePanelResetTitle: "\u5C06\u8BE5\u5173\u952E\u5E27\u91CD\u7F6E\u4E3A\u521D\u59CB\u59FF\u6001\uFF08panX=0, panY=0, scale=1\uFF09",
|
|
1288
|
+
keyframePanelBadgePinned: "\u5DF2\u5728\u8BE5\u65F6\u523B\u56FA\u5B9A",
|
|
1289
|
+
keyframePanelBadgeAnimated: "\u6574\u6BB5\u6709\u52A8\u753B\uFF0C\u4F46\u5F53\u524D\u65F6\u523B\u6CA1\u6709\u9501\u70B9",
|
|
1290
|
+
keyframePanelBadgeStatic: "\u672A\u52A8\u753B\uFF08\u6CBF\u7528\u9759\u6001\u503C\uFF09",
|
|
1291
|
+
keyframePanelTimeSuffix: "\u79D2",
|
|
1292
|
+
keyframeEasingLinear: "\u7EBF\u6027",
|
|
1293
|
+
keyframeEasingEaseIn: "\u7F13\u5165",
|
|
1294
|
+
keyframeEasingEaseOut: "\u7F13\u51FA",
|
|
1295
|
+
keyframeEasingEaseInOut: "\u7F13\u5165\u7F13\u51FA",
|
|
586
1296
|
exitFullscreen: "\u9000\u51FA\u5168\u5C4F",
|
|
587
1297
|
exitFullscreenTitle: "\u9000\u51FA\u5168\u5C4F (Esc)",
|
|
588
1298
|
newTrack: "+ \u65B0\u8F68\u9053",
|
|
@@ -657,11 +1367,19 @@ var ThumbnailRibbon = class {
|
|
|
657
1367
|
/**
|
|
658
1368
|
* Paint thumbnails for the clip's visible window onto `ctx`. The
|
|
659
1369
|
* canvas is the per-clip strip — width = clip's px width, height =
|
|
660
|
-
*
|
|
661
|
-
* and the px range we're
|
|
1370
|
+
* `pxHeight` (defaults to the cached `THUMB_HEIGHT`). Source-time
|
|
1371
|
+
* range derives from the clip's `in/out` and the px range we're
|
|
1372
|
+
* drawing into.
|
|
1373
|
+
*
|
|
1374
|
+
* `pxHeight` lets the caller stretch thumbs to fill a taller clip
|
|
1375
|
+
* body when `trackHeight` is configured above the default. Aspect
|
|
1376
|
+
* ratio is already broken per-thumb (we slice variable widths from a
|
|
1377
|
+
* fixed-aspect cached bitmap), so stretching height too is fine — it
|
|
1378
|
+
* preserves the "filmstrip" look without leaving an empty bottom
|
|
1379
|
+
* band of the brand gradient showing through.
|
|
662
1380
|
*/
|
|
663
|
-
paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
|
|
664
|
-
ctx.clearRect(0, 0, pxWidth,
|
|
1381
|
+
paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth, pxHeight = THUMB_HEIGHT) {
|
|
1382
|
+
ctx.clearRect(0, 0, pxWidth, pxHeight);
|
|
665
1383
|
const st = this.sources.get(sourceId);
|
|
666
1384
|
if (!st) return;
|
|
667
1385
|
if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
|
|
@@ -674,10 +1392,10 @@ var ThumbnailRibbon = class {
|
|
|
674
1392
|
const x = Math.round(i * pxWidth / count);
|
|
675
1393
|
const w = Math.round((i + 1) * pxWidth / count) - x;
|
|
676
1394
|
if (bmp) {
|
|
677
|
-
ctx.drawImage(bmp, x, 0, w,
|
|
1395
|
+
ctx.drawImage(bmp, x, 0, w, pxHeight);
|
|
678
1396
|
} else {
|
|
679
1397
|
ctx.fillStyle = "rgba(255,255,255,0.04)";
|
|
680
|
-
ctx.fillRect(x, 0, w,
|
|
1398
|
+
ctx.fillRect(x, 0, w, pxHeight);
|
|
681
1399
|
this.enqueue(st, bucket);
|
|
682
1400
|
}
|
|
683
1401
|
}
|
|
@@ -768,12 +1486,12 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
768
1486
|
const { viewportWidth: W, viewportHeight: H } = state;
|
|
769
1487
|
ctx.fillStyle = style.bg;
|
|
770
1488
|
ctx.fillRect(0, 0, W, H);
|
|
771
|
-
const baseX = state.showHeader
|
|
1489
|
+
const baseX = contentLeftX(state.showHeader);
|
|
772
1490
|
const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
|
|
773
|
-
const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1491
|
+
const trackAreaH = H - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
774
1492
|
ctx.save();
|
|
775
1493
|
ctx.beginPath();
|
|
776
|
-
ctx.rect(baseX, RULER_HEIGHT, trackAreaW, trackAreaH);
|
|
1494
|
+
ctx.rect(baseX, exports.RULER_HEIGHT, trackAreaW, trackAreaH);
|
|
777
1495
|
ctx.clip();
|
|
778
1496
|
ctx.translate(0, -state.scrollTop);
|
|
779
1497
|
drawTracks(ctx, state, style, thumbs);
|
|
@@ -785,7 +1503,7 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
785
1503
|
if (state.showHeader) {
|
|
786
1504
|
ctx.save();
|
|
787
1505
|
ctx.beginPath();
|
|
788
|
-
ctx.rect(0, RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
|
|
1506
|
+
ctx.rect(0, exports.RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
|
|
789
1507
|
ctx.clip();
|
|
790
1508
|
ctx.translate(0, -state.scrollTop);
|
|
791
1509
|
drawHeaders(ctx, state, style);
|
|
@@ -793,7 +1511,7 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
793
1511
|
}
|
|
794
1512
|
ctx.save();
|
|
795
1513
|
ctx.beginPath();
|
|
796
|
-
ctx.rect(baseX, 0, trackAreaW, RULER_HEIGHT);
|
|
1514
|
+
ctx.rect(baseX, 0, trackAreaW, exports.RULER_HEIGHT);
|
|
797
1515
|
ctx.clip();
|
|
798
1516
|
drawRuler(ctx, state, style);
|
|
799
1517
|
ctx.restore();
|
|
@@ -808,16 +1526,22 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
808
1526
|
ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
|
|
809
1527
|
ctx.clip();
|
|
810
1528
|
drawSnapGuide(ctx, state, style);
|
|
811
|
-
drawPlayhead(ctx, state, style);
|
|
812
1529
|
ctx.restore();
|
|
813
1530
|
drawScrollbarV(ctx, state, style);
|
|
814
1531
|
drawScrollbarH(ctx, state, style);
|
|
1532
|
+
const playheadLeft = state.showHeader ? HEADER_WIDTH : 0;
|
|
1533
|
+
ctx.save();
|
|
1534
|
+
ctx.beginPath();
|
|
1535
|
+
ctx.rect(playheadLeft, 0, W - playheadLeft, H);
|
|
1536
|
+
ctx.clip();
|
|
1537
|
+
drawPlayhead(ctx, state, style);
|
|
1538
|
+
ctx.restore();
|
|
815
1539
|
}
|
|
816
1540
|
function drawCoverageGaps(ctx, state, style) {
|
|
817
1541
|
const gaps = uncoveredIntervals(state.project);
|
|
818
1542
|
if (gaps.length === 0) return;
|
|
819
|
-
const baseX = state.showHeader
|
|
820
|
-
const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1543
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1544
|
+
const trackStackH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
821
1545
|
for (const [s, e] of gaps) {
|
|
822
1546
|
const x1 = Math.max(
|
|
823
1547
|
baseX,
|
|
@@ -829,16 +1553,16 @@ function drawCoverageGaps(ctx, state, style) {
|
|
|
829
1553
|
);
|
|
830
1554
|
if (x2 <= x1) continue;
|
|
831
1555
|
ctx.fillStyle = "rgba(250, 167, 0, 0.35)";
|
|
832
|
-
ctx.fillRect(x1, 0, x2 - x1, RULER_HEIGHT);
|
|
1556
|
+
ctx.fillRect(x1, 0, x2 - x1, exports.RULER_HEIGHT);
|
|
833
1557
|
ctx.fillStyle = "rgba(250, 167, 0, 0.12)";
|
|
834
|
-
ctx.fillRect(x1, RULER_HEIGHT, x2 - x1, trackStackH);
|
|
1558
|
+
ctx.fillRect(x1, exports.RULER_HEIGHT, x2 - x1, trackStackH);
|
|
835
1559
|
ctx.save();
|
|
836
1560
|
ctx.strokeStyle = "rgba(250, 167, 0, 0.6)";
|
|
837
1561
|
ctx.lineWidth = 1;
|
|
838
1562
|
ctx.beginPath();
|
|
839
1563
|
for (let hx = Math.floor(x1); hx < x2; hx += 6) {
|
|
840
|
-
ctx.moveTo(hx, RULER_HEIGHT - 1);
|
|
841
|
-
ctx.lineTo(hx + 6, RULER_HEIGHT - 7);
|
|
1564
|
+
ctx.moveTo(hx, exports.RULER_HEIGHT - 1);
|
|
1565
|
+
ctx.lineTo(hx + 6, exports.RULER_HEIGHT - 7);
|
|
842
1566
|
}
|
|
843
1567
|
ctx.stroke();
|
|
844
1568
|
ctx.restore();
|
|
@@ -855,7 +1579,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
|
|
|
855
1579
|
}
|
|
856
1580
|
}
|
|
857
1581
|
if (!real) return;
|
|
858
|
-
const baseX = state.showHeader
|
|
1582
|
+
const baseX = contentLeftX(state.showHeader);
|
|
859
1583
|
const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
|
|
860
1584
|
const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
861
1585
|
const overlap = ghost.wouldOverlap;
|
|
@@ -887,7 +1611,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
|
|
|
887
1611
|
}
|
|
888
1612
|
function drawDropOutline(ctx, startX, trackIndex, widthPx, color, emphasized) {
|
|
889
1613
|
const y = trackY(trackIndex) + CLIP_INSET - 1;
|
|
890
|
-
const h = TRACK_HEIGHT - CLIP_INSET * 2 + 2;
|
|
1614
|
+
const h = exports.TRACK_HEIGHT - CLIP_INSET * 2 + 2;
|
|
891
1615
|
ctx.save();
|
|
892
1616
|
if (emphasized) {
|
|
893
1617
|
ctx.shadowColor = withAlpha(color, 0.45);
|
|
@@ -904,36 +1628,36 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
|
|
|
904
1628
|
const w = state.viewportWidth - baseX;
|
|
905
1629
|
ctx.save();
|
|
906
1630
|
ctx.fillStyle = withAlpha(style.info, 0.04);
|
|
907
|
-
ctx.fillRect(baseX, y, w, TRACK_HEIGHT);
|
|
1631
|
+
ctx.fillRect(baseX, y, w, exports.TRACK_HEIGHT);
|
|
908
1632
|
ctx.strokeStyle = withAlpha(style.info, 0.35);
|
|
909
1633
|
ctx.lineWidth = 1;
|
|
910
1634
|
ctx.setLineDash([3, 4]);
|
|
911
1635
|
ctx.beginPath();
|
|
912
1636
|
ctx.moveTo(baseX, y + 0.5);
|
|
913
1637
|
ctx.lineTo(baseX + w, y + 0.5);
|
|
914
|
-
ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
|
|
915
|
-
ctx.lineTo(baseX + w, y + TRACK_HEIGHT - 0.5);
|
|
1638
|
+
ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
|
|
1639
|
+
ctx.lineTo(baseX + w, y + exports.TRACK_HEIGHT - 0.5);
|
|
916
1640
|
ctx.stroke();
|
|
917
1641
|
ctx.setLineDash([]);
|
|
918
1642
|
if (state.showHeader) {
|
|
919
1643
|
ctx.fillStyle = withAlpha(style.info, 0.7);
|
|
920
1644
|
ctx.font = "10px system-ui, -apple-system, sans-serif";
|
|
921
1645
|
ctx.textBaseline = "middle";
|
|
922
|
-
ctx.fillText(state.locale.newTrack, 12, y + TRACK_HEIGHT / 2);
|
|
1646
|
+
ctx.fillText(state.locale.newTrack, 12, y + exports.TRACK_HEIGHT / 2);
|
|
923
1647
|
}
|
|
924
1648
|
ctx.restore();
|
|
925
1649
|
}
|
|
926
1650
|
function drawRuler(ctx, state, style) {
|
|
927
1651
|
const { pxPerSec, scrollLeft, viewportWidth: W } = state;
|
|
928
|
-
const baseX = state.showHeader
|
|
1652
|
+
const baseX = contentLeftX(state.showHeader);
|
|
929
1653
|
const rulerW = W - baseX;
|
|
930
1654
|
ctx.fillStyle = style.bg;
|
|
931
|
-
ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
|
|
1655
|
+
ctx.fillRect(baseX, 0, rulerW, exports.RULER_HEIGHT);
|
|
932
1656
|
ctx.strokeStyle = style.border;
|
|
933
1657
|
ctx.lineWidth = 1;
|
|
934
1658
|
ctx.beginPath();
|
|
935
|
-
ctx.moveTo(baseX, RULER_HEIGHT - 0.5);
|
|
936
|
-
ctx.lineTo(W, RULER_HEIGHT - 0.5);
|
|
1659
|
+
ctx.moveTo(baseX, exports.RULER_HEIGHT - 0.5);
|
|
1660
|
+
ctx.lineTo(W, exports.RULER_HEIGHT - 0.5);
|
|
937
1661
|
ctx.stroke();
|
|
938
1662
|
const minPx = 80;
|
|
939
1663
|
const tickSec = niceTickSeconds(minPx / pxPerSec);
|
|
@@ -954,12 +1678,12 @@ function drawRuler(ctx, state, style) {
|
|
|
954
1678
|
ctx.lineWidth = 1;
|
|
955
1679
|
const h = isMajor ? 10 : 6;
|
|
956
1680
|
ctx.beginPath();
|
|
957
|
-
ctx.moveTo(x + 0.5, RULER_HEIGHT - h);
|
|
958
|
-
ctx.lineTo(x + 0.5, RULER_HEIGHT - 1);
|
|
1681
|
+
ctx.moveTo(x + 0.5, exports.RULER_HEIGHT - h);
|
|
1682
|
+
ctx.lineTo(x + 0.5, exports.RULER_HEIGHT - 1);
|
|
959
1683
|
ctx.stroke();
|
|
960
1684
|
if (isMajor) {
|
|
961
1685
|
ctx.fillStyle = withAlpha(style.textMuted, 0.85);
|
|
962
|
-
ctx.fillText(formatRulerLabel(s), x + 3, RULER_HEIGHT - 12);
|
|
1686
|
+
ctx.fillText(formatRulerLabel(s), x + 3, exports.RULER_HEIGHT - 12);
|
|
963
1687
|
}
|
|
964
1688
|
}
|
|
965
1689
|
}
|
|
@@ -971,15 +1695,15 @@ function drawTracks(ctx, state, style, thumbs) {
|
|
|
971
1695
|
}
|
|
972
1696
|
function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
973
1697
|
const { viewportWidth: W } = state;
|
|
974
|
-
const baseX = state.showHeader
|
|
1698
|
+
const baseX = contentLeftX(state.showHeader);
|
|
975
1699
|
const y = trackY(trackIndex);
|
|
976
1700
|
ctx.fillStyle = style.trackBg;
|
|
977
|
-
ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
|
|
1701
|
+
ctx.fillRect(baseX, y, W - baseX, exports.TRACK_HEIGHT);
|
|
978
1702
|
ctx.strokeStyle = style.border;
|
|
979
1703
|
ctx.lineWidth = 1;
|
|
980
1704
|
ctx.beginPath();
|
|
981
|
-
ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
|
|
982
|
-
ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
|
|
1705
|
+
ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
|
|
1706
|
+
ctx.lineTo(W, y + exports.TRACK_HEIGHT - 0.5);
|
|
983
1707
|
ctx.stroke();
|
|
984
1708
|
if (state.dropTargetTrackIndex === trackIndex) {
|
|
985
1709
|
ctx.strokeStyle = withAlpha(style.info, 0.45);
|
|
@@ -987,8 +1711,8 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
|
987
1711
|
ctx.beginPath();
|
|
988
1712
|
ctx.moveTo(baseX, y + 0.5);
|
|
989
1713
|
ctx.lineTo(W, y + 0.5);
|
|
990
|
-
ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
|
|
991
|
-
ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
|
|
1714
|
+
ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
|
|
1715
|
+
ctx.lineTo(W, y + exports.TRACK_HEIGHT - 0.5);
|
|
992
1716
|
ctx.stroke();
|
|
993
1717
|
}
|
|
994
1718
|
for (const clip of track.clips) {
|
|
@@ -1009,11 +1733,11 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
|
1009
1733
|
}
|
|
1010
1734
|
function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
|
|
1011
1735
|
const { pxPerSec, scrollLeft } = state;
|
|
1012
|
-
const baseX = state.showHeader
|
|
1736
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1013
1737
|
const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
|
|
1014
1738
|
const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
|
|
1015
1739
|
const y = trackY(trackIndex) + CLIP_INSET;
|
|
1016
|
-
const h = TRACK_HEIGHT - CLIP_INSET * 2;
|
|
1740
|
+
const h = exports.TRACK_HEIGHT - CLIP_INSET * 2;
|
|
1017
1741
|
if (startX + widthPx < baseX || startX > state.viewportWidth) return;
|
|
1018
1742
|
ctx.save();
|
|
1019
1743
|
if (dim) ctx.globalAlpha = 0.3;
|
|
@@ -1032,7 +1756,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
|
|
|
1032
1756
|
roundRect(ctx, startX, y, widthPx, h, 6);
|
|
1033
1757
|
ctx.clip();
|
|
1034
1758
|
ctx.translate(startX, y);
|
|
1035
|
-
thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
|
|
1759
|
+
thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx, h);
|
|
1036
1760
|
ctx.restore();
|
|
1037
1761
|
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
|
1038
1762
|
ctx.lineWidth = 1;
|
|
@@ -1058,6 +1782,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
|
|
|
1058
1782
|
ctx.fillRect(startX + 2, y + 12, 2, h - 24);
|
|
1059
1783
|
ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
|
|
1060
1784
|
}
|
|
1785
|
+
if (!dim && state.keyframesEnabled && clip.keyframes && clip.keyframes.length > 0) {
|
|
1786
|
+
const diamondY = y + h / 2;
|
|
1787
|
+
const halfSize = 5;
|
|
1788
|
+
const moments = groupKeyframesByTime(clip.keyframes, 16);
|
|
1789
|
+
const ghost = state.keyframeDragGhost;
|
|
1790
|
+
for (const moment of moments) {
|
|
1791
|
+
const draggedHere = ghost ? moment.kfs.find(
|
|
1792
|
+
(k) => ghost.clipId === clip.id && ghost.keyframeId === k.id
|
|
1793
|
+
) : void 0;
|
|
1794
|
+
const effectiveTime = draggedHere ? ghost.ghostTimeMs : moment.time;
|
|
1795
|
+
const kfX = startX + effectiveTime / 1e3 * pxPerSec;
|
|
1796
|
+
if (kfX < baseX - halfSize || kfX > state.viewportWidth + halfSize) continue;
|
|
1797
|
+
const isSelected = state.selectedKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.selectedKeyframe?.keyframeId);
|
|
1798
|
+
const isHovered = state.hoveredKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.hoveredKeyframe?.keyframeId);
|
|
1799
|
+
const drawSize = isHovered ? halfSize + 1.5 : halfSize;
|
|
1800
|
+
ctx.beginPath();
|
|
1801
|
+
ctx.moveTo(kfX, diamondY - drawSize);
|
|
1802
|
+
ctx.lineTo(kfX + drawSize, diamondY);
|
|
1803
|
+
ctx.lineTo(kfX, diamondY + drawSize);
|
|
1804
|
+
ctx.lineTo(kfX - drawSize, diamondY);
|
|
1805
|
+
ctx.closePath();
|
|
1806
|
+
ctx.fillStyle = isSelected ? style.selectedRing : isHovered ? "#ffffff" : withAlpha(style.text, 0.85);
|
|
1807
|
+
ctx.fill();
|
|
1808
|
+
ctx.strokeStyle = isHovered ? style.selectedRing : "rgba(0, 0, 0, 0.65)";
|
|
1809
|
+
ctx.lineWidth = isHovered ? 1.5 : 1;
|
|
1810
|
+
ctx.stroke();
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1061
1813
|
ctx.restore();
|
|
1062
1814
|
}
|
|
1063
1815
|
function drawHeaders(ctx, state, style) {
|
|
@@ -1076,18 +1828,18 @@ function drawHeaders(ctx, state, style) {
|
|
|
1076
1828
|
const y = trackY(i);
|
|
1077
1829
|
ctx.strokeStyle = style.border;
|
|
1078
1830
|
ctx.beginPath();
|
|
1079
|
-
ctx.moveTo(0, y + TRACK_HEIGHT - 0.5);
|
|
1080
|
-
ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT - 0.5);
|
|
1831
|
+
ctx.moveTo(0, y + exports.TRACK_HEIGHT - 0.5);
|
|
1832
|
+
ctx.lineTo(HEADER_WIDTH, y + exports.TRACK_HEIGHT - 0.5);
|
|
1081
1833
|
ctx.stroke();
|
|
1082
1834
|
ctx.fillStyle = withAlpha(style.text, 0.7);
|
|
1083
1835
|
const template = t.kind === "video" ? state.locale.videoTrackLabel : state.locale.audioTrackLabel;
|
|
1084
1836
|
const label = formatLabel(template, { n: i + 1 });
|
|
1085
|
-
ctx.fillText(label, 12, y + TRACK_HEIGHT / 2);
|
|
1837
|
+
ctx.fillText(label, 12, y + exports.TRACK_HEIGHT / 2);
|
|
1086
1838
|
if (t.clips.length === 0) {
|
|
1087
1839
|
const hovered = state.hoveredTrackIndex === i;
|
|
1088
1840
|
const btnSize = 18;
|
|
1089
1841
|
const btnLeft = HEADER_WIDTH - btnSize - 6;
|
|
1090
|
-
const btnTop = y + (TRACK_HEIGHT - btnSize) / 2;
|
|
1842
|
+
const btnTop = y + (exports.TRACK_HEIGHT - btnSize) / 2;
|
|
1091
1843
|
ctx.save();
|
|
1092
1844
|
if (hovered) {
|
|
1093
1845
|
ctx.fillStyle = withAlpha(style.text, 0.1);
|
|
@@ -1108,7 +1860,7 @@ function drawHeaders(ctx, state, style) {
|
|
|
1108
1860
|
}
|
|
1109
1861
|
}
|
|
1110
1862
|
function drawPlayhead(ctx, state, style) {
|
|
1111
|
-
const baseX = state.showHeader
|
|
1863
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1112
1864
|
const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1113
1865
|
if (x < baseX - 2 || x > state.viewportWidth + 2) return;
|
|
1114
1866
|
ctx.strokeStyle = style.playhead;
|
|
@@ -1122,7 +1874,9 @@ function drawPlayhead(ctx, state, style) {
|
|
|
1122
1874
|
const padX = 6;
|
|
1123
1875
|
const w = ctx.measureText(label).width + padX * 2;
|
|
1124
1876
|
const h = 14;
|
|
1125
|
-
const
|
|
1877
|
+
const contentRight = state.viewportWidth - SCROLLBAR_THICKNESS;
|
|
1878
|
+
const rawBx = x - w / 2;
|
|
1879
|
+
const bx = Math.max(baseX, Math.min(contentRight - w, rawBx));
|
|
1126
1880
|
const by = 2;
|
|
1127
1881
|
ctx.fillStyle = style.playhead;
|
|
1128
1882
|
roundRect(ctx, bx, by, w, h, 4);
|
|
@@ -1150,11 +1904,11 @@ function drawSnapGuide(ctx, state, style) {
|
|
|
1150
1904
|
}
|
|
1151
1905
|
function drawScrollbarV(ctx, state, style) {
|
|
1152
1906
|
if (state.scrollbarOpacityY <= 0.01) return;
|
|
1153
|
-
const visibleH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1907
|
+
const visibleH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1154
1908
|
const contentH = contentHeight(state.project.tracks, state.isDragging);
|
|
1155
1909
|
if (contentH <= visibleH) return;
|
|
1156
1910
|
const trackX = state.viewportWidth - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
|
|
1157
|
-
const trackY0 = RULER_HEIGHT + SCROLLBAR_INSET;
|
|
1911
|
+
const trackY0 = exports.RULER_HEIGHT + SCROLLBAR_INSET;
|
|
1158
1912
|
const trackLen = visibleH - SCROLLBAR_INSET * 2;
|
|
1159
1913
|
const thumbLen = Math.max(
|
|
1160
1914
|
SCROLLBAR_MIN_THUMB,
|
|
@@ -1178,7 +1932,7 @@ function drawScrollbarV(ctx, state, style) {
|
|
|
1178
1932
|
}
|
|
1179
1933
|
function drawScrollbarH(ctx, state, style) {
|
|
1180
1934
|
if (state.scrollbarOpacityX <= 0.01) return;
|
|
1181
|
-
const baseX = state.showHeader
|
|
1935
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1182
1936
|
const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1183
1937
|
const contentW = contentWidth(state.project, state.pxPerSec);
|
|
1184
1938
|
if (contentW <= visibleW) return;
|
|
@@ -1261,21 +2015,35 @@ function parseColor(s) {
|
|
|
1261
2015
|
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1262
2016
|
return null;
|
|
1263
2017
|
}
|
|
2018
|
+
function groupKeyframesByTime(kfs, epsilonMs) {
|
|
2019
|
+
const sorted = [...kfs].sort((a, b) => a.time - b.time);
|
|
2020
|
+
const out = [];
|
|
2021
|
+
for (const k of sorted) {
|
|
2022
|
+
const last = out[out.length - 1];
|
|
2023
|
+
if (last && Math.abs(k.time - last.time) < epsilonMs) {
|
|
2024
|
+
last.kfs.push(k);
|
|
2025
|
+
} else {
|
|
2026
|
+
out.push({ time: k.time, kfs: [k] });
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return out;
|
|
2030
|
+
}
|
|
1264
2031
|
|
|
1265
2032
|
// src/timeline/hit.ts
|
|
2033
|
+
var KEYFRAME_HIT_RADIUS = 8;
|
|
1266
2034
|
function hitTest(x, y, ctx) {
|
|
1267
2035
|
if (y < 0 || x < 0) return { kind: "outside" };
|
|
1268
|
-
const baseX = ctx.showHeader
|
|
1269
|
-
const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
2036
|
+
const baseX = contentLeftX(ctx.showHeader);
|
|
2037
|
+
const visibleH = ctx.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1270
2038
|
const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
|
|
1271
|
-
if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
|
|
2039
|
+
if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= exports.RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
|
|
1272
2040
|
const trackLen = visibleH - SCROLLBAR_INSET * 2;
|
|
1273
2041
|
const thumbLen = Math.max(
|
|
1274
2042
|
SCROLLBAR_MIN_THUMB,
|
|
1275
2043
|
trackLen * (visibleH / contentH)
|
|
1276
2044
|
);
|
|
1277
2045
|
const maxScroll = contentH - visibleH;
|
|
1278
|
-
const thumbY = RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
|
|
2046
|
+
const thumbY = exports.RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
|
|
1279
2047
|
if (y >= thumbY && y <= thumbY + thumbLen) {
|
|
1280
2048
|
return { kind: "scrollbar-thumb-v", thumbY, thumbLen };
|
|
1281
2049
|
}
|
|
@@ -1296,14 +2064,14 @@ function hitTest(x, y, ctx) {
|
|
|
1296
2064
|
}
|
|
1297
2065
|
return { kind: "scrollbar-track-h", before: x < thumbX };
|
|
1298
2066
|
}
|
|
1299
|
-
if (ctx.showHeader && x < HEADER_WIDTH && y >= RULER_HEIGHT) {
|
|
2067
|
+
if (ctx.showHeader && x < HEADER_WIDTH && y >= exports.RULER_HEIGHT) {
|
|
1300
2068
|
const ti2 = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
|
|
1301
2069
|
if (ti2 >= 0) {
|
|
1302
2070
|
const track2 = ctx.project.tracks[ti2];
|
|
1303
2071
|
if (track2.clips.length === 0) {
|
|
1304
2072
|
const btnSize = 18;
|
|
1305
2073
|
const btnLeft = HEADER_WIDTH - btnSize - 6;
|
|
1306
|
-
const btnTop = RULER_HEIGHT + ti2 *
|
|
2074
|
+
const btnTop = exports.RULER_HEIGHT + ti2 * exports.TRACK_HEIGHT + (exports.TRACK_HEIGHT - btnSize) / 2 - ctx.scrollTop;
|
|
1307
2075
|
if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
|
|
1308
2076
|
return { kind: "header-delete", trackIndex: ti2 };
|
|
1309
2077
|
}
|
|
@@ -1312,11 +2080,28 @@ function hitTest(x, y, ctx) {
|
|
|
1312
2080
|
}
|
|
1313
2081
|
return { kind: "outside" };
|
|
1314
2082
|
}
|
|
1315
|
-
if (y < RULER_HEIGHT) return { kind: "ruler" };
|
|
2083
|
+
if (y < exports.RULER_HEIGHT) return { kind: "ruler" };
|
|
1316
2084
|
const ti = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
|
|
1317
2085
|
if (ti < 0) return { kind: "outside" };
|
|
1318
2086
|
const track = ctx.project.tracks[ti];
|
|
1319
2087
|
const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
|
|
2088
|
+
if (ctx.keyframesEnabled) {
|
|
2089
|
+
for (const clip of track.clips) {
|
|
2090
|
+
if (!clip.keyframes || clip.keyframes.length === 0) continue;
|
|
2091
|
+
const startX = msToXLocal(clip.start, ctx);
|
|
2092
|
+
for (const kf of clip.keyframes) {
|
|
2093
|
+
const kfX = startX + kf.time / 1e3 * ctx.pxPerSec;
|
|
2094
|
+
if (Math.abs(x - kfX) <= KEYFRAME_HIT_RADIUS) {
|
|
2095
|
+
return {
|
|
2096
|
+
kind: "keyframe",
|
|
2097
|
+
trackIndex: ti,
|
|
2098
|
+
clipId: clip.id,
|
|
2099
|
+
keyframeId: kf.id
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
1320
2105
|
for (const clip of track.clips) {
|
|
1321
2106
|
const start = clip.start;
|
|
1322
2107
|
const end = clip.start + (clip.out - clip.in);
|
|
@@ -1335,8 +2120,7 @@ function hitTest(x, y, ctx) {
|
|
|
1335
2120
|
return { kind: "track-empty", trackIndex: ti };
|
|
1336
2121
|
}
|
|
1337
2122
|
function msToXLocal(ms, ctx) {
|
|
1338
|
-
|
|
1339
|
-
return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
2123
|
+
return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1340
2124
|
}
|
|
1341
2125
|
|
|
1342
2126
|
// src/timeline/index.ts
|
|
@@ -1370,6 +2154,8 @@ var Timeline = class _Timeline {
|
|
|
1370
2154
|
readOnly;
|
|
1371
2155
|
autoFitEnabled;
|
|
1372
2156
|
locale;
|
|
2157
|
+
keyframesEnabled = false;
|
|
2158
|
+
selectedKeyframe = null;
|
|
1373
2159
|
scrollLeft = 0;
|
|
1374
2160
|
scrollTop = 0;
|
|
1375
2161
|
viewportWidth = 0;
|
|
@@ -1388,6 +2174,7 @@ var Timeline = class _Timeline {
|
|
|
1388
2174
|
scrollbarDrag = null;
|
|
1389
2175
|
hoveredClipId = null;
|
|
1390
2176
|
hoveredTrackIndex = null;
|
|
2177
|
+
hoveredKeyframe = null;
|
|
1391
2178
|
hoverCursor = "default";
|
|
1392
2179
|
dropTargetTrackIndex = null;
|
|
1393
2180
|
snapX = null;
|
|
@@ -1426,6 +2213,8 @@ var Timeline = class _Timeline {
|
|
|
1426
2213
|
this.readOnly = opts.readOnly === true;
|
|
1427
2214
|
this.autoFitEnabled = opts.autoFit !== false;
|
|
1428
2215
|
this.locale = mergeLocale(opts.locale);
|
|
2216
|
+
this.keyframesEnabled = opts.keyframesEnabled === true;
|
|
2217
|
+
this.selectedKeyframe = opts.selectedKeyframe ?? null;
|
|
1429
2218
|
this.root.classList.add("aicut-timeline-canvas");
|
|
1430
2219
|
this.root.innerHTML = "";
|
|
1431
2220
|
this.root.style.position = this.root.style.position || "relative";
|
|
@@ -1473,6 +2262,7 @@ var Timeline = class _Timeline {
|
|
|
1473
2262
|
this.thumbs.syncSources(this.project.sources);
|
|
1474
2263
|
this.attachPointer();
|
|
1475
2264
|
this.attachWheel();
|
|
2265
|
+
this.attachKeyboard();
|
|
1476
2266
|
this.attachResize();
|
|
1477
2267
|
this.resizeCanvas();
|
|
1478
2268
|
this.scheduleRender();
|
|
@@ -1553,21 +2343,21 @@ var Timeline = class _Timeline {
|
|
|
1553
2343
|
* Exposed publicly so React/Vue wrappers can forward it to a ref.
|
|
1554
2344
|
*/
|
|
1555
2345
|
getDebugInfo() {
|
|
1556
|
-
const baseX = this.showHeader
|
|
2346
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1557
2347
|
const clips = [];
|
|
1558
2348
|
for (let ti = 0; ti < this.project.tracks.length; ti++) {
|
|
1559
2349
|
const t = this.project.tracks[ti];
|
|
1560
2350
|
for (const c of t.clips) {
|
|
1561
2351
|
const x = baseX + c.start / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
1562
2352
|
const width = (c.out - c.in) / 1e3 * this.pxPerSec;
|
|
1563
|
-
const y = RULER_HEIGHT + ti * TRACK_HEIGHT + 6;
|
|
2353
|
+
const y = exports.RULER_HEIGHT + ti * exports.TRACK_HEIGHT + 6;
|
|
1564
2354
|
clips.push({
|
|
1565
2355
|
id: c.id,
|
|
1566
2356
|
trackIndex: ti,
|
|
1567
2357
|
x,
|
|
1568
2358
|
width,
|
|
1569
2359
|
y,
|
|
1570
|
-
height: TRACK_HEIGHT - 12
|
|
2360
|
+
height: exports.TRACK_HEIGHT - 12
|
|
1571
2361
|
});
|
|
1572
2362
|
}
|
|
1573
2363
|
}
|
|
@@ -1597,30 +2387,29 @@ var Timeline = class _Timeline {
|
|
|
1597
2387
|
const rect = this.canvas.getBoundingClientRect();
|
|
1598
2388
|
this.viewportWidth = Math.max(1, Math.floor(rect.width));
|
|
1599
2389
|
this.viewportHeight = Math.max(
|
|
1600
|
-
Math.floor(rect.height) || RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS,
|
|
1601
|
-
RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS
|
|
2390
|
+
Math.floor(rect.height) || exports.RULER_HEIGHT + exports.TRACK_HEIGHT + SCROLLBAR_THICKNESS,
|
|
2391
|
+
exports.RULER_HEIGHT + exports.TRACK_HEIGHT + SCROLLBAR_THICKNESS
|
|
1602
2392
|
);
|
|
1603
2393
|
const dpr = window.devicePixelRatio || 1;
|
|
1604
2394
|
this.canvas.width = Math.floor(this.viewportWidth * dpr);
|
|
1605
2395
|
this.canvas.height = Math.floor(this.viewportHeight * dpr);
|
|
1606
|
-
this.canvas.style.height = `${this.viewportHeight}px`;
|
|
1607
2396
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1608
2397
|
}
|
|
1609
2398
|
computeFitScale() {
|
|
1610
|
-
const baseX = this.showHeader
|
|
2399
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1611
2400
|
const w = this.viewportWidth - baseX - 24;
|
|
1612
2401
|
const dur = projectDuration(this.project);
|
|
1613
2402
|
if (w <= 0 || dur <= 0) return null;
|
|
1614
2403
|
return clampScale(w / (dur / 1e3));
|
|
1615
2404
|
}
|
|
1616
2405
|
maxScrollLeft() {
|
|
1617
|
-
const baseX = this.showHeader
|
|
2406
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1618
2407
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1619
2408
|
const cw = contentWidth(this.project, this.pxPerSec);
|
|
1620
|
-
return Math.max(0, cw - visibleW +
|
|
2409
|
+
return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
|
|
1621
2410
|
}
|
|
1622
2411
|
maxScrollTop() {
|
|
1623
|
-
const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
2412
|
+
const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1624
2413
|
const ch = contentHeight(this.project.tracks, this.drag?.kind === "move");
|
|
1625
2414
|
return Math.max(0, ch - visibleH);
|
|
1626
2415
|
}
|
|
@@ -1719,9 +2508,36 @@ var Timeline = class _Timeline {
|
|
|
1719
2508
|
scrollbarOpacityX: this.scrollbarOpacity("h"),
|
|
1720
2509
|
scrollbarActiveY: this.scrollbarDrag?.axis === "v",
|
|
1721
2510
|
scrollbarActiveX: this.scrollbarDrag?.axis === "h",
|
|
1722
|
-
locale: this.locale
|
|
2511
|
+
locale: this.locale,
|
|
2512
|
+
keyframesEnabled: this.keyframesEnabled,
|
|
2513
|
+
selectedKeyframe: this.selectedKeyframe,
|
|
2514
|
+
hoveredKeyframe: this.hoveredKeyframe,
|
|
2515
|
+
keyframeDragGhost: this.drag?.kind === "keyframe-drag" ? {
|
|
2516
|
+
clipId: this.drag.clipId,
|
|
2517
|
+
keyframeId: this.drag.keyframeId,
|
|
2518
|
+
ghostTimeMs: this.drag.ghostTimeMs
|
|
2519
|
+
} : null
|
|
1723
2520
|
};
|
|
1724
2521
|
}
|
|
2522
|
+
/** Host-pushed state — Editor calls this when its keyframe mode
|
|
2523
|
+
* changes or when a keyframe is selected/deselected externally. */
|
|
2524
|
+
setKeyframeState(state) {
|
|
2525
|
+
let dirty = false;
|
|
2526
|
+
if (state.enabled !== void 0 && state.enabled !== this.keyframesEnabled) {
|
|
2527
|
+
this.keyframesEnabled = state.enabled;
|
|
2528
|
+
dirty = true;
|
|
2529
|
+
}
|
|
2530
|
+
if (state.selected !== void 0) {
|
|
2531
|
+
const a = this.selectedKeyframe;
|
|
2532
|
+
const b = state.selected;
|
|
2533
|
+
const same = a?.clipId === b?.clipId && a?.keyframeId === b?.keyframeId;
|
|
2534
|
+
if (!same) {
|
|
2535
|
+
this.selectedKeyframe = b;
|
|
2536
|
+
dirty = true;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
if (dirty) this.scheduleRender();
|
|
2540
|
+
}
|
|
1725
2541
|
readStyle() {
|
|
1726
2542
|
const cs = getComputedStyle(this.root);
|
|
1727
2543
|
const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
|
|
@@ -1750,6 +2566,7 @@ var Timeline = class _Timeline {
|
|
|
1750
2566
|
this.canvas.addEventListener("pointerleave", () => {
|
|
1751
2567
|
if (!this.drag && !this.scrollbarDrag) {
|
|
1752
2568
|
this.hoveredClipId = null;
|
|
2569
|
+
this.hoveredKeyframe = null;
|
|
1753
2570
|
this.hoverCursor = "default";
|
|
1754
2571
|
this.hoverScrollbarY = false;
|
|
1755
2572
|
this.hoverScrollbarX = false;
|
|
@@ -1781,8 +2598,8 @@ var Timeline = class _Timeline {
|
|
|
1781
2598
|
}
|
|
1782
2599
|
if (target.kind === "scrollbar-track-v") {
|
|
1783
2600
|
const page = Math.max(
|
|
1784
|
-
TRACK_HEIGHT,
|
|
1785
|
-
this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS
|
|
2601
|
+
exports.TRACK_HEIGHT,
|
|
2602
|
+
this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS
|
|
1786
2603
|
);
|
|
1787
2604
|
this.scrollTop += target.before ? -page : page;
|
|
1788
2605
|
this.clampScroll();
|
|
@@ -1790,7 +2607,7 @@ var Timeline = class _Timeline {
|
|
|
1790
2607
|
return;
|
|
1791
2608
|
}
|
|
1792
2609
|
if (target.kind === "scrollbar-track-h") {
|
|
1793
|
-
const baseX = this.showHeader
|
|
2610
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1794
2611
|
const page = Math.max(
|
|
1795
2612
|
80,
|
|
1796
2613
|
this.viewportWidth - baseX - SCROLLBAR_THICKNESS
|
|
@@ -1830,6 +2647,33 @@ var Timeline = class _Timeline {
|
|
|
1830
2647
|
this.scheduleRender();
|
|
1831
2648
|
return;
|
|
1832
2649
|
}
|
|
2650
|
+
if (target.kind === "keyframe") {
|
|
2651
|
+
const found = findClip(this.project, target.clipId);
|
|
2652
|
+
const kf = found?.clip.keyframes?.find((k) => k.id === target.keyframeId);
|
|
2653
|
+
if (!found || !kf) return;
|
|
2654
|
+
this.selectedKeyframe = {
|
|
2655
|
+
clipId: target.clipId,
|
|
2656
|
+
keyframeId: target.keyframeId
|
|
2657
|
+
};
|
|
2658
|
+
this.opts.onSelectKeyframe?.({
|
|
2659
|
+
clipId: target.clipId,
|
|
2660
|
+
keyframeId: target.keyframeId
|
|
2661
|
+
});
|
|
2662
|
+
const absMs = found.clip.start + kf.time;
|
|
2663
|
+
this.timeMs = absMs;
|
|
2664
|
+
this.opts.onSeek?.(absMs);
|
|
2665
|
+
this.drag = {
|
|
2666
|
+
kind: "keyframe-drag",
|
|
2667
|
+
clipId: target.clipId,
|
|
2668
|
+
keyframeId: target.keyframeId,
|
|
2669
|
+
trackIndex: target.trackIndex,
|
|
2670
|
+
pointerStartX: x,
|
|
2671
|
+
originalTimeMs: kf.time,
|
|
2672
|
+
ghostTimeMs: kf.time
|
|
2673
|
+
};
|
|
2674
|
+
this.scheduleRender();
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
1833
2677
|
if (target.kind === "clip") {
|
|
1834
2678
|
const found = findClip(this.project, target.clipId);
|
|
1835
2679
|
if (!found) return;
|
|
@@ -1881,7 +2725,7 @@ var Timeline = class _Timeline {
|
|
|
1881
2725
|
const { x, y } = this.localCoords(e);
|
|
1882
2726
|
if (this.scrollbarDrag) {
|
|
1883
2727
|
if (this.scrollbarDrag.axis === "v") {
|
|
1884
|
-
const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
2728
|
+
const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1885
2729
|
const contentH = contentHeight(
|
|
1886
2730
|
this.project.tracks,
|
|
1887
2731
|
this.drag?.kind === "move"
|
|
@@ -1896,7 +2740,7 @@ var Timeline = class _Timeline {
|
|
|
1896
2740
|
const ratio = maxScroll / free;
|
|
1897
2741
|
this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
|
|
1898
2742
|
} else {
|
|
1899
|
-
const baseX = this.showHeader
|
|
2743
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1900
2744
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1901
2745
|
const contentW = contentWidth(this.project, this.pxPerSec);
|
|
1902
2746
|
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
@@ -1920,10 +2764,19 @@ var Timeline = class _Timeline {
|
|
|
1920
2764
|
let cursor = "default";
|
|
1921
2765
|
let onScrollbarV = false;
|
|
1922
2766
|
let onScrollbarH = false;
|
|
2767
|
+
let nextHoverKeyframe = null;
|
|
1923
2768
|
if (target.kind === "clip") {
|
|
1924
2769
|
nextHover = target.clipId;
|
|
1925
2770
|
nextHoverTrack = target.trackIndex;
|
|
1926
2771
|
cursor = this.readOnly ? "pointer" : "grab";
|
|
2772
|
+
} else if (target.kind === "keyframe") {
|
|
2773
|
+
nextHover = target.clipId;
|
|
2774
|
+
nextHoverTrack = target.trackIndex;
|
|
2775
|
+
nextHoverKeyframe = {
|
|
2776
|
+
clipId: target.clipId,
|
|
2777
|
+
keyframeId: target.keyframeId
|
|
2778
|
+
};
|
|
2779
|
+
cursor = "pointer";
|
|
1927
2780
|
} else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
|
|
1928
2781
|
nextHover = target.clipId;
|
|
1929
2782
|
nextHoverTrack = target.trackIndex;
|
|
@@ -1947,12 +2800,14 @@ var Timeline = class _Timeline {
|
|
|
1947
2800
|
cursor = "default";
|
|
1948
2801
|
}
|
|
1949
2802
|
const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
|
|
1950
|
-
|
|
2803
|
+
const kfHoverChanged = (nextHoverKeyframe?.clipId ?? null) !== (this.hoveredKeyframe?.clipId ?? null) || (nextHoverKeyframe?.keyframeId ?? null) !== (this.hoveredKeyframe?.keyframeId ?? null);
|
|
2804
|
+
if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged || kfHoverChanged) {
|
|
1951
2805
|
this.hoveredClipId = nextHover;
|
|
1952
2806
|
this.hoveredTrackIndex = nextHoverTrack;
|
|
1953
2807
|
this.hoverCursor = cursor;
|
|
1954
2808
|
this.hoverScrollbarY = onScrollbarV;
|
|
1955
2809
|
this.hoverScrollbarX = onScrollbarH;
|
|
2810
|
+
this.hoveredKeyframe = nextHoverKeyframe;
|
|
1956
2811
|
this.scheduleRender();
|
|
1957
2812
|
}
|
|
1958
2813
|
return;
|
|
@@ -1975,6 +2830,26 @@ var Timeline = class _Timeline {
|
|
|
1975
2830
|
this.maybeStartDragAutoScroll();
|
|
1976
2831
|
return;
|
|
1977
2832
|
}
|
|
2833
|
+
if (this.drag.kind === "keyframe-drag") {
|
|
2834
|
+
const found = findClip(this.project, this.drag.clipId);
|
|
2835
|
+
if (!found) return;
|
|
2836
|
+
const clip = found.clip;
|
|
2837
|
+
const duration = clip.out - clip.in;
|
|
2838
|
+
const dxPx = x - this.drag.pointerStartX;
|
|
2839
|
+
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
2840
|
+
const nextLocal = Math.max(
|
|
2841
|
+
0,
|
|
2842
|
+
Math.min(duration, this.drag.originalTimeMs + dxMs)
|
|
2843
|
+
);
|
|
2844
|
+
const snappedAbs = this.applySnap(clip.start + nextLocal, null);
|
|
2845
|
+
const snappedLocal = Math.max(
|
|
2846
|
+
0,
|
|
2847
|
+
Math.min(duration, snappedAbs - clip.start)
|
|
2848
|
+
);
|
|
2849
|
+
this.drag.ghostTimeMs = Math.round(snappedLocal);
|
|
2850
|
+
this.scheduleRender();
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
1978
2853
|
if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
|
|
1979
2854
|
const dxPx = x - this.drag.pointerStartX;
|
|
1980
2855
|
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
@@ -2019,8 +2894,9 @@ var Timeline = class _Timeline {
|
|
|
2019
2894
|
nextStart = this.applySnap(nextStart, drag.clipId);
|
|
2020
2895
|
const tiRaw = this.trackIndexAtY(y);
|
|
2021
2896
|
const phantomIdx = this.project.tracks.length;
|
|
2022
|
-
const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
|
|
2023
|
-
const
|
|
2897
|
+
const phantomScreenY = exports.RULER_HEIGHT + phantomIdx * exports.TRACK_HEIGHT - this.scrollTop;
|
|
2898
|
+
const viewportBottom = this.viewportHeight - SCROLLBAR_THICKNESS;
|
|
2899
|
+
const onPhantom = y >= phantomScreenY && y < Math.max(phantomScreenY + exports.TRACK_HEIGHT, viewportBottom);
|
|
2024
2900
|
const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
|
|
2025
2901
|
let ghostTrackIndex = intendedTrackIndex;
|
|
2026
2902
|
let overlap = false;
|
|
@@ -2058,7 +2934,7 @@ var Timeline = class _Timeline {
|
|
|
2058
2934
|
dragScrollSpeedY() {
|
|
2059
2935
|
if (!this.drag || this.drag.kind !== "move") return 0;
|
|
2060
2936
|
const y = this.lastDragPointerY;
|
|
2061
|
-
const top = RULER_HEIGHT;
|
|
2937
|
+
const top = exports.RULER_HEIGHT;
|
|
2062
2938
|
const bottom = this.viewportHeight - SCROLLBAR_THICKNESS;
|
|
2063
2939
|
const zone = 36;
|
|
2064
2940
|
const maxSpeed = 16;
|
|
@@ -2126,6 +3002,14 @@ var Timeline = class _Timeline {
|
|
|
2126
3002
|
});
|
|
2127
3003
|
this.opts.onChange?.(this.getProject());
|
|
2128
3004
|
}
|
|
3005
|
+
} else if (drag.kind === "keyframe-drag") {
|
|
3006
|
+
if (drag.ghostTimeMs !== drag.originalTimeMs) {
|
|
3007
|
+
this.opts.onMoveKeyframe?.(
|
|
3008
|
+
drag.clipId,
|
|
3009
|
+
drag.keyframeId,
|
|
3010
|
+
drag.ghostTimeMs
|
|
3011
|
+
);
|
|
3012
|
+
}
|
|
2129
3013
|
} else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
|
|
2130
3014
|
const found = findClip(this.project, drag.clipId);
|
|
2131
3015
|
if (found) {
|
|
@@ -2139,6 +3023,25 @@ var Timeline = class _Timeline {
|
|
|
2139
3023
|
}
|
|
2140
3024
|
this.scheduleRender();
|
|
2141
3025
|
}
|
|
3026
|
+
attachKeyboard() {
|
|
3027
|
+
this.canvas.tabIndex = -1;
|
|
3028
|
+
this.canvas.style.outline = "none";
|
|
3029
|
+
this.canvas.addEventListener("keydown", (e) => {
|
|
3030
|
+
if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
|
|
3031
|
+
e.preventDefault();
|
|
3032
|
+
const step = e.shiftKey ? bigFrameStepMs(this.project) : frameStepMs(this.project);
|
|
3033
|
+
const dir = e.code === "ArrowLeft" ? -1 : 1;
|
|
3034
|
+
const dur = projectDuration(this.project);
|
|
3035
|
+
const next = Math.max(0, Math.min(dur, this.timeMs + dir * step));
|
|
3036
|
+
if (next === this.timeMs) return;
|
|
3037
|
+
this.timeMs = next;
|
|
3038
|
+
this.opts.onSeek?.(next);
|
|
3039
|
+
this.scheduleRender();
|
|
3040
|
+
});
|
|
3041
|
+
this.canvas.addEventListener("pointerdown", () => {
|
|
3042
|
+
if (document.activeElement !== this.canvas) this.canvas.focus();
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
2142
3045
|
attachWheel() {
|
|
2143
3046
|
this.canvas.addEventListener(
|
|
2144
3047
|
"wheel",
|
|
@@ -2159,7 +3062,7 @@ var Timeline = class _Timeline {
|
|
|
2159
3062
|
if (Math.abs(next - this.pxPerSec) < 0.01) return;
|
|
2160
3063
|
this.pxPerSec = next;
|
|
2161
3064
|
this.hasAutoFitted = true;
|
|
2162
|
-
const baseX = this.showHeader
|
|
3065
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2163
3066
|
this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
|
|
2164
3067
|
this.clampScroll();
|
|
2165
3068
|
this.touchScrollbar("h");
|
|
@@ -2193,64 +3096,768 @@ var Timeline = class _Timeline {
|
|
|
2193
3096
|
{ passive: false }
|
|
2194
3097
|
);
|
|
2195
3098
|
}
|
|
2196
|
-
attachResize() {
|
|
2197
|
-
if (typeof ResizeObserver === "undefined") return;
|
|
2198
|
-
this.resizeObs = new ResizeObserver(() => {
|
|
2199
|
-
this.resizeCanvas();
|
|
2200
|
-
if (!this.hasAutoFitted && this.autoFitEnabled) {
|
|
2201
|
-
const fit = this.computeFitScale();
|
|
2202
|
-
if (fit != null) {
|
|
2203
|
-
this.pxPerSec = fit;
|
|
2204
|
-
this.opts.onScaleChange?.(fit);
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
this.scheduleRender();
|
|
3099
|
+
attachResize() {
|
|
3100
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
3101
|
+
this.resizeObs = new ResizeObserver(() => {
|
|
3102
|
+
this.resizeCanvas();
|
|
3103
|
+
if (!this.hasAutoFitted && this.autoFitEnabled) {
|
|
3104
|
+
const fit = this.computeFitScale();
|
|
3105
|
+
if (fit != null) {
|
|
3106
|
+
this.pxPerSec = fit;
|
|
3107
|
+
this.opts.onScaleChange?.(fit);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
this.scheduleRender();
|
|
3111
|
+
});
|
|
3112
|
+
this.resizeObs.observe(this.root);
|
|
3113
|
+
}
|
|
3114
|
+
// ---- helpers --------------------------------------------------------
|
|
3115
|
+
localCoords(e) {
|
|
3116
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
3117
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
3118
|
+
}
|
|
3119
|
+
hitTarget(x, y) {
|
|
3120
|
+
return hitTest(x, y, {
|
|
3121
|
+
project: this.project,
|
|
3122
|
+
pxPerSec: this.pxPerSec,
|
|
3123
|
+
scrollLeft: this.scrollLeft,
|
|
3124
|
+
scrollTop: this.scrollTop,
|
|
3125
|
+
showHeader: this.showHeader,
|
|
3126
|
+
viewportWidth: this.viewportWidth,
|
|
3127
|
+
viewportHeight: this.viewportHeight,
|
|
3128
|
+
isDragging: this.drag?.kind === "move",
|
|
3129
|
+
keyframesEnabled: this.keyframesEnabled
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
trackIndexAtY(y) {
|
|
3133
|
+
return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
|
|
3134
|
+
}
|
|
3135
|
+
applySnap(ms, ignoreClipId) {
|
|
3136
|
+
if (!this.snapEnabled) {
|
|
3137
|
+
this.snapX = null;
|
|
3138
|
+
return ms;
|
|
3139
|
+
}
|
|
3140
|
+
const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
|
|
3141
|
+
const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
|
|
3142
|
+
let best = ms;
|
|
3143
|
+
let bestDist = tolMs;
|
|
3144
|
+
for (const t of targets) {
|
|
3145
|
+
const d = Math.abs(t - ms);
|
|
3146
|
+
if (d < bestDist) {
|
|
3147
|
+
bestDist = d;
|
|
3148
|
+
best = t;
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
if (best !== ms) {
|
|
3152
|
+
const baseX = contentLeftX(this.showHeader);
|
|
3153
|
+
this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
3154
|
+
} else {
|
|
3155
|
+
this.snapX = null;
|
|
3156
|
+
}
|
|
3157
|
+
return best;
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
|
|
3161
|
+
// src/ui/keyframe-overlay.ts
|
|
3162
|
+
var KeyframeOverlay = class _KeyframeOverlay {
|
|
3163
|
+
editor;
|
|
3164
|
+
host;
|
|
3165
|
+
root;
|
|
3166
|
+
frameBody;
|
|
3167
|
+
handles;
|
|
3168
|
+
rafHandle = null;
|
|
3169
|
+
destroyed = false;
|
|
3170
|
+
drag = null;
|
|
3171
|
+
capturedPointerId = null;
|
|
3172
|
+
/** Timer handle for the wheel-burst → interaction commit. */
|
|
3173
|
+
wheelInteractionTimer = null;
|
|
3174
|
+
/** Snap-target threshold in CSS px — the same feel as the timeline. */
|
|
3175
|
+
static SNAP_PX = 8;
|
|
3176
|
+
constructor(host, editor) {
|
|
3177
|
+
this.host = host;
|
|
3178
|
+
this.editor = editor;
|
|
3179
|
+
this.root = document.createElement("div");
|
|
3180
|
+
this.root.className = "aicut-keyframe-overlay";
|
|
3181
|
+
this.root.setAttribute("data-testid", "aicut-keyframe-overlay");
|
|
3182
|
+
this.root.style.display = "none";
|
|
3183
|
+
this.frameBody = document.createElement("div");
|
|
3184
|
+
this.frameBody.className = "aicut-keyframe-overlay__frame";
|
|
3185
|
+
this.frameBody.setAttribute("data-testid", "aicut-keyframe-frame");
|
|
3186
|
+
this.frameBody.addEventListener("pointerdown", (e) => this.onTransStart(e));
|
|
3187
|
+
this.frameBody.addEventListener(
|
|
3188
|
+
"wheel",
|
|
3189
|
+
(e) => this.onPinchScale(e),
|
|
3190
|
+
{ passive: false }
|
|
3191
|
+
);
|
|
3192
|
+
this.root.appendChild(this.frameBody);
|
|
3193
|
+
this.handles = {
|
|
3194
|
+
tl: this.makeHandle("tl"),
|
|
3195
|
+
tr: this.makeHandle("tr"),
|
|
3196
|
+
bl: this.makeHandle("bl"),
|
|
3197
|
+
br: this.makeHandle("br")
|
|
3198
|
+
};
|
|
3199
|
+
host.appendChild(this.root);
|
|
3200
|
+
this.startTick();
|
|
3201
|
+
}
|
|
3202
|
+
destroy() {
|
|
3203
|
+
this.destroyed = true;
|
|
3204
|
+
if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
|
|
3205
|
+
if (this.wheelInteractionTimer != null) {
|
|
3206
|
+
clearTimeout(this.wheelInteractionTimer);
|
|
3207
|
+
this.wheelInteractionTimer = null;
|
|
3208
|
+
this.editor.endInteraction();
|
|
3209
|
+
}
|
|
3210
|
+
this.root.remove();
|
|
3211
|
+
}
|
|
3212
|
+
// ---- frame body drag (translate) -------------------------------------
|
|
3213
|
+
onTransStart(e) {
|
|
3214
|
+
if (e.button !== 0) return;
|
|
3215
|
+
const ctx = this.ensureSelectedClip();
|
|
3216
|
+
if (!ctx) return;
|
|
3217
|
+
e.preventDefault();
|
|
3218
|
+
e.stopPropagation();
|
|
3219
|
+
this.frameBody.setPointerCapture(e.pointerId);
|
|
3220
|
+
this.capturedPointerId = e.pointerId;
|
|
3221
|
+
this.drag = {
|
|
3222
|
+
kind: "translate",
|
|
3223
|
+
clipId: ctx.clip.id,
|
|
3224
|
+
pointerStartX: e.clientX,
|
|
3225
|
+
pointerStartY: e.clientY,
|
|
3226
|
+
startPanX: ctx.transform.panX,
|
|
3227
|
+
startPanY: ctx.transform.panY
|
|
3228
|
+
};
|
|
3229
|
+
this.editor.beginInteraction();
|
|
3230
|
+
this.frameBody.addEventListener("pointermove", this.onPointerMove);
|
|
3231
|
+
this.frameBody.addEventListener("pointerup", this.onPointerUp);
|
|
3232
|
+
this.frameBody.addEventListener("pointercancel", this.onPointerUp);
|
|
3233
|
+
}
|
|
3234
|
+
// ---- pinch-to-scale --------------------------------------------------
|
|
3235
|
+
onPinchScale(e) {
|
|
3236
|
+
if (!e.ctrlKey) return;
|
|
3237
|
+
const ctx = this.ensureSelectedClip();
|
|
3238
|
+
if (!ctx) return;
|
|
3239
|
+
e.preventDefault();
|
|
3240
|
+
e.stopPropagation();
|
|
3241
|
+
const step = Math.max(-50, Math.min(50, -e.deltaY));
|
|
3242
|
+
const factor = Math.exp(step * 0.01);
|
|
3243
|
+
const next = Math.max(
|
|
3244
|
+
0.05,
|
|
3245
|
+
Math.min(16, ctx.transform.scale * factor)
|
|
3246
|
+
);
|
|
3247
|
+
if (this.wheelInteractionTimer == null) {
|
|
3248
|
+
this.editor.beginInteraction();
|
|
3249
|
+
} else {
|
|
3250
|
+
clearTimeout(this.wheelInteractionTimer);
|
|
3251
|
+
}
|
|
3252
|
+
this.wheelInteractionTimer = window.setTimeout(() => {
|
|
3253
|
+
this.wheelInteractionTimer = null;
|
|
3254
|
+
this.editor.endInteraction();
|
|
3255
|
+
}, 200);
|
|
3256
|
+
this.editor.setValueAtPlayhead(
|
|
3257
|
+
ctx.clip.id,
|
|
3258
|
+
"scale",
|
|
3259
|
+
Math.round(next * 100) / 100
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
// ---- corner-handle drag (scale) --------------------------------------
|
|
3263
|
+
onScaleStart(corner, e) {
|
|
3264
|
+
if (e.button !== 0) return;
|
|
3265
|
+
const ctx = this.ensureSelectedClip();
|
|
3266
|
+
if (!ctx) return;
|
|
3267
|
+
e.preventDefault();
|
|
3268
|
+
e.stopPropagation();
|
|
3269
|
+
const rect = this.editor.getActiveOutputFrameRect() ?? this.editor.getActiveFrameRect();
|
|
3270
|
+
if (!rect) return;
|
|
3271
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
3272
|
+
const cx = hostRect.left + rect.x + rect.w / 2;
|
|
3273
|
+
const cy = hostRect.top + rect.y + rect.h / 2;
|
|
3274
|
+
const startDist = Math.hypot(e.clientX - cx, e.clientY - cy);
|
|
3275
|
+
if (startDist < 1) return;
|
|
3276
|
+
const target = this.handles[corner];
|
|
3277
|
+
target.setPointerCapture(e.pointerId);
|
|
3278
|
+
this.capturedPointerId = e.pointerId;
|
|
3279
|
+
this.drag = {
|
|
3280
|
+
kind: "scale",
|
|
3281
|
+
clipId: ctx.clip.id,
|
|
3282
|
+
centerX: cx,
|
|
3283
|
+
centerY: cy,
|
|
3284
|
+
startDistance: startDist,
|
|
3285
|
+
startScale: ctx.transform.scale
|
|
3286
|
+
};
|
|
3287
|
+
this.editor.beginInteraction();
|
|
3288
|
+
target.addEventListener("pointermove", this.onPointerMove);
|
|
3289
|
+
target.addEventListener("pointerup", this.onPointerUp);
|
|
3290
|
+
target.addEventListener("pointercancel", this.onPointerUp);
|
|
3291
|
+
}
|
|
3292
|
+
onPointerMove = (e) => {
|
|
3293
|
+
if (!this.drag) return;
|
|
3294
|
+
if (this.drag.kind === "translate") {
|
|
3295
|
+
const dx = e.clientX - this.drag.pointerStartX;
|
|
3296
|
+
const dy = e.clientY - this.drag.pointerStartY;
|
|
3297
|
+
const rawPanX = this.drag.startPanX + dx;
|
|
3298
|
+
const rawPanY = this.drag.startPanY + dy;
|
|
3299
|
+
const snapped = this.applySnap(this.drag.clipId, rawPanX, rawPanY);
|
|
3300
|
+
this.editor.setValueAtPlayhead(
|
|
3301
|
+
this.drag.clipId,
|
|
3302
|
+
"panX",
|
|
3303
|
+
Math.round(snapped.panX)
|
|
3304
|
+
);
|
|
3305
|
+
this.editor.setValueAtPlayhead(
|
|
3306
|
+
this.drag.clipId,
|
|
3307
|
+
"panY",
|
|
3308
|
+
Math.round(snapped.panY)
|
|
3309
|
+
);
|
|
3310
|
+
} else {
|
|
3311
|
+
const dist = Math.hypot(
|
|
3312
|
+
e.clientX - this.drag.centerX,
|
|
3313
|
+
e.clientY - this.drag.centerY
|
|
3314
|
+
);
|
|
3315
|
+
const ratio = dist / this.drag.startDistance;
|
|
3316
|
+
const next = Math.max(
|
|
3317
|
+
0.05,
|
|
3318
|
+
Math.min(16, this.drag.startScale * ratio)
|
|
3319
|
+
);
|
|
3320
|
+
this.editor.setValueAtPlayhead(
|
|
3321
|
+
this.drag.clipId,
|
|
3322
|
+
"scale",
|
|
3323
|
+
Math.round(next * 100) / 100
|
|
3324
|
+
);
|
|
3325
|
+
}
|
|
3326
|
+
};
|
|
3327
|
+
/**
|
|
3328
|
+
* Snap raw pan to: centered (panX/Y = 0) and the four edge-alignment
|
|
3329
|
+
* stops (content's L/R/T/B edge flush with the output's matching
|
|
3330
|
+
* edge). When content is smaller than output, the edge stops collapse
|
|
3331
|
+
* to the same point as 0 — harmless dup. Threshold = 8 CSS px.
|
|
3332
|
+
*/
|
|
3333
|
+
applySnap(clipId, rawPanX, rawPanY) {
|
|
3334
|
+
const out = this.editor.getActiveOutputFrameRect();
|
|
3335
|
+
if (!out) return { panX: rawPanX, panY: rawPanY };
|
|
3336
|
+
const clip = this.findClip(clipId);
|
|
3337
|
+
if (!clip) return { panX: rawPanX, panY: rawPanY };
|
|
3338
|
+
const t = (() => {
|
|
3339
|
+
try {
|
|
3340
|
+
const transformer = this.editor.getActiveFrameRect();
|
|
3341
|
+
if (!transformer) return null;
|
|
3342
|
+
return { w: transformer.w, h: transformer.h };
|
|
3343
|
+
} catch {
|
|
3344
|
+
return null;
|
|
3345
|
+
}
|
|
3346
|
+
})();
|
|
3347
|
+
const contentW = t?.w ?? out.w;
|
|
3348
|
+
const contentH = t?.h ?? out.h;
|
|
3349
|
+
const edgeX = (contentW - out.w) / 2;
|
|
3350
|
+
const edgeY = (contentH - out.h) / 2;
|
|
3351
|
+
const xTargets = [0, edgeX, -edgeX];
|
|
3352
|
+
const yTargets = [0, edgeY, -edgeY];
|
|
3353
|
+
const px = nearestSnap(rawPanX, xTargets, _KeyframeOverlay.SNAP_PX);
|
|
3354
|
+
const py = nearestSnap(rawPanY, yTargets, _KeyframeOverlay.SNAP_PX);
|
|
3355
|
+
return { panX: px, panY: py };
|
|
3356
|
+
}
|
|
3357
|
+
findClip(clipId) {
|
|
3358
|
+
const project = this.editor.getProject();
|
|
3359
|
+
for (const t of project.tracks) {
|
|
3360
|
+
const c = t.clips.find((cl) => cl.id === clipId);
|
|
3361
|
+
if (c) return c;
|
|
3362
|
+
}
|
|
3363
|
+
return null;
|
|
3364
|
+
}
|
|
3365
|
+
onPointerUp = (e) => {
|
|
3366
|
+
if (!this.drag) return;
|
|
3367
|
+
const targetEl = e.currentTarget;
|
|
3368
|
+
if (targetEl && this.capturedPointerId === e.pointerId) {
|
|
3369
|
+
try {
|
|
3370
|
+
targetEl.releasePointerCapture(e.pointerId);
|
|
3371
|
+
} catch {
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
targetEl?.removeEventListener("pointermove", this.onPointerMove);
|
|
3375
|
+
targetEl?.removeEventListener("pointerup", this.onPointerUp);
|
|
3376
|
+
targetEl?.removeEventListener("pointercancel", this.onPointerUp);
|
|
3377
|
+
this.drag = null;
|
|
3378
|
+
this.capturedPointerId = null;
|
|
3379
|
+
this.editor.endInteraction();
|
|
3380
|
+
};
|
|
3381
|
+
// ---- per-frame layout ------------------------------------------------
|
|
3382
|
+
startTick() {
|
|
3383
|
+
const tick = () => {
|
|
3384
|
+
if (this.destroyed) return;
|
|
3385
|
+
this.layout();
|
|
3386
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
3387
|
+
};
|
|
3388
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
3389
|
+
}
|
|
3390
|
+
layout() {
|
|
3391
|
+
const enabled = this.editor.isKeyframesEnabled();
|
|
3392
|
+
if (!enabled) {
|
|
3393
|
+
this.root.style.display = "none";
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
const outRect = this.editor.getActiveOutputFrameRect();
|
|
3397
|
+
const contentRect = this.editor.getActiveFrameRect() ?? outRect;
|
|
3398
|
+
if (!outRect) {
|
|
3399
|
+
this.root.style.display = "none";
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
this.root.style.display = "block";
|
|
3403
|
+
Object.assign(this.frameBody.style, {
|
|
3404
|
+
left: `${outRect.x}px`,
|
|
3405
|
+
top: `${outRect.y}px`,
|
|
3406
|
+
width: `${outRect.w}px`,
|
|
3407
|
+
height: `${outRect.h}px`
|
|
3408
|
+
});
|
|
3409
|
+
const fullyCovered = contentRect ? contentRect.x <= outRect.x + 0.5 && contentRect.x + contentRect.w >= outRect.x + outRect.w - 0.5 && contentRect.y <= outRect.y + 0.5 && contentRect.y + contentRect.h >= outRect.y + outRect.h - 0.5 : true;
|
|
3410
|
+
this.frameBody.classList.toggle(
|
|
3411
|
+
"aicut-keyframe-overlay__frame--warn",
|
|
3412
|
+
!fullyCovered
|
|
3413
|
+
);
|
|
3414
|
+
const halfHandle = 6;
|
|
3415
|
+
const r = contentRect ?? outRect;
|
|
3416
|
+
const fbLeft = r.x;
|
|
3417
|
+
const fbTop = r.y;
|
|
3418
|
+
const fbRight = r.x + r.w;
|
|
3419
|
+
const fbBottom = r.y + r.h;
|
|
3420
|
+
const place = (el, cx, cy) => {
|
|
3421
|
+
el.style.left = `${cx - halfHandle}px`;
|
|
3422
|
+
el.style.top = `${cy - halfHandle}px`;
|
|
3423
|
+
};
|
|
3424
|
+
place(this.handles.tl, fbLeft, fbTop);
|
|
3425
|
+
place(this.handles.tr, fbRight, fbTop);
|
|
3426
|
+
place(this.handles.bl, fbLeft, fbBottom);
|
|
3427
|
+
place(this.handles.br, fbRight, fbBottom);
|
|
3428
|
+
}
|
|
3429
|
+
// ---- helpers ---------------------------------------------------------
|
|
3430
|
+
makeHandle(name) {
|
|
3431
|
+
const el = document.createElement("div");
|
|
3432
|
+
el.className = `aicut-keyframe-overlay__handle aicut-keyframe-overlay__handle--${name}`;
|
|
3433
|
+
el.setAttribute("data-testid", `aicut-keyframe-handle-${name}`);
|
|
3434
|
+
el.addEventListener("pointerdown", (e) => this.onScaleStart(name, e));
|
|
3435
|
+
this.root.appendChild(el);
|
|
3436
|
+
return el;
|
|
3437
|
+
}
|
|
3438
|
+
/**
|
|
3439
|
+
* Resolve the currently selected clip + its current effective
|
|
3440
|
+
* transform (so drag baselines are correct). Returns null when no
|
|
3441
|
+
* clip is selected or the playhead isn't over it.
|
|
3442
|
+
*/
|
|
3443
|
+
ensureSelectedClip() {
|
|
3444
|
+
const selectedClipId = this.editor.getSelection();
|
|
3445
|
+
if (!selectedClipId) return null;
|
|
3446
|
+
const project = this.editor.getProject();
|
|
3447
|
+
let clip = null;
|
|
3448
|
+
for (const t of project.tracks) {
|
|
3449
|
+
const c = t.clips.find((cl) => cl.id === selectedClipId);
|
|
3450
|
+
if (c) {
|
|
3451
|
+
clip = c;
|
|
3452
|
+
break;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
if (!clip) return null;
|
|
3456
|
+
const playheadLocal = this.editor.getTime() - clip.start;
|
|
3457
|
+
if (playheadLocal < 0 || playheadLocal > clip.out - clip.in) {
|
|
3458
|
+
return null;
|
|
3459
|
+
}
|
|
3460
|
+
const transform = getEffectiveTransform(clip, playheadLocal);
|
|
3461
|
+
return { clip, transform };
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
3464
|
+
function nearestSnap(raw, targets, threshold) {
|
|
3465
|
+
let best = raw;
|
|
3466
|
+
let bestDist = threshold;
|
|
3467
|
+
for (const t of targets) {
|
|
3468
|
+
const d = Math.abs(raw - t);
|
|
3469
|
+
if (d < bestDist) {
|
|
3470
|
+
bestDist = d;
|
|
3471
|
+
best = t;
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
return best;
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// src/ui/keyframe-panel.ts
|
|
3478
|
+
var EASING_VALUES = [
|
|
3479
|
+
"linear",
|
|
3480
|
+
"easeIn",
|
|
3481
|
+
"easeOut",
|
|
3482
|
+
"easeInOut"
|
|
3483
|
+
];
|
|
3484
|
+
function easingLabel(value, locale) {
|
|
3485
|
+
switch (value) {
|
|
3486
|
+
case "linear":
|
|
3487
|
+
return locale.keyframeEasingLinear;
|
|
3488
|
+
case "easeIn":
|
|
3489
|
+
return locale.keyframeEasingEaseIn;
|
|
3490
|
+
case "easeOut":
|
|
3491
|
+
return locale.keyframeEasingEaseOut;
|
|
3492
|
+
case "easeInOut":
|
|
3493
|
+
return locale.keyframeEasingEaseInOut;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
var TIME_EPS_MS = 16;
|
|
3497
|
+
var KeyframePanel = class {
|
|
3498
|
+
editor;
|
|
3499
|
+
locale;
|
|
3500
|
+
root;
|
|
3501
|
+
inputs;
|
|
3502
|
+
kfBadges;
|
|
3503
|
+
timeLabel;
|
|
3504
|
+
titleLabel;
|
|
3505
|
+
resetBtn;
|
|
3506
|
+
easingTrigger;
|
|
3507
|
+
easingTriggerLabel;
|
|
3508
|
+
easingMenu;
|
|
3509
|
+
easingItems;
|
|
3510
|
+
easingValue = "linear";
|
|
3511
|
+
easingDisabled = false;
|
|
3512
|
+
easingOpen = false;
|
|
3513
|
+
easingLabelEl;
|
|
3514
|
+
rowLabels;
|
|
3515
|
+
lastSyncKey = "";
|
|
3516
|
+
// Bound once so add/remove listener pairs reference the same fn.
|
|
3517
|
+
boundOutsideClick = null;
|
|
3518
|
+
boundDocKeydown = null;
|
|
3519
|
+
constructor(host, editor, locale) {
|
|
3520
|
+
this.editor = editor;
|
|
3521
|
+
this.locale = locale;
|
|
3522
|
+
this.root = document.createElement("div");
|
|
3523
|
+
this.root.className = "aicut-keyframe-panel";
|
|
3524
|
+
this.root.setAttribute("data-testid", "aicut-keyframe-panel");
|
|
3525
|
+
this.root.style.display = "none";
|
|
3526
|
+
this.root.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
3527
|
+
this.root.addEventListener("wheel", (e) => e.stopPropagation());
|
|
3528
|
+
const title = document.createElement("div");
|
|
3529
|
+
title.className = "aicut-keyframe-panel__title";
|
|
3530
|
+
this.titleLabel = document.createElement("span");
|
|
3531
|
+
this.timeLabel = document.createElement("span");
|
|
3532
|
+
this.timeLabel.className = "aicut-keyframe-panel__time";
|
|
3533
|
+
title.append(this.titleLabel, this.timeLabel);
|
|
3534
|
+
this.root.appendChild(title);
|
|
3535
|
+
const xRow = this.makeRow("kf-x", "panX", 1);
|
|
3536
|
+
const yRow = this.makeRow("kf-y", "panY", 1);
|
|
3537
|
+
const scaleRow = this.makeRow("kf-scale", "scale", 0.05);
|
|
3538
|
+
this.inputs = {
|
|
3539
|
+
panX: xRow.input,
|
|
3540
|
+
panY: yRow.input,
|
|
3541
|
+
scale: scaleRow.input
|
|
3542
|
+
};
|
|
3543
|
+
this.rowLabels = {
|
|
3544
|
+
panX: xRow.label,
|
|
3545
|
+
panY: yRow.label,
|
|
3546
|
+
scale: scaleRow.label
|
|
3547
|
+
};
|
|
3548
|
+
this.kfBadges = {
|
|
3549
|
+
panX: this.makeBadge(this.inputs.panX),
|
|
3550
|
+
panY: this.makeBadge(this.inputs.panY),
|
|
3551
|
+
scale: this.makeBadge(this.inputs.scale)
|
|
3552
|
+
};
|
|
3553
|
+
const easingRow = document.createElement("div");
|
|
3554
|
+
easingRow.className = "aicut-keyframe-panel__row aicut-keyframe-panel__row--easing";
|
|
3555
|
+
this.easingLabelEl = document.createElement("label");
|
|
3556
|
+
const dd = document.createElement("div");
|
|
3557
|
+
dd.className = "aicut-keyframe-panel__dropdown";
|
|
3558
|
+
dd.setAttribute("data-testid", "aicut-kf-easing");
|
|
3559
|
+
this.easingTrigger = document.createElement("button");
|
|
3560
|
+
this.easingTrigger.type = "button";
|
|
3561
|
+
this.easingTrigger.className = "aicut-keyframe-panel__dropdown-trigger";
|
|
3562
|
+
this.easingTrigger.setAttribute("aria-haspopup", "listbox");
|
|
3563
|
+
this.easingTrigger.setAttribute("aria-expanded", "false");
|
|
3564
|
+
this.easingTriggerLabel = document.createElement("span");
|
|
3565
|
+
this.easingTriggerLabel.className = "aicut-keyframe-panel__dropdown-trigger-label";
|
|
3566
|
+
const chevron = document.createElement("span");
|
|
3567
|
+
chevron.className = "aicut-keyframe-panel__dropdown-chevron";
|
|
3568
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
3569
|
+
this.easingTrigger.append(this.easingTriggerLabel, chevron);
|
|
3570
|
+
this.easingTrigger.addEventListener("click", (e) => {
|
|
3571
|
+
e.stopPropagation();
|
|
3572
|
+
if (this.easingDisabled) return;
|
|
3573
|
+
this.toggleEasingMenu();
|
|
3574
|
+
});
|
|
3575
|
+
this.easingTrigger.addEventListener("keydown", (e) => {
|
|
3576
|
+
if (this.easingDisabled) return;
|
|
3577
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
3578
|
+
e.preventDefault();
|
|
3579
|
+
if (!this.easingOpen) this.openEasingMenu();
|
|
3580
|
+
this.easingItems.get(this.easingValue)?.focus();
|
|
3581
|
+
}
|
|
3582
|
+
});
|
|
3583
|
+
this.easingMenu = document.createElement("ul");
|
|
3584
|
+
this.easingMenu.className = "aicut-keyframe-panel__dropdown-menu";
|
|
3585
|
+
this.easingMenu.setAttribute("role", "listbox");
|
|
3586
|
+
this.easingMenu.style.display = "none";
|
|
3587
|
+
this.easingItems = /* @__PURE__ */ new Map();
|
|
3588
|
+
for (const value of EASING_VALUES) {
|
|
3589
|
+
const li = document.createElement("li");
|
|
3590
|
+
li.className = "aicut-keyframe-panel__dropdown-item";
|
|
3591
|
+
li.setAttribute("role", "option");
|
|
3592
|
+
li.setAttribute("data-value", value);
|
|
3593
|
+
li.setAttribute("tabindex", "-1");
|
|
3594
|
+
li.addEventListener("click", (e) => {
|
|
3595
|
+
e.stopPropagation();
|
|
3596
|
+
this.selectEasing(value);
|
|
3597
|
+
});
|
|
3598
|
+
li.addEventListener("keydown", (e) => this.onMenuKeydown(e, value));
|
|
3599
|
+
this.easingItems.set(value, li);
|
|
3600
|
+
this.easingMenu.appendChild(li);
|
|
3601
|
+
}
|
|
3602
|
+
dd.append(this.easingTrigger, this.easingMenu);
|
|
3603
|
+
easingRow.append(this.easingLabelEl, dd);
|
|
3604
|
+
this.root.appendChild(easingRow);
|
|
3605
|
+
const actions = document.createElement("div");
|
|
3606
|
+
actions.className = "aicut-keyframe-panel__actions";
|
|
3607
|
+
this.resetBtn = document.createElement("button");
|
|
3608
|
+
this.resetBtn.type = "button";
|
|
3609
|
+
this.resetBtn.className = "aicut-keyframe-panel__reset";
|
|
3610
|
+
this.resetBtn.setAttribute("data-testid", "aicut-keyframe-reset");
|
|
3611
|
+
this.resetBtn.addEventListener("click", () => this.onReset());
|
|
3612
|
+
actions.appendChild(this.resetBtn);
|
|
3613
|
+
this.root.appendChild(actions);
|
|
3614
|
+
this.applyLocaleText();
|
|
3615
|
+
host.appendChild(this.root);
|
|
3616
|
+
}
|
|
3617
|
+
setLocale(locale) {
|
|
3618
|
+
this.locale = locale;
|
|
3619
|
+
this.applyLocaleText();
|
|
3620
|
+
this.lastSyncKey = "";
|
|
3621
|
+
this.render();
|
|
3622
|
+
}
|
|
3623
|
+
applyLocaleText() {
|
|
3624
|
+
this.titleLabel.textContent = this.locale.keyframePanelTitle;
|
|
3625
|
+
this.rowLabels.panX.textContent = this.locale.keyframePanelLabelX;
|
|
3626
|
+
this.rowLabels.panY.textContent = this.locale.keyframePanelLabelY;
|
|
3627
|
+
this.rowLabels.scale.textContent = this.locale.keyframePanelLabelScale;
|
|
3628
|
+
this.easingLabelEl.textContent = this.locale.keyframePanelLabelEasing;
|
|
3629
|
+
this.resetBtn.textContent = this.locale.keyframePanelReset;
|
|
3630
|
+
this.resetBtn.title = this.locale.keyframePanelResetTitle;
|
|
3631
|
+
for (const [value, li] of this.easingItems) {
|
|
3632
|
+
li.textContent = easingLabel(value, this.locale);
|
|
3633
|
+
}
|
|
3634
|
+
this.easingTriggerLabel.textContent = easingLabel(
|
|
3635
|
+
this.easingValue,
|
|
3636
|
+
this.locale
|
|
3637
|
+
);
|
|
3638
|
+
}
|
|
3639
|
+
destroy() {
|
|
3640
|
+
this.closeEasingMenu();
|
|
3641
|
+
this.root.remove();
|
|
3642
|
+
}
|
|
3643
|
+
render() {
|
|
3644
|
+
const enabled = this.editor.isKeyframesEnabled();
|
|
3645
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3646
|
+
if (!enabled || !sel) {
|
|
3647
|
+
this.root.style.display = "none";
|
|
3648
|
+
this.lastSyncKey = "";
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
const clip = this.findClip(sel.clipId);
|
|
3652
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3653
|
+
if (!clip || !anchorKf) {
|
|
3654
|
+
this.root.style.display = "none";
|
|
3655
|
+
this.lastSyncKey = "";
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
const time = anchorKf.time;
|
|
3659
|
+
const moment = (clip.keyframes ?? []).filter(
|
|
3660
|
+
(k) => Math.abs(k.time - time) < TIME_EPS_MS
|
|
3661
|
+
);
|
|
3662
|
+
const interp = getEffectiveTransform(clip, time);
|
|
3663
|
+
const valueOf = (prop) => {
|
|
3664
|
+
const m = moment.find((k) => k.prop === prop);
|
|
3665
|
+
if (m) return m.value;
|
|
3666
|
+
return interp[prop];
|
|
3667
|
+
};
|
|
3668
|
+
const v = {
|
|
3669
|
+
panX: valueOf("panX"),
|
|
3670
|
+
panY: valueOf("panY"),
|
|
3671
|
+
scale: valueOf("scale")
|
|
3672
|
+
};
|
|
3673
|
+
const sharedEasing = (() => {
|
|
3674
|
+
if (moment.length === 0) return "linear";
|
|
3675
|
+
const anchor = moment.find((k) => k.id === sel.keyframeId) ?? moment[0];
|
|
3676
|
+
return anchor.easing ?? "linear";
|
|
3677
|
+
})();
|
|
3678
|
+
const syncKey = `${clip.id}|${time}|${v.panX.toFixed(2)}|${v.panY.toFixed(2)}|${v.scale.toFixed(4)}|${moment.map((m) => m.prop).join(",")}|${sharedEasing}`;
|
|
3679
|
+
this.root.style.display = "flex";
|
|
3680
|
+
if (syncKey === this.lastSyncKey) return;
|
|
3681
|
+
this.lastSyncKey = syncKey;
|
|
3682
|
+
this.setIfBlur(this.inputs.panX, String(Math.round(v.panX)));
|
|
3683
|
+
this.setIfBlur(this.inputs.panY, String(Math.round(v.panY)));
|
|
3684
|
+
this.setIfBlur(this.inputs.scale, v.scale.toFixed(2));
|
|
3685
|
+
this.timeLabel.textContent = `${(time / 1e3).toFixed(2)}${this.locale.keyframePanelTimeSuffix}`;
|
|
3686
|
+
this.setEasingValue(sharedEasing);
|
|
3687
|
+
this.setEasingDisabled(moment.length === 0);
|
|
3688
|
+
for (const p of ["panX", "panY", "scale"]) {
|
|
3689
|
+
const animated = moment.some((k) => k.prop === p) || hasKeyframesForProp(clip, p);
|
|
3690
|
+
const pinned = moment.some((k) => k.prop === p);
|
|
3691
|
+
this.kfBadges[p].classList.toggle(
|
|
3692
|
+
"aicut-keyframe-panel__badge--on",
|
|
3693
|
+
pinned
|
|
3694
|
+
);
|
|
3695
|
+
this.kfBadges[p].title = pinned ? this.locale.keyframePanelBadgePinned : animated ? this.locale.keyframePanelBadgeAnimated : this.locale.keyframePanelBadgeStatic;
|
|
3696
|
+
}
|
|
3697
|
+
this.resetBtn.disabled = false;
|
|
3698
|
+
}
|
|
3699
|
+
// ---- internals ------------------------------------------------------
|
|
3700
|
+
makeRow(testId, prop, step) {
|
|
3701
|
+
const row = document.createElement("div");
|
|
3702
|
+
row.className = "aicut-keyframe-panel__row";
|
|
3703
|
+
const lab = document.createElement("label");
|
|
3704
|
+
const input = document.createElement("input");
|
|
3705
|
+
input.type = "number";
|
|
3706
|
+
input.step = String(step);
|
|
3707
|
+
input.setAttribute("data-testid", `aicut-${testId}`);
|
|
3708
|
+
input.addEventListener("blur", () => this.commit(prop, input.value));
|
|
3709
|
+
input.addEventListener("keydown", (e) => {
|
|
3710
|
+
if (e.key === "Enter") input.blur();
|
|
2208
3711
|
});
|
|
2209
|
-
|
|
3712
|
+
row.append(lab, input);
|
|
3713
|
+
this.root.appendChild(row);
|
|
3714
|
+
return { input, label: lab };
|
|
3715
|
+
}
|
|
3716
|
+
makeBadge(input) {
|
|
3717
|
+
const dot = document.createElement("span");
|
|
3718
|
+
dot.className = "aicut-keyframe-panel__badge";
|
|
3719
|
+
input.parentElement?.appendChild(dot);
|
|
3720
|
+
return dot;
|
|
3721
|
+
}
|
|
3722
|
+
commit(prop, raw) {
|
|
3723
|
+
const num = Number(raw);
|
|
3724
|
+
if (!Number.isFinite(num)) return;
|
|
3725
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3726
|
+
if (!sel) return;
|
|
3727
|
+
const clip = this.findClip(sel.clipId);
|
|
3728
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3729
|
+
if (!clip || !anchorKf) return;
|
|
3730
|
+
this.editor.addKeyframe(sel.clipId, prop, {
|
|
3731
|
+
time: anchorKf.time,
|
|
3732
|
+
value: num
|
|
3733
|
+
});
|
|
3734
|
+
if (anchorKf.prop !== prop) {
|
|
3735
|
+
const refreshedClip = this.findClip(sel.clipId);
|
|
3736
|
+
const created = (refreshedClip?.keyframes ?? []).find(
|
|
3737
|
+
(k) => k.prop === prop && Math.abs(k.time - anchorKf.time) < TIME_EPS_MS
|
|
3738
|
+
);
|
|
3739
|
+
if (created) {
|
|
3740
|
+
this.editor.setSelectedKeyframe({
|
|
3741
|
+
clipId: sel.clipId,
|
|
3742
|
+
keyframeId: created.id
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
2210
3746
|
}
|
|
2211
|
-
// ----
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
3747
|
+
// ---- custom dropdown -------------------------------------------------
|
|
3748
|
+
setEasingValue(value) {
|
|
3749
|
+
if (this.easingValue === value) return;
|
|
3750
|
+
this.easingValue = value;
|
|
3751
|
+
this.easingTriggerLabel.textContent = easingLabel(value, this.locale);
|
|
3752
|
+
for (const [v, li] of this.easingItems) {
|
|
3753
|
+
li.classList.toggle(
|
|
3754
|
+
"aicut-keyframe-panel__dropdown-item--selected",
|
|
3755
|
+
v === value
|
|
3756
|
+
);
|
|
3757
|
+
li.setAttribute("aria-selected", v === value ? "true" : "false");
|
|
3758
|
+
}
|
|
2215
3759
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
3760
|
+
setEasingDisabled(disabled) {
|
|
3761
|
+
if (this.easingDisabled === disabled) return;
|
|
3762
|
+
this.easingDisabled = disabled;
|
|
3763
|
+
this.easingTrigger.disabled = disabled;
|
|
3764
|
+
this.easingTrigger.classList.toggle(
|
|
3765
|
+
"aicut-keyframe-panel__dropdown-trigger--disabled",
|
|
3766
|
+
disabled
|
|
3767
|
+
);
|
|
3768
|
+
if (disabled && this.easingOpen) this.closeEasingMenu();
|
|
3769
|
+
}
|
|
3770
|
+
toggleEasingMenu() {
|
|
3771
|
+
if (this.easingOpen) this.closeEasingMenu();
|
|
3772
|
+
else this.openEasingMenu();
|
|
3773
|
+
}
|
|
3774
|
+
openEasingMenu() {
|
|
3775
|
+
if (this.easingOpen || this.easingDisabled) return;
|
|
3776
|
+
this.easingOpen = true;
|
|
3777
|
+
this.easingMenu.style.display = "";
|
|
3778
|
+
this.easingTrigger.setAttribute("aria-expanded", "true");
|
|
3779
|
+
this.easingTrigger.classList.add(
|
|
3780
|
+
"aicut-keyframe-panel__dropdown-trigger--open"
|
|
3781
|
+
);
|
|
3782
|
+
requestAnimationFrame(() => {
|
|
3783
|
+
if (!this.easingOpen) return;
|
|
3784
|
+
this.boundOutsideClick = (e) => {
|
|
3785
|
+
if (!this.easingMenu.contains(e.target) && !this.easingTrigger.contains(e.target)) {
|
|
3786
|
+
this.closeEasingMenu();
|
|
3787
|
+
}
|
|
3788
|
+
};
|
|
3789
|
+
this.boundDocKeydown = (e) => {
|
|
3790
|
+
if (e.key === "Escape") {
|
|
3791
|
+
e.stopPropagation();
|
|
3792
|
+
this.closeEasingMenu();
|
|
3793
|
+
this.easingTrigger.focus();
|
|
3794
|
+
} else if (e.key === "Tab") {
|
|
3795
|
+
this.closeEasingMenu();
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3798
|
+
document.addEventListener("click", this.boundOutsideClick, true);
|
|
3799
|
+
document.addEventListener("keydown", this.boundDocKeydown);
|
|
2226
3800
|
});
|
|
2227
3801
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
3802
|
+
closeEasingMenu() {
|
|
3803
|
+
if (!this.easingOpen) return;
|
|
3804
|
+
this.easingOpen = false;
|
|
3805
|
+
this.easingMenu.style.display = "none";
|
|
3806
|
+
this.easingTrigger.setAttribute("aria-expanded", "false");
|
|
3807
|
+
this.easingTrigger.classList.remove(
|
|
3808
|
+
"aicut-keyframe-panel__dropdown-trigger--open"
|
|
3809
|
+
);
|
|
3810
|
+
if (this.boundOutsideClick) {
|
|
3811
|
+
document.removeEventListener("click", this.boundOutsideClick, true);
|
|
3812
|
+
this.boundOutsideClick = null;
|
|
3813
|
+
}
|
|
3814
|
+
if (this.boundDocKeydown) {
|
|
3815
|
+
document.removeEventListener("keydown", this.boundDocKeydown);
|
|
3816
|
+
this.boundDocKeydown = null;
|
|
3817
|
+
}
|
|
2230
3818
|
}
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
3819
|
+
selectEasing(value) {
|
|
3820
|
+
this.closeEasingMenu();
|
|
3821
|
+
this.easingTrigger.focus();
|
|
3822
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3823
|
+
if (!sel) return;
|
|
3824
|
+
const clip = this.findClip(sel.clipId);
|
|
3825
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3826
|
+
if (!clip || !anchorKf) return;
|
|
3827
|
+
this.editor.setKeyframesEasingAtTime(sel.clipId, anchorKf.time, value);
|
|
3828
|
+
}
|
|
3829
|
+
onMenuKeydown(e, value) {
|
|
3830
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
3831
|
+
e.preventDefault();
|
|
3832
|
+
this.selectEasing(value);
|
|
3833
|
+
return;
|
|
2235
3834
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
const d = Math.abs(t - ms);
|
|
2242
|
-
if (d < bestDist) {
|
|
2243
|
-
bestDist = d;
|
|
2244
|
-
best = t;
|
|
2245
|
-
}
|
|
3835
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
3836
|
+
e.preventDefault();
|
|
3837
|
+
const idx = EASING_VALUES.indexOf(value);
|
|
3838
|
+
const next = e.key === "ArrowDown" ? EASING_VALUES[(idx + 1) % EASING_VALUES.length] : EASING_VALUES[(idx - 1 + EASING_VALUES.length) % EASING_VALUES.length];
|
|
3839
|
+
this.easingItems.get(next)?.focus();
|
|
2246
3840
|
}
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
3841
|
+
}
|
|
3842
|
+
onReset() {
|
|
3843
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3844
|
+
if (!sel) return;
|
|
3845
|
+
const clip = this.findClip(sel.clipId);
|
|
3846
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3847
|
+
if (!clip || !anchorKf) return;
|
|
3848
|
+
this.editor.resetKeyframesAtTime(sel.clipId, anchorKf.time);
|
|
3849
|
+
}
|
|
3850
|
+
setIfBlur(input, value) {
|
|
3851
|
+
if (document.activeElement === input) return;
|
|
3852
|
+
if (input.value !== value) input.value = value;
|
|
3853
|
+
}
|
|
3854
|
+
findClip(clipId) {
|
|
3855
|
+
const project = this.editor.getProject();
|
|
3856
|
+
for (const t of project.tracks) {
|
|
3857
|
+
const c = t.clips.find((cl) => cl.id === clipId);
|
|
3858
|
+
if (c) return c;
|
|
2252
3859
|
}
|
|
2253
|
-
return
|
|
3860
|
+
return null;
|
|
2254
3861
|
}
|
|
2255
3862
|
};
|
|
2256
3863
|
|
|
@@ -2297,6 +3904,24 @@ var ICONS = {
|
|
|
2297
3904
|
trash: wrap(
|
|
2298
3905
|
`<g transform="translate(1 1)" fill="currentColor"><path d="M5 1.25h4v.9H13v1.05H1V2.15h4v-.9zM2.4 4.1h9.2l-.65 8.9c-.04.55-.5.96-1.05.96H4.1c-.55 0-1.01-.41-1.05-.96L2.4 4.1zm2.3 1.7l.35 7.1h1l-.35-7.1h-1zm2.65 0v7.1h1V5.8h-1zm2.3 0l-.35 7.1h1l.35-7.1h-1z"/></g>`
|
|
2299
3906
|
),
|
|
3907
|
+
/** "Skip to start" — vertical bar + left-pointing triangle. Sits to
|
|
3908
|
+
* the left of the keyframe diamond so the clip-edge nav cluster
|
|
3909
|
+
* reads as [|◀ ◇ ▶|] = "go to clip start / add kf / go to clip end". */
|
|
3910
|
+
seekClipStart: wrap(
|
|
3911
|
+
`<g transform="translate(2 3)" fill="currentColor"><rect x="0" y="0" width="1.6" height="10" rx="0.4"/><path d="M11 0.6c0-0.5-0.55-0.78-0.95-0.48l-6.5 4.4c-0.34 0.23-0.34 0.73 0 0.96l6.5 4.4c0.4 0.3 0.95 0.02 0.95-0.48z"/></g>`
|
|
3912
|
+
),
|
|
3913
|
+
/** "Skip to end" — mirror of seekClipStart. */
|
|
3914
|
+
seekClipEnd: wrap(
|
|
3915
|
+
`<g transform="translate(1 3)" fill="currentColor"><path d="M0 0.6c0-0.5 0.55-0.78 0.95-0.48l6.5 4.4c0.34 0.23 0.34 0.73 0 0.96l-6.5 4.4c-0.4 0.3-0.95 0.02-0.95-0.48z"/><rect x="10.4" y="0" width="1.6" height="10" rx="0.4"/></g>`
|
|
3916
|
+
),
|
|
3917
|
+
/** Outlined diamond (rotated square) — "add keyframe" affordance. */
|
|
3918
|
+
keyframeOutline: wrap(
|
|
3919
|
+
`<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="0.5" y="0.5" width="7" height="7" rx="0.5"/></g>`
|
|
3920
|
+
),
|
|
3921
|
+
/** Filled diamond — shown when a keyframe already exists at playhead. */
|
|
3922
|
+
keyframeFilled: wrap(
|
|
3923
|
+
`<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="currentColor"><rect x="0" y="0" width="8" height="8" rx="0.8"/></g>`
|
|
3924
|
+
),
|
|
2300
3925
|
/** Counter-clockwise circular arrow — "reset to initial layout". */
|
|
2301
3926
|
reset: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`
|
|
2302
3927
|
};
|
|
@@ -2323,6 +3948,9 @@ var Toolbar = class {
|
|
|
2323
3948
|
splitBtn;
|
|
2324
3949
|
trimLeftBtn;
|
|
2325
3950
|
trimRightBtn;
|
|
3951
|
+
seekClipStartBtn;
|
|
3952
|
+
seekClipEndBtn;
|
|
3953
|
+
keyframeBtn;
|
|
2326
3954
|
playBtn;
|
|
2327
3955
|
playIcon;
|
|
2328
3956
|
timeLabel;
|
|
@@ -2348,9 +3976,37 @@ var Toolbar = class {
|
|
|
2348
3976
|
this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
|
|
2349
3977
|
this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
|
|
2350
3978
|
this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
3979
|
+
this.seekClipStartBtn = mkIconButton(
|
|
3980
|
+
"seekClipStart",
|
|
3981
|
+
locale.seekClipStart,
|
|
3982
|
+
() => cb.onSeekClipStart(),
|
|
3983
|
+
"aicut-seek-clip-start"
|
|
3984
|
+
);
|
|
3985
|
+
this.seekClipStartBtn.style.display = "none";
|
|
3986
|
+
this.keyframeBtn = mkIconButton(
|
|
3987
|
+
"keyframeOutline",
|
|
3988
|
+
locale.keyframeAdd,
|
|
3989
|
+
() => cb.onKeyframeToggle(),
|
|
3990
|
+
"aicut-keyframe"
|
|
3991
|
+
);
|
|
3992
|
+
this.keyframeBtn.style.display = "none";
|
|
3993
|
+
this.seekClipEndBtn = mkIconButton(
|
|
3994
|
+
"seekClipEnd",
|
|
3995
|
+
locale.seekClipEnd,
|
|
3996
|
+
() => cb.onSeekClipEnd(),
|
|
3997
|
+
"aicut-seek-clip-end"
|
|
3998
|
+
);
|
|
3999
|
+
this.seekClipEndBtn.style.display = "none";
|
|
4000
|
+
left.append(
|
|
4001
|
+
this.undoBtn,
|
|
4002
|
+
this.redoBtn,
|
|
4003
|
+
this.splitBtn,
|
|
4004
|
+
this.trimLeftBtn,
|
|
4005
|
+
this.trimRightBtn,
|
|
4006
|
+
this.seekClipStartBtn,
|
|
4007
|
+
this.keyframeBtn,
|
|
4008
|
+
this.seekClipEndBtn
|
|
4009
|
+
);
|
|
2354
4010
|
const center = mkGroup("aicut-toolbar-center");
|
|
2355
4011
|
this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
|
|
2356
4012
|
this.playBtn = document.createElement("button");
|
|
@@ -2427,11 +4083,38 @@ var Toolbar = class {
|
|
|
2427
4083
|
this.trimLeftBtn.disabled = !state.canTrim;
|
|
2428
4084
|
this.trimRightBtn.disabled = !state.canTrim;
|
|
2429
4085
|
}
|
|
4086
|
+
if (!this.lastState || this.lastState.clipEdgeNavEnabled !== state.clipEdgeNavEnabled) {
|
|
4087
|
+
const display = state.clipEdgeNavEnabled ? "" : "none";
|
|
4088
|
+
this.seekClipStartBtn.style.display = display;
|
|
4089
|
+
this.seekClipEndBtn.style.display = display;
|
|
4090
|
+
}
|
|
4091
|
+
if (!this.lastState || this.lastState.canSeekClipEdge !== state.canSeekClipEdge) {
|
|
4092
|
+
this.seekClipStartBtn.disabled = !state.canSeekClipEdge;
|
|
4093
|
+
this.seekClipEndBtn.disabled = !state.canSeekClipEdge;
|
|
4094
|
+
}
|
|
2430
4095
|
if (!this.lastState || this.lastState.snap !== state.snap) {
|
|
2431
4096
|
this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
|
|
2432
4097
|
this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
|
|
2433
4098
|
this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
|
|
2434
4099
|
}
|
|
4100
|
+
if (!this.lastState || this.lastState.keyframesEnabled !== state.keyframesEnabled) {
|
|
4101
|
+
this.keyframeBtn.style.display = state.keyframesEnabled ? "" : "none";
|
|
4102
|
+
}
|
|
4103
|
+
if (state.keyframesEnabled) {
|
|
4104
|
+
if (!this.lastState || this.lastState.hasKeyframeAtPlayhead !== state.hasKeyframeAtPlayhead) {
|
|
4105
|
+
this.keyframeBtn.innerHTML = state.hasKeyframeAtPlayhead ? ICONS.keyframeFilled : ICONS.keyframeOutline;
|
|
4106
|
+
const title = state.hasKeyframeAtPlayhead ? this.locale.keyframeRemove : this.locale.keyframeAdd;
|
|
4107
|
+
this.keyframeBtn.title = title;
|
|
4108
|
+
this.keyframeBtn.setAttribute("aria-label", title);
|
|
4109
|
+
this.keyframeBtn.setAttribute(
|
|
4110
|
+
"data-state",
|
|
4111
|
+
state.hasKeyframeAtPlayhead ? "on" : "off"
|
|
4112
|
+
);
|
|
4113
|
+
}
|
|
4114
|
+
if (!this.lastState || this.lastState.canKeyframe !== state.canKeyframe) {
|
|
4115
|
+
this.keyframeBtn.disabled = !state.canKeyframe;
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
2435
4118
|
if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
|
|
2436
4119
|
const ratio = scaleToSlider(state.pxPerSec);
|
|
2437
4120
|
const nextVal = String(Math.round(ratio * 100));
|
|
@@ -2464,12 +4147,20 @@ var Toolbar = class {
|
|
|
2464
4147
|
applyTitle(this.splitBtn, locale.split);
|
|
2465
4148
|
applyTitle(this.trimLeftBtn, locale.trimLeft);
|
|
2466
4149
|
applyTitle(this.trimRightBtn, locale.trimRight);
|
|
4150
|
+
applyTitle(this.seekClipStartBtn, locale.seekClipStart);
|
|
4151
|
+
applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
|
|
2467
4152
|
applyTitle(this.playBtn, locale.playPause);
|
|
2468
4153
|
applyTitle(this.fullscreenBtn, locale.fullscreen);
|
|
2469
4154
|
applyTitle(this.snapBtn, locale.snap);
|
|
2470
4155
|
applyTitle(this.zoomOutBtn, locale.zoomOut);
|
|
2471
4156
|
applyTitle(this.zoomInBtn, locale.zoomIn);
|
|
2472
4157
|
applyTitle(this.resetBtn, locale.reset);
|
|
4158
|
+
if (this.keyframeBtn) {
|
|
4159
|
+
const hasKf = this.lastState?.hasKeyframeAtPlayhead === true;
|
|
4160
|
+
const t = hasKf ? locale.keyframeRemove : locale.keyframeAdd;
|
|
4161
|
+
this.keyframeBtn.title = t;
|
|
4162
|
+
this.keyframeBtn.setAttribute("aria-label", t);
|
|
4163
|
+
}
|
|
2473
4164
|
if (this.lastState) {
|
|
2474
4165
|
this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
|
|
2475
4166
|
}
|
|
@@ -2529,6 +4220,8 @@ var EditorUI = class {
|
|
|
2529
4220
|
toolbar;
|
|
2530
4221
|
timelineHost;
|
|
2531
4222
|
timeline;
|
|
4223
|
+
keyframePanel;
|
|
4224
|
+
keyframeOverlay;
|
|
2532
4225
|
fullscreen = false;
|
|
2533
4226
|
onDocKeydown = null;
|
|
2534
4227
|
constructor(root, editor, cb) {
|
|
@@ -2575,10 +4268,14 @@ var EditorUI = class {
|
|
|
2575
4268
|
snap: editor.getSnap(),
|
|
2576
4269
|
autoFit: true,
|
|
2577
4270
|
locale,
|
|
4271
|
+
keyframesEnabled: editor.isKeyframesEnabled(),
|
|
4272
|
+
selectedKeyframe: editor.getSelectedKeyframe(),
|
|
2578
4273
|
onSeek: cb.onSeek,
|
|
2579
4274
|
onSelectClip: cb.onSelectClip,
|
|
2580
4275
|
onMoveClip: cb.onMoveClip,
|
|
2581
4276
|
onResizeClip: cb.onResizeClip,
|
|
4277
|
+
onSelectKeyframe: cb.onSelectKeyframe,
|
|
4278
|
+
onMoveKeyframe: cb.onMoveKeyframe,
|
|
2582
4279
|
onScaleChange: cb.onScaleChange,
|
|
2583
4280
|
onDeleteTrack: (trackId) => editor.removeTrack(trackId),
|
|
2584
4281
|
// Mirror the editor's smart routing into the drag preview so
|
|
@@ -2603,6 +4300,8 @@ var EditorUI = class {
|
|
|
2603
4300
|
};
|
|
2604
4301
|
}
|
|
2605
4302
|
});
|
|
4303
|
+
this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
|
|
4304
|
+
this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
|
|
2606
4305
|
this.attachKeyboard(cb);
|
|
2607
4306
|
}
|
|
2608
4307
|
// ---- fullscreen -----------------------------------------------------
|
|
@@ -2652,6 +4351,13 @@ var EditorUI = class {
|
|
|
2652
4351
|
const selectedClipId = this.editor.getSelection();
|
|
2653
4352
|
const pxPerSec = this.editor.getScale();
|
|
2654
4353
|
const snap = this.editor.getSnap();
|
|
4354
|
+
const kfEnabled = this.editor.isKeyframesEnabled();
|
|
4355
|
+
const kfState = this.computeKeyframeToolbarState(
|
|
4356
|
+
project,
|
|
4357
|
+
selectedClipId,
|
|
4358
|
+
time,
|
|
4359
|
+
kfEnabled
|
|
4360
|
+
);
|
|
2655
4361
|
this.toolbar.render({
|
|
2656
4362
|
playing: this.editor.isPlaying(),
|
|
2657
4363
|
time,
|
|
@@ -2660,18 +4366,34 @@ var EditorUI = class {
|
|
|
2660
4366
|
canRedo: this.editor.canRedo(),
|
|
2661
4367
|
canSplit: this.canSplitAt(time),
|
|
2662
4368
|
canTrim: this.canTrimAt(time, selectedClipId),
|
|
4369
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4370
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
2663
4371
|
snap,
|
|
2664
|
-
pxPerSec
|
|
4372
|
+
pxPerSec,
|
|
4373
|
+
...kfState
|
|
2665
4374
|
});
|
|
2666
4375
|
this.timeline.setProject(project);
|
|
2667
4376
|
this.timeline.setTime(time);
|
|
2668
4377
|
this.timeline.setScale(pxPerSec);
|
|
2669
4378
|
this.timeline.setSelection(selectedClipId);
|
|
2670
4379
|
this.timeline.setSnap(snap);
|
|
4380
|
+
this.timeline.setKeyframeState({
|
|
4381
|
+
enabled: this.editor.isKeyframesEnabled(),
|
|
4382
|
+
selected: this.editor.getSelectedKeyframe()
|
|
4383
|
+
});
|
|
4384
|
+
this.keyframePanel.render();
|
|
2671
4385
|
}
|
|
2672
4386
|
/** Playback-fast path: nudge playhead + toolbar time label only. */
|
|
2673
4387
|
onTimeTick(timeMs) {
|
|
2674
4388
|
this.timeline.setTime(timeMs);
|
|
4389
|
+
const selectedClipId = this.editor.getSelection();
|
|
4390
|
+
const kfEnabled = this.editor.isKeyframesEnabled();
|
|
4391
|
+
const kfState = this.computeKeyframeToolbarState(
|
|
4392
|
+
this.editor.getProject(),
|
|
4393
|
+
selectedClipId,
|
|
4394
|
+
timeMs,
|
|
4395
|
+
kfEnabled
|
|
4396
|
+
);
|
|
2675
4397
|
this.toolbar.render({
|
|
2676
4398
|
playing: this.editor.isPlaying(),
|
|
2677
4399
|
time: timeMs,
|
|
@@ -2679,9 +4401,12 @@ var EditorUI = class {
|
|
|
2679
4401
|
canUndo: this.editor.canUndo(),
|
|
2680
4402
|
canRedo: this.editor.canRedo(),
|
|
2681
4403
|
canSplit: this.canSplitAt(timeMs),
|
|
2682
|
-
canTrim: this.canTrimAt(timeMs,
|
|
4404
|
+
canTrim: this.canTrimAt(timeMs, selectedClipId),
|
|
4405
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4406
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
2683
4407
|
snap: this.editor.getSnap(),
|
|
2684
|
-
pxPerSec: this.editor.getScale()
|
|
4408
|
+
pxPerSec: this.editor.getScale(),
|
|
4409
|
+
...kfState
|
|
2685
4410
|
});
|
|
2686
4411
|
}
|
|
2687
4412
|
/** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
|
|
@@ -2693,6 +4418,7 @@ var EditorUI = class {
|
|
|
2693
4418
|
this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
|
|
2694
4419
|
this.fullscreenExitBtn.textContent = locale.exitFullscreen;
|
|
2695
4420
|
this.timeline.setLocale(locale);
|
|
4421
|
+
this.keyframePanel.setLocale(locale);
|
|
2696
4422
|
this.render();
|
|
2697
4423
|
}
|
|
2698
4424
|
destroy() {
|
|
@@ -2702,10 +4428,56 @@ var EditorUI = class {
|
|
|
2702
4428
|
}
|
|
2703
4429
|
this.toolbar.destroy();
|
|
2704
4430
|
this.timeline.destroy();
|
|
4431
|
+
this.keyframePanel.destroy();
|
|
4432
|
+
this.keyframeOverlay.destroy();
|
|
2705
4433
|
this.root.innerHTML = "";
|
|
2706
4434
|
this.root.classList.remove("aicut-root", "aicut-fullscreen");
|
|
2707
4435
|
}
|
|
2708
4436
|
// ---- helpers --------------------------------------------------------
|
|
4437
|
+
/** Walk the selected clip + playhead state to figure out (a) whether
|
|
4438
|
+
* the keyframe button should be enabled, and (b) whether a keyframe
|
|
4439
|
+
* already exists at the playhead's clip-local time (so the button
|
|
4440
|
+
* swaps to "remove" mode). */
|
|
4441
|
+
computeKeyframeToolbarState(project, selectedClipId, time, keyframesEnabled) {
|
|
4442
|
+
if (!keyframesEnabled || !selectedClipId) {
|
|
4443
|
+
return {
|
|
4444
|
+
canKeyframe: false,
|
|
4445
|
+
hasKeyframeAtPlayhead: false,
|
|
4446
|
+
keyframesEnabled
|
|
4447
|
+
};
|
|
4448
|
+
}
|
|
4449
|
+
let clip = null;
|
|
4450
|
+
for (const t of project.tracks) {
|
|
4451
|
+
const c = t.clips.find((cl) => cl.id === selectedClipId);
|
|
4452
|
+
if (c) {
|
|
4453
|
+
clip = c;
|
|
4454
|
+
break;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
if (!clip) {
|
|
4458
|
+
return {
|
|
4459
|
+
canKeyframe: false,
|
|
4460
|
+
hasKeyframeAtPlayhead: false,
|
|
4461
|
+
keyframesEnabled
|
|
4462
|
+
};
|
|
4463
|
+
}
|
|
4464
|
+
const localMs = time - clip.start;
|
|
4465
|
+
const duration = clipDuration(clip);
|
|
4466
|
+
if (localMs < 0 || localMs > duration) {
|
|
4467
|
+
return {
|
|
4468
|
+
canKeyframe: false,
|
|
4469
|
+
hasKeyframeAtPlayhead: false,
|
|
4470
|
+
keyframesEnabled
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
4473
|
+
const roundedLocal = Math.round(localMs);
|
|
4474
|
+
const hasKf = clip.keyframes?.some((k) => k.time === roundedLocal) ?? false;
|
|
4475
|
+
return {
|
|
4476
|
+
canKeyframe: true,
|
|
4477
|
+
hasKeyframeAtPlayhead: hasKf,
|
|
4478
|
+
keyframesEnabled
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
2709
4481
|
canSplitAt(timeMs) {
|
|
2710
4482
|
const project = this.editor.getProject();
|
|
2711
4483
|
for (const t of project.tracks) {
|
|
@@ -2747,6 +4519,22 @@ var EditorUI = class {
|
|
|
2747
4519
|
} else if (e.code === "KeyW") {
|
|
2748
4520
|
e.preventDefault();
|
|
2749
4521
|
cb.onTrimRight();
|
|
4522
|
+
} else if (e.code === "KeyI" && this.editor.isClipEdgeNavEnabled()) {
|
|
4523
|
+
e.preventDefault();
|
|
4524
|
+
cb.onSeekClipStart();
|
|
4525
|
+
} else if (e.code === "KeyO" && this.editor.isClipEdgeNavEnabled()) {
|
|
4526
|
+
e.preventDefault();
|
|
4527
|
+
cb.onSeekClipEnd();
|
|
4528
|
+
} else if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
|
|
4529
|
+
e.preventDefault();
|
|
4530
|
+
const project = this.editor.getProject();
|
|
4531
|
+
const step = e.shiftKey ? bigFrameStepMs(project) : frameStepMs(project);
|
|
4532
|
+
const dir = e.code === "ArrowLeft" ? -1 : 1;
|
|
4533
|
+
const next = Math.max(
|
|
4534
|
+
0,
|
|
4535
|
+
Math.min(this.editor.getDuration(), this.editor.getTime() + dir * step)
|
|
4536
|
+
);
|
|
4537
|
+
cb.onSeek(next);
|
|
2750
4538
|
} else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
|
|
2751
4539
|
e.preventDefault();
|
|
2752
4540
|
if (e.shiftKey) cb.onRedo();
|
|
@@ -2775,6 +4563,13 @@ var Editor = class _Editor {
|
|
|
2775
4563
|
bus = new EventBus();
|
|
2776
4564
|
history = new HistoryStack();
|
|
2777
4565
|
selectedClipId = null;
|
|
4566
|
+
selectedKeyframe = null;
|
|
4567
|
+
keyframesEnabled;
|
|
4568
|
+
clipEdgeNavEnabled;
|
|
4569
|
+
/** Drag-session bookkeeping for ripple-merge undo. See
|
|
4570
|
+
* beginInteraction / endInteraction docs on EditorApi. */
|
|
4571
|
+
interactionDepth = 0;
|
|
4572
|
+
interactionStartSnapshot = null;
|
|
2778
4573
|
pxPerSec;
|
|
2779
4574
|
snap;
|
|
2780
4575
|
locale;
|
|
@@ -2785,7 +4580,21 @@ var Editor = class _Editor {
|
|
|
2785
4580
|
this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
|
|
2786
4581
|
this.snap = opts.initialSnap !== false;
|
|
2787
4582
|
this.locale = mergeLocale(opts.locale);
|
|
4583
|
+
this.keyframesEnabled = opts.keyframes?.enabled === true;
|
|
4584
|
+
this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
|
|
4585
|
+
if (opts.trackHeight != null || opts.rulerHeight != null) {
|
|
4586
|
+
setTimelineMetrics({
|
|
4587
|
+
...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
|
|
4588
|
+
...opts.rulerHeight != null ? { rulerHeight: opts.rulerHeight } : {}
|
|
4589
|
+
});
|
|
4590
|
+
}
|
|
2788
4591
|
applyTheme(this.container, opts.theme);
|
|
4592
|
+
if (opts.timelineHeight != null && opts.timelineHeight > 0) {
|
|
4593
|
+
this.container.style.setProperty(
|
|
4594
|
+
"--aicut-timeline-height",
|
|
4595
|
+
`${Math.round(opts.timelineHeight)}px`
|
|
4596
|
+
);
|
|
4597
|
+
}
|
|
2789
4598
|
this.ui = new EditorUI(this.container, this, {
|
|
2790
4599
|
onPlayToggle: () => this.togglePlay(),
|
|
2791
4600
|
onSplit: () => this.split(),
|
|
@@ -2801,9 +4610,18 @@ var Editor = class _Editor {
|
|
|
2801
4610
|
onSelectClip: (id) => this.setSelection(id),
|
|
2802
4611
|
onDeleteClip: (id) => this.removeClip(id),
|
|
2803
4612
|
onMoveClip: (id, opts2) => this.moveClip(id, opts2),
|
|
2804
|
-
onResizeClip: (id, edits) => this.resizeClip(id, edits)
|
|
4613
|
+
onResizeClip: (id, edits) => this.resizeClip(id, edits),
|
|
4614
|
+
onSelectKeyframe: (target) => this.setSelectedKeyframe(target),
|
|
4615
|
+
onMoveKeyframe: (clipId, keyframeId, timeMs) => this.moveKeyframe(clipId, keyframeId, timeMs),
|
|
4616
|
+
onKeyframeToggle: () => this.toggleKeyframeAtPlayhead(),
|
|
4617
|
+
onSeekClipStart: () => this.seekToSelectedClipEdge("start"),
|
|
4618
|
+
onSeekClipEnd: () => this.seekToSelectedClipEdge("end")
|
|
4619
|
+
});
|
|
4620
|
+
const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
|
|
4621
|
+
this.engine = engineFactory({
|
|
4622
|
+
host: this.ui.previewHost,
|
|
4623
|
+
project: this.project
|
|
2805
4624
|
});
|
|
2806
|
-
this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
|
|
2807
4625
|
this.engine.onTimeUpdate = (ms) => {
|
|
2808
4626
|
this.bus.emit("time", { timeMs: ms });
|
|
2809
4627
|
this.ui.onTimeTick(ms);
|
|
@@ -3221,6 +5039,9 @@ var Editor = class _Editor {
|
|
|
3221
5039
|
for (const c of t.clips) {
|
|
3222
5040
|
if (c.id === ignoreClipId) continue;
|
|
3223
5041
|
targets.push(c.start, clipEnd(c));
|
|
5042
|
+
if (c.keyframes) {
|
|
5043
|
+
for (const kf of c.keyframes) targets.push(c.start + kf.time);
|
|
5044
|
+
}
|
|
3224
5045
|
}
|
|
3225
5046
|
}
|
|
3226
5047
|
let best = timeMs;
|
|
@@ -3242,6 +5063,304 @@ var Editor = class _Editor {
|
|
|
3242
5063
|
if (clipId === this.selectedClipId) return;
|
|
3243
5064
|
this.selectedClipId = clipId;
|
|
3244
5065
|
this.bus.emit("selectionChange", { clipId });
|
|
5066
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId !== clipId) {
|
|
5067
|
+
this.selectedKeyframe = null;
|
|
5068
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5069
|
+
}
|
|
5070
|
+
this.ui.render();
|
|
5071
|
+
}
|
|
5072
|
+
// ---- keyframes ------------------------------------------------------
|
|
5073
|
+
isKeyframesEnabled() {
|
|
5074
|
+
return this.keyframesEnabled;
|
|
5075
|
+
}
|
|
5076
|
+
/**
|
|
5077
|
+
* Screen-space CSS-pixel rect of the actively painted frame
|
|
5078
|
+
* (post-transform), relative to the editor's preview element.
|
|
5079
|
+
* Null when no clip is active, the engine doesn't expose
|
|
5080
|
+
* `getFrameRect`, or the rect isn't computed yet. Used by the
|
|
5081
|
+
* library's keyframe-editing overlay.
|
|
5082
|
+
*/
|
|
5083
|
+
getActiveFrameRect() {
|
|
5084
|
+
return this.engine.getFrameRect?.() ?? null;
|
|
5085
|
+
}
|
|
5086
|
+
/**
|
|
5087
|
+
* Screen-space CSS-pixel rect of the OUTPUT FRAME (the fixed
|
|
5088
|
+
* stage that clips the rendered video). Different from
|
|
5089
|
+
* `getActiveFrameRect` which includes the keyframe transform —
|
|
5090
|
+
* this one stays put as the user drags / scales the content.
|
|
5091
|
+
* Used by the overlay to anchor the dashed border + drag body.
|
|
5092
|
+
*/
|
|
5093
|
+
getActiveOutputFrameRect() {
|
|
5094
|
+
return this.engine.getOutputFrameRect?.() ?? null;
|
|
5095
|
+
}
|
|
5096
|
+
setKeyframesEnabled(enabled) {
|
|
5097
|
+
if (enabled === this.keyframesEnabled) return;
|
|
5098
|
+
this.keyframesEnabled = enabled;
|
|
5099
|
+
if (!enabled && this.selectedKeyframe) {
|
|
5100
|
+
this.selectedKeyframe = null;
|
|
5101
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5102
|
+
}
|
|
5103
|
+
this.bus.emit("keyframesEnabledChange", { enabled });
|
|
5104
|
+
this.ui.render();
|
|
5105
|
+
}
|
|
5106
|
+
isClipEdgeNavEnabled() {
|
|
5107
|
+
return this.clipEdgeNavEnabled;
|
|
5108
|
+
}
|
|
5109
|
+
setClipEdgeNavEnabled(enabled) {
|
|
5110
|
+
if (enabled === this.clipEdgeNavEnabled) return;
|
|
5111
|
+
this.clipEdgeNavEnabled = enabled;
|
|
5112
|
+
this.bus.emit("clipEdgeNavEnabledChange", { enabled });
|
|
5113
|
+
this.ui.render();
|
|
5114
|
+
}
|
|
5115
|
+
addKeyframe(clipId, prop, opts = {}) {
|
|
5116
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5117
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5118
|
+
if (!trk || !cl) return null;
|
|
5119
|
+
const duration = clipDuration(cl);
|
|
5120
|
+
const playheadLocal = this.engine.getTime() - cl.start;
|
|
5121
|
+
const rawTime = opts.time ?? playheadLocal;
|
|
5122
|
+
const time = Math.max(0, Math.min(duration, Math.round(rawTime)));
|
|
5123
|
+
const value = opts.value ?? interpolateProp(cl, prop, time);
|
|
5124
|
+
this.pushHistory();
|
|
5125
|
+
cl.keyframes = upsertKeyframe(
|
|
5126
|
+
cl.keyframes,
|
|
5127
|
+
prop,
|
|
5128
|
+
time,
|
|
5129
|
+
value,
|
|
5130
|
+
() => createId("kf")
|
|
5131
|
+
);
|
|
5132
|
+
cl.keyframes.sort((a, b) => {
|
|
5133
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5134
|
+
return a.time - b.time;
|
|
5135
|
+
});
|
|
5136
|
+
this.afterMutation();
|
|
5137
|
+
const created = cl.keyframes.find(
|
|
5138
|
+
(k) => k.prop === prop && Math.abs(k.time - time) < 16
|
|
5139
|
+
);
|
|
5140
|
+
return created?.id ?? null;
|
|
5141
|
+
}
|
|
5142
|
+
removeKeyframe(clipId, keyframeId) {
|
|
5143
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5144
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5145
|
+
if (!trk || !cl || !cl.keyframes) return false;
|
|
5146
|
+
const idx = cl.keyframes.findIndex((k) => k.id === keyframeId);
|
|
5147
|
+
if (idx < 0) return false;
|
|
5148
|
+
this.pushHistory();
|
|
5149
|
+
const next = cl.keyframes.slice();
|
|
5150
|
+
next.splice(idx, 1);
|
|
5151
|
+
cl.keyframes = next.length > 0 ? next : void 0;
|
|
5152
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId && this.selectedKeyframe.keyframeId === keyframeId) {
|
|
5153
|
+
this.selectedKeyframe = null;
|
|
5154
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5155
|
+
}
|
|
5156
|
+
this.afterMutation();
|
|
5157
|
+
return true;
|
|
5158
|
+
}
|
|
5159
|
+
moveKeyframe(clipId, keyframeId, timeMs) {
|
|
5160
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5161
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5162
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
5163
|
+
if (!trk || !cl || !kf || !cl.keyframes) return false;
|
|
5164
|
+
const duration = clipDuration(cl);
|
|
5165
|
+
const clamped = Math.max(0, Math.min(duration, Math.round(timeMs)));
|
|
5166
|
+
if (clamped === kf.time) return false;
|
|
5167
|
+
if (cl.keyframes.some(
|
|
5168
|
+
(k) => k.id !== keyframeId && k.prop === kf.prop && k.time === clamped
|
|
5169
|
+
)) {
|
|
5170
|
+
return false;
|
|
5171
|
+
}
|
|
5172
|
+
this.pushHistory();
|
|
5173
|
+
kf.time = clamped;
|
|
5174
|
+
cl.keyframes.sort((a, b) => {
|
|
5175
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5176
|
+
return a.time - b.time;
|
|
5177
|
+
});
|
|
5178
|
+
this.afterMutation();
|
|
5179
|
+
return true;
|
|
5180
|
+
}
|
|
5181
|
+
setKeyframeValue(clipId, keyframeId, value) {
|
|
5182
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5183
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5184
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
5185
|
+
if (!trk || !cl || !kf) return false;
|
|
5186
|
+
if (Math.abs(kf.value - value) < 1e-9) return false;
|
|
5187
|
+
this.pushHistory();
|
|
5188
|
+
kf.value = value;
|
|
5189
|
+
this.afterMutation();
|
|
5190
|
+
return true;
|
|
5191
|
+
}
|
|
5192
|
+
setKeyframeEasing(clipId, keyframeId, easing) {
|
|
5193
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5194
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5195
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
5196
|
+
if (!trk || !cl || !kf) return false;
|
|
5197
|
+
const current = kf.easing ?? "linear";
|
|
5198
|
+
if (current === easing) return false;
|
|
5199
|
+
this.pushHistory();
|
|
5200
|
+
if (easing === "linear") {
|
|
5201
|
+
delete kf.easing;
|
|
5202
|
+
} else {
|
|
5203
|
+
kf.easing = easing;
|
|
5204
|
+
}
|
|
5205
|
+
this.afterMutation();
|
|
5206
|
+
return true;
|
|
5207
|
+
}
|
|
5208
|
+
setKeyframesEasingAtTime(clipId, timeMs, easing) {
|
|
5209
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5210
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5211
|
+
if (!trk || !cl || !cl.keyframes) return false;
|
|
5212
|
+
const t = Math.round(timeMs);
|
|
5213
|
+
const matches = cl.keyframes.filter((k) => Math.abs(k.time - t) < 16);
|
|
5214
|
+
if (matches.length === 0) return false;
|
|
5215
|
+
const anyChange = matches.some((k) => (k.easing ?? "linear") !== easing);
|
|
5216
|
+
if (!anyChange) return false;
|
|
5217
|
+
this.pushHistory();
|
|
5218
|
+
for (const kf of matches) {
|
|
5219
|
+
if (easing === "linear") delete kf.easing;
|
|
5220
|
+
else kf.easing = easing;
|
|
5221
|
+
}
|
|
5222
|
+
this.afterMutation();
|
|
5223
|
+
return true;
|
|
5224
|
+
}
|
|
5225
|
+
setValueAtPlayhead(clipId, prop, value) {
|
|
5226
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5227
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5228
|
+
if (!trk || !cl) return false;
|
|
5229
|
+
const duration = clipDuration(cl);
|
|
5230
|
+
const playheadLocal = this.engine.getTime() - cl.start;
|
|
5231
|
+
const time = Math.max(0, Math.min(duration, Math.round(playheadLocal)));
|
|
5232
|
+
const hasKf = cl.keyframes?.some((k) => k.prop === prop) ?? false;
|
|
5233
|
+
if (hasKf) {
|
|
5234
|
+
this.pushHistory();
|
|
5235
|
+
cl.keyframes = upsertKeyframe(
|
|
5236
|
+
cl.keyframes,
|
|
5237
|
+
prop,
|
|
5238
|
+
time,
|
|
5239
|
+
value,
|
|
5240
|
+
() => createId("kf")
|
|
5241
|
+
);
|
|
5242
|
+
cl.keyframes.sort((a, b) => {
|
|
5243
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5244
|
+
return a.time - b.time;
|
|
5245
|
+
});
|
|
5246
|
+
this.afterMutation();
|
|
5247
|
+
return true;
|
|
5248
|
+
}
|
|
5249
|
+
if ((cl[prop] ?? (prop === "scale" ? 1 : 0)) === value) return false;
|
|
5250
|
+
this.pushHistory();
|
|
5251
|
+
cl[prop] = value;
|
|
5252
|
+
this.afterMutation();
|
|
5253
|
+
return true;
|
|
5254
|
+
}
|
|
5255
|
+
getSelectedKeyframe() {
|
|
5256
|
+
return this.selectedKeyframe;
|
|
5257
|
+
}
|
|
5258
|
+
resetClipTransform(clipId) {
|
|
5259
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5260
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5261
|
+
if (!trk || !cl) return false;
|
|
5262
|
+
const dirty = cl.keyframes && cl.keyframes.length > 0 || cl.panX !== void 0 || cl.panY !== void 0 || cl.scale !== void 0;
|
|
5263
|
+
if (!dirty) return false;
|
|
5264
|
+
this.pushHistory();
|
|
5265
|
+
delete cl.panX;
|
|
5266
|
+
delete cl.panY;
|
|
5267
|
+
delete cl.scale;
|
|
5268
|
+
cl.keyframes = void 0;
|
|
5269
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId) {
|
|
5270
|
+
this.selectedKeyframe = null;
|
|
5271
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5272
|
+
}
|
|
5273
|
+
this.afterMutation();
|
|
5274
|
+
return true;
|
|
5275
|
+
}
|
|
5276
|
+
resetKeyframesAtTime(clipId, timeMs) {
|
|
5277
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5278
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5279
|
+
if (!trk || !cl) return false;
|
|
5280
|
+
const duration = clipDuration(cl);
|
|
5281
|
+
const t = Math.max(0, Math.min(duration, Math.round(timeMs)));
|
|
5282
|
+
this.pushHistory();
|
|
5283
|
+
let kfs = cl.keyframes ?? [];
|
|
5284
|
+
kfs = upsertKeyframe(kfs, "panX", t, 0, () => createId("kf"));
|
|
5285
|
+
kfs = upsertKeyframe(kfs, "panY", t, 0, () => createId("kf"));
|
|
5286
|
+
kfs = upsertKeyframe(kfs, "scale", t, 1, () => createId("kf"));
|
|
5287
|
+
kfs.sort((a, b) => {
|
|
5288
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5289
|
+
return a.time - b.time;
|
|
5290
|
+
});
|
|
5291
|
+
cl.keyframes = kfs;
|
|
5292
|
+
this.afterMutation();
|
|
5293
|
+
return true;
|
|
5294
|
+
}
|
|
5295
|
+
seekToClipEdge(clipId, edge) {
|
|
5296
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5297
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5298
|
+
if (!trk || !cl) return false;
|
|
5299
|
+
const target = edge === "start" ? cl.start : Math.max(cl.start, clipEnd(cl) - 1);
|
|
5300
|
+
if (this.engine.getTime() === target) return false;
|
|
5301
|
+
this.seek(target);
|
|
5302
|
+
return true;
|
|
5303
|
+
}
|
|
5304
|
+
seekToSelectedClipEdge(edge) {
|
|
5305
|
+
if (!this.selectedClipId) return false;
|
|
5306
|
+
return this.seekToClipEdge(this.selectedClipId, edge);
|
|
5307
|
+
}
|
|
5308
|
+
toggleKeyframeAtPlayhead() {
|
|
5309
|
+
const clipId = this.selectedClipId;
|
|
5310
|
+
if (!clipId) return false;
|
|
5311
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5312
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5313
|
+
if (!trk || !cl) return false;
|
|
5314
|
+
const localMs = this.engine.getTime() - cl.start;
|
|
5315
|
+
const duration = clipDuration(cl);
|
|
5316
|
+
if (localMs < 0 || localMs > duration) return false;
|
|
5317
|
+
const t = Math.round(localMs);
|
|
5318
|
+
const existing = cl.keyframes?.filter((k) => Math.abs(k.time - t) < 16);
|
|
5319
|
+
if (existing && existing.length > 0) {
|
|
5320
|
+
this.pushHistory();
|
|
5321
|
+
const ids = new Set(existing.map((k) => k.id));
|
|
5322
|
+
const next = cl.keyframes.filter((k) => !ids.has(k.id));
|
|
5323
|
+
cl.keyframes = next.length > 0 ? next : void 0;
|
|
5324
|
+
if (this.selectedKeyframe && ids.has(this.selectedKeyframe.keyframeId)) {
|
|
5325
|
+
this.selectedKeyframe = null;
|
|
5326
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5327
|
+
}
|
|
5328
|
+
this.afterMutation();
|
|
5329
|
+
return true;
|
|
5330
|
+
}
|
|
5331
|
+
const current = getEffectiveTransform(cl, t);
|
|
5332
|
+
this.pushHistory();
|
|
5333
|
+
let kfs = cl.keyframes ?? [];
|
|
5334
|
+
kfs = upsertKeyframe(kfs, "panX", t, current.panX, () => createId("kf"));
|
|
5335
|
+
kfs = upsertKeyframe(kfs, "panY", t, current.panY, () => createId("kf"));
|
|
5336
|
+
kfs = upsertKeyframe(kfs, "scale", t, current.scale, () => createId("kf"));
|
|
5337
|
+
kfs.sort((a, b) => {
|
|
5338
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5339
|
+
return a.time - b.time;
|
|
5340
|
+
});
|
|
5341
|
+
cl.keyframes = kfs;
|
|
5342
|
+
const anchor = kfs.find(
|
|
5343
|
+
(k) => k.prop === "panX" && Math.abs(k.time - t) < 16
|
|
5344
|
+
);
|
|
5345
|
+
if (anchor) {
|
|
5346
|
+
this.selectedKeyframe = { clipId, keyframeId: anchor.id };
|
|
5347
|
+
this.bus.emit("keyframeSelectionChange", {
|
|
5348
|
+
target: this.selectedKeyframe
|
|
5349
|
+
});
|
|
5350
|
+
}
|
|
5351
|
+
this.afterMutation();
|
|
5352
|
+
return true;
|
|
5353
|
+
}
|
|
5354
|
+
setSelectedKeyframe(target) {
|
|
5355
|
+
if (target?.clipId === this.selectedKeyframe?.clipId && target?.keyframeId === this.selectedKeyframe?.keyframeId) {
|
|
5356
|
+
return;
|
|
5357
|
+
}
|
|
5358
|
+
this.selectedKeyframe = target;
|
|
5359
|
+
if (target && target.clipId !== this.selectedClipId) {
|
|
5360
|
+
this.selectedClipId = target.clipId;
|
|
5361
|
+
this.bus.emit("selectionChange", { clipId: target.clipId });
|
|
5362
|
+
}
|
|
5363
|
+
this.bus.emit("keyframeSelectionChange", { target });
|
|
3245
5364
|
this.ui.render();
|
|
3246
5365
|
}
|
|
3247
5366
|
// ---- history --------------------------------------------------------
|
|
@@ -3255,6 +5374,7 @@ var Editor = class _Editor {
|
|
|
3255
5374
|
const prev = this.history.undo(this.project);
|
|
3256
5375
|
if (!prev) return false;
|
|
3257
5376
|
this.project = prev;
|
|
5377
|
+
this.reconcileSelectionsWithProject();
|
|
3258
5378
|
this.engine.setProject(this.project);
|
|
3259
5379
|
this.bus.emit("change", { project: this.getProject() });
|
|
3260
5380
|
this.emitHistory();
|
|
@@ -3265,12 +5385,57 @@ var Editor = class _Editor {
|
|
|
3265
5385
|
const next = this.history.redo(this.project);
|
|
3266
5386
|
if (!next) return false;
|
|
3267
5387
|
this.project = next;
|
|
5388
|
+
this.reconcileSelectionsWithProject();
|
|
3268
5389
|
this.engine.setProject(this.project);
|
|
3269
5390
|
this.bus.emit("change", { project: this.getProject() });
|
|
3270
5391
|
this.emitHistory();
|
|
3271
5392
|
this.ui.render();
|
|
3272
5393
|
return true;
|
|
3273
5394
|
}
|
|
5395
|
+
beginInteraction() {
|
|
5396
|
+
this.interactionDepth += 1;
|
|
5397
|
+
}
|
|
5398
|
+
endInteraction() {
|
|
5399
|
+
if (this.interactionDepth === 0) return;
|
|
5400
|
+
this.interactionDepth -= 1;
|
|
5401
|
+
if (this.interactionDepth > 0) return;
|
|
5402
|
+
const snapshot = this.interactionStartSnapshot;
|
|
5403
|
+
this.interactionStartSnapshot = null;
|
|
5404
|
+
if (snapshot == null) return;
|
|
5405
|
+
const now = JSON.stringify(this.project);
|
|
5406
|
+
if (now === snapshot) return;
|
|
5407
|
+
this.history.push(JSON.parse(snapshot));
|
|
5408
|
+
this.emitHistory();
|
|
5409
|
+
}
|
|
5410
|
+
/**
|
|
5411
|
+
* Selections (clipId + selectedKeyframe) live OUTSIDE the project
|
|
5412
|
+
* snapshot, so undo / redo can leave them pointing at ids that no
|
|
5413
|
+
* longer exist. Defend against dangling refs by clearing anything
|
|
5414
|
+
* the restored project doesn't actually contain — and emit the
|
|
5415
|
+
* paired change events so panels / overlays hide cleanly instead
|
|
5416
|
+
* of holding zombie references.
|
|
5417
|
+
*/
|
|
5418
|
+
reconcileSelectionsWithProject() {
|
|
5419
|
+
if (this.selectedKeyframe) {
|
|
5420
|
+
const trk = findTrackOfClip(this.project, this.selectedKeyframe.clipId);
|
|
5421
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedKeyframe.clipId);
|
|
5422
|
+
const kf = cl?.keyframes?.find(
|
|
5423
|
+
(k) => k.id === this.selectedKeyframe.keyframeId
|
|
5424
|
+
);
|
|
5425
|
+
if (!kf) {
|
|
5426
|
+
this.selectedKeyframe = null;
|
|
5427
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5428
|
+
}
|
|
5429
|
+
}
|
|
5430
|
+
if (this.selectedClipId) {
|
|
5431
|
+
const trk = findTrackOfClip(this.project, this.selectedClipId);
|
|
5432
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
|
|
5433
|
+
if (!cl) {
|
|
5434
|
+
this.selectedClipId = null;
|
|
5435
|
+
this.bus.emit("selectionChange", { clipId: null });
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
5438
|
+
}
|
|
3274
5439
|
// ---- events ---------------------------------------------------------
|
|
3275
5440
|
on(event, handler) {
|
|
3276
5441
|
return this.bus.on(event, handler);
|
|
@@ -3307,6 +5472,12 @@ var Editor = class _Editor {
|
|
|
3307
5472
|
return null;
|
|
3308
5473
|
}
|
|
3309
5474
|
pushHistory() {
|
|
5475
|
+
if (this.interactionDepth > 0) {
|
|
5476
|
+
if (this.interactionStartSnapshot == null) {
|
|
5477
|
+
this.interactionStartSnapshot = JSON.stringify(this.project);
|
|
5478
|
+
}
|
|
5479
|
+
return;
|
|
5480
|
+
}
|
|
3310
5481
|
this.history.push(this.project);
|
|
3311
5482
|
this.emitHistory();
|
|
3312
5483
|
}
|
|
@@ -3348,17 +5519,24 @@ function clampScale2(s) {
|
|
|
3348
5519
|
return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
|
|
3349
5520
|
}
|
|
3350
5521
|
|
|
5522
|
+
exports.CanvasCompositorEngine = CanvasCompositorEngine;
|
|
3351
5523
|
exports.Editor = Editor;
|
|
3352
5524
|
exports.HEADER_WIDTH = HEADER_WIDTH;
|
|
3353
|
-
exports.
|
|
3354
|
-
exports.
|
|
5525
|
+
exports.HtmlVideoEngine = HtmlVideoEngine;
|
|
5526
|
+
exports.IDENTITY_TRANSFORM = IDENTITY_TRANSFORM;
|
|
3355
5527
|
exports.Timeline = Timeline;
|
|
5528
|
+
exports.canvasCompositorEngineFactory = canvasCompositorEngineFactory;
|
|
3356
5529
|
exports.createEmptyProject = createEmptyProject;
|
|
3357
5530
|
exports.createId = createId;
|
|
3358
5531
|
exports.formatLabel = formatLabel;
|
|
5532
|
+
exports.getEffectiveTransform = getEffectiveTransform;
|
|
5533
|
+
exports.getTransformAtTimelineTime = getTransformAtTimelineTime;
|
|
5534
|
+
exports.htmlVideoEngineFactory = htmlVideoEngineFactory;
|
|
5535
|
+
exports.isIdentityTransform = isIdentityTransform;
|
|
3359
5536
|
exports.localeEn = localeEn;
|
|
3360
5537
|
exports.localeZh = localeZh;
|
|
3361
5538
|
exports.mergeLocale = mergeLocale;
|
|
3362
5539
|
exports.normalizeProject = normalizeProject;
|
|
5540
|
+
exports.setTimelineMetrics = setTimelineMetrics;
|
|
3363
5541
|
//# sourceMappingURL=index.cjs.map
|
|
3364
5542
|
//# sourceMappingURL=index.cjs.map
|