@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.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
1
|
+
import { getEffectiveTransform, interpolateProp, upsertKeyframe, hasKeyframesForProp } from './chunk-WTCK3XQ6.js';
|
|
2
|
+
export { IDENTITY_TRANSFORM, getEffectiveTransform, getTransformAtTimelineTime, isIdentityTransform } from './chunk-WTCK3XQ6.js';
|
|
3
|
+
import { mergeLocale, applyTheme, formatLabel } from './chunk-H6AY6NW4.js';
|
|
4
|
+
export { formatLabel, localeEn, localeZh, mergeLocale } from './chunk-H6AY6NW4.js';
|
|
3
5
|
|
|
4
6
|
// src/events.ts
|
|
5
7
|
var EventBus = class {
|
|
@@ -82,6 +84,19 @@ function createId(prefix = "id") {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
// src/model.ts
|
|
87
|
+
var KEYFRAME_PROPS = ["panX", "panY", "scale"];
|
|
88
|
+
var DEFAULT_FPS = 30;
|
|
89
|
+
var BIG_FRAME_STEP = 10;
|
|
90
|
+
function projectFps(project) {
|
|
91
|
+
const f = project.fps;
|
|
92
|
+
return f != null && f > 0 ? f : DEFAULT_FPS;
|
|
93
|
+
}
|
|
94
|
+
function frameStepMs(project) {
|
|
95
|
+
return Math.max(1, Math.round(1e3 / projectFps(project)));
|
|
96
|
+
}
|
|
97
|
+
function bigFrameStepMs(project) {
|
|
98
|
+
return Math.max(1, Math.round(BIG_FRAME_STEP * 1e3 / projectFps(project)));
|
|
99
|
+
}
|
|
85
100
|
function createEmptyProject() {
|
|
86
101
|
return {
|
|
87
102
|
version: 1,
|
|
@@ -118,11 +133,58 @@ function findTrackOfClip(project, clipId) {
|
|
|
118
133
|
function normalizeProject(project) {
|
|
119
134
|
const sources = project.sources.map((s) => ({ ...s }));
|
|
120
135
|
const tracks = project.tracks.map((t) => {
|
|
121
|
-
const clips = t.clips.filter((c) => c.out > c.in).map((c) =>
|
|
136
|
+
const clips = t.clips.filter((c) => c.out > c.in).map((c) => {
|
|
137
|
+
const out = { ...c, id: c.id || createId("clip") };
|
|
138
|
+
if (c.keyframes && c.keyframes.length > 0) {
|
|
139
|
+
const duration = c.out - c.in;
|
|
140
|
+
out.keyframes = migrateKeyframes(c.keyframes).filter((kf) => kf.time >= 0 && kf.time <= duration).map((kf) => ({ ...kf, id: kf.id || createId("kf") })).sort((a, b) => {
|
|
141
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
142
|
+
return a.time - b.time;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}).sort((a, b) => a.start - b.start);
|
|
122
147
|
return { ...t, id: t.id || createId("track"), clips };
|
|
123
148
|
});
|
|
124
149
|
return { version: 1, sources, tracks };
|
|
125
150
|
}
|
|
151
|
+
function migrateKeyframes(raw) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const kf of raw) {
|
|
154
|
+
if ("prop" in kf && "value" in kf) {
|
|
155
|
+
out.push(kf);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const tuple = kf;
|
|
159
|
+
const id = tuple.id;
|
|
160
|
+
const t = tuple.time;
|
|
161
|
+
if (typeof tuple.x === "number") {
|
|
162
|
+
out.push({
|
|
163
|
+
id: id ? `${id}-px` : createId("kf"),
|
|
164
|
+
prop: "panX",
|
|
165
|
+
time: t,
|
|
166
|
+
value: tuple.x
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (typeof tuple.y === "number") {
|
|
170
|
+
out.push({
|
|
171
|
+
id: id ? `${id}-py` : createId("kf"),
|
|
172
|
+
prop: "panY",
|
|
173
|
+
time: t,
|
|
174
|
+
value: tuple.y
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (typeof tuple.scale === "number") {
|
|
178
|
+
out.push({
|
|
179
|
+
id: id ? `${id}-s` : createId("kf"),
|
|
180
|
+
prop: "scale",
|
|
181
|
+
time: t,
|
|
182
|
+
value: tuple.scale
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
126
188
|
function splitClipAt(clip, localOffset) {
|
|
127
189
|
const sourceLen = clip.out - clip.in;
|
|
128
190
|
if (localOffset <= 0 || localOffset >= sourceLen) return null;
|
|
@@ -133,6 +195,47 @@ function splitClipAt(clip, localOffset) {
|
|
|
133
195
|
in: clip.in + localOffset,
|
|
134
196
|
start: clip.start + localOffset
|
|
135
197
|
};
|
|
198
|
+
if (clip.keyframes && clip.keyframes.length > 0) {
|
|
199
|
+
const leftKf = [];
|
|
200
|
+
const rightKf = [];
|
|
201
|
+
for (const prop of KEYFRAME_PROPS) {
|
|
202
|
+
const propKfs = clip.keyframes.filter((k) => k.prop === prop);
|
|
203
|
+
if (propKfs.length === 0) continue;
|
|
204
|
+
const boundaryValue = interpolateProp(clip, prop, localOffset);
|
|
205
|
+
let leftSeamPresent = false;
|
|
206
|
+
let rightSeamPresent = false;
|
|
207
|
+
for (const kf of propKfs) {
|
|
208
|
+
if (kf.time < localOffset) {
|
|
209
|
+
leftKf.push(kf);
|
|
210
|
+
} else if (kf.time > localOffset) {
|
|
211
|
+
rightKf.push({ ...kf, id: createId("kf"), time: kf.time - localOffset });
|
|
212
|
+
} else {
|
|
213
|
+
leftKf.push(kf);
|
|
214
|
+
leftSeamPresent = true;
|
|
215
|
+
rightKf.push({ ...kf, id: createId("kf"), time: 0 });
|
|
216
|
+
rightSeamPresent = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!leftSeamPresent) {
|
|
220
|
+
leftKf.push({
|
|
221
|
+
id: createId("kf"),
|
|
222
|
+
prop,
|
|
223
|
+
time: localOffset,
|
|
224
|
+
value: boundaryValue
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (!rightSeamPresent) {
|
|
228
|
+
rightKf.push({
|
|
229
|
+
id: createId("kf"),
|
|
230
|
+
prop,
|
|
231
|
+
time: 0,
|
|
232
|
+
value: boundaryValue
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
left.keyframes = leftKf.length > 0 ? leftKf : void 0;
|
|
237
|
+
right.keyframes = rightKf.length > 0 ? rightKf : void 0;
|
|
238
|
+
}
|
|
136
239
|
return [left, right];
|
|
137
240
|
}
|
|
138
241
|
function projectDuration(project) {
|
|
@@ -152,6 +255,19 @@ var HANDLE_PX = 8;
|
|
|
152
255
|
var CLIP_INSET = 6;
|
|
153
256
|
var SCALE_MIN = 10;
|
|
154
257
|
var SCALE_MAX = 400;
|
|
258
|
+
var TIMELINE_PAD_LEFT = 12;
|
|
259
|
+
var TIMELINE_PAD_RIGHT = 12;
|
|
260
|
+
function contentLeftX(showHeader) {
|
|
261
|
+
return (showHeader ? HEADER_WIDTH : 0) + TIMELINE_PAD_LEFT;
|
|
262
|
+
}
|
|
263
|
+
function setTimelineMetrics(opts) {
|
|
264
|
+
if (opts.trackHeight != null && opts.trackHeight > 0) {
|
|
265
|
+
TRACK_HEIGHT = Math.round(opts.trackHeight);
|
|
266
|
+
}
|
|
267
|
+
if (opts.rulerHeight != null && opts.rulerHeight > 0) {
|
|
268
|
+
RULER_HEIGHT = Math.round(opts.rulerHeight);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
155
271
|
var SCROLLBAR_THICKNESS = 10;
|
|
156
272
|
var SCROLLBAR_MIN_THUMB = 24;
|
|
157
273
|
var SCROLLBAR_INSET = 2;
|
|
@@ -179,8 +295,10 @@ function trackIndexAt(y, trackCount, scrollTop = 0) {
|
|
|
179
295
|
return idx;
|
|
180
296
|
}
|
|
181
297
|
function xToMs(x, pxPerSec, scrollLeft, showHeader) {
|
|
182
|
-
|
|
183
|
-
|
|
298
|
+
return Math.max(
|
|
299
|
+
0,
|
|
300
|
+
(x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
|
|
301
|
+
);
|
|
184
302
|
}
|
|
185
303
|
function niceTickSeconds(targetSec) {
|
|
186
304
|
if (targetSec <= 0) return 1;
|
|
@@ -208,6 +326,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
|
|
|
208
326
|
for (const c of t.clips) {
|
|
209
327
|
if (c.id === ignoreClipId) continue;
|
|
210
328
|
arr.push(c.start, c.start + (c.out - c.in));
|
|
329
|
+
if (c.keyframes) {
|
|
330
|
+
for (const kf of c.keyframes) arr.push(c.start + kf.time);
|
|
331
|
+
}
|
|
211
332
|
}
|
|
212
333
|
}
|
|
213
334
|
return arr;
|
|
@@ -254,30 +375,40 @@ function uncoveredIntervals(project) {
|
|
|
254
375
|
return gaps;
|
|
255
376
|
}
|
|
256
377
|
|
|
257
|
-
// src/playback.ts
|
|
258
|
-
var
|
|
378
|
+
// src/playback/html-video.ts
|
|
379
|
+
var HtmlVideoEngine = class {
|
|
259
380
|
host;
|
|
260
381
|
mount;
|
|
261
|
-
|
|
382
|
+
sources = /* @__PURE__ */ new Map();
|
|
262
383
|
project;
|
|
263
384
|
currentClipId = null;
|
|
264
385
|
playing = false;
|
|
265
386
|
timeMs = 0;
|
|
266
387
|
rafHandle = null;
|
|
267
388
|
lastFrameTs = 0;
|
|
389
|
+
/** Permanent rAF that positions the active wrapper at the output
|
|
390
|
+
* rect + pushes keyframe transform onto the inner video via CSS. */
|
|
391
|
+
transformRaf = null;
|
|
268
392
|
/** Public event hooks — set by Editor. */
|
|
269
393
|
onTimeUpdate;
|
|
270
394
|
onEnded;
|
|
271
395
|
onError;
|
|
272
396
|
onReady;
|
|
273
397
|
onSourceMetadata;
|
|
274
|
-
constructor(
|
|
275
|
-
this.host = host;
|
|
276
|
-
this.project = project;
|
|
398
|
+
constructor(opts) {
|
|
399
|
+
this.host = opts.host;
|
|
400
|
+
this.project = opts.project;
|
|
277
401
|
this.mount = document.createElement("div");
|
|
278
402
|
this.mount.className = "aicut-preview";
|
|
403
|
+
Object.assign(this.mount.style, {
|
|
404
|
+
position: "absolute",
|
|
405
|
+
inset: "0",
|
|
406
|
+
width: "100%",
|
|
407
|
+
height: "100%"
|
|
408
|
+
});
|
|
279
409
|
this.host.appendChild(this.mount);
|
|
280
410
|
this.syncSources();
|
|
411
|
+
this.startTransformLoop();
|
|
281
412
|
}
|
|
282
413
|
setProject(next) {
|
|
283
414
|
this.project = next;
|
|
@@ -300,9 +431,9 @@ var PlaybackEngine = class {
|
|
|
300
431
|
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
301
432
|
this.activate(clip);
|
|
302
433
|
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
303
|
-
const
|
|
304
|
-
if (!
|
|
305
|
-
void
|
|
434
|
+
const s = this.sources.get(clip.sourceId);
|
|
435
|
+
if (!s) return;
|
|
436
|
+
void s.video.play().catch((err) => this.onError?.(err));
|
|
306
437
|
this.playing = true;
|
|
307
438
|
this.startTickLoop();
|
|
308
439
|
}
|
|
@@ -313,8 +444,7 @@ var PlaybackEngine = class {
|
|
|
313
444
|
if (this.currentClipId) {
|
|
314
445
|
const clip = this.clipById(this.currentClipId);
|
|
315
446
|
if (clip) {
|
|
316
|
-
|
|
317
|
-
v?.pause();
|
|
447
|
+
this.sources.get(clip.sourceId)?.video.pause();
|
|
318
448
|
}
|
|
319
449
|
}
|
|
320
450
|
}
|
|
@@ -341,13 +471,417 @@ var PlaybackEngine = class {
|
|
|
341
471
|
}
|
|
342
472
|
this.onTimeUpdate?.(clamped);
|
|
343
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* The OUTPUT frame — the fixed stage the rendered video is clipped
|
|
476
|
+
* to. Independent of the keyframe transform. Used by the overlay to
|
|
477
|
+
* draw the dashed border at a stable position.
|
|
478
|
+
*/
|
|
479
|
+
getOutputFrameRect() {
|
|
480
|
+
return this.baseFrameRect();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The CONTENT frame — where the transformed video pixels actually
|
|
484
|
+
* land. Equal to the output frame when transform is identity; may
|
|
485
|
+
* extend outside (zoom in) or fit inside (zoom out) when not.
|
|
486
|
+
*/
|
|
487
|
+
getFrameRect() {
|
|
488
|
+
const base = this.baseFrameRect();
|
|
489
|
+
if (!base) return null;
|
|
490
|
+
const clip = this.clipById(this.currentClipId);
|
|
491
|
+
if (!clip) return base;
|
|
492
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
493
|
+
const cx = base.x + base.w / 2 + t.panX;
|
|
494
|
+
const cy = base.y + base.h / 2 + t.panY;
|
|
495
|
+
const w = base.w * t.scale;
|
|
496
|
+
const h = base.h * t.scale;
|
|
497
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
498
|
+
}
|
|
499
|
+
/** Untransformed contain-letterbox rect — the OUTPUT frame. */
|
|
500
|
+
baseFrameRect() {
|
|
501
|
+
if (!this.currentClipId) return null;
|
|
502
|
+
const clip = this.clipById(this.currentClipId);
|
|
503
|
+
if (!clip) return null;
|
|
504
|
+
const s = this.sources.get(clip.sourceId);
|
|
505
|
+
if (!s) return null;
|
|
506
|
+
const v = s.video;
|
|
507
|
+
if (v.videoWidth === 0 || v.videoHeight === 0) return null;
|
|
508
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
509
|
+
const cw = hostRect.width;
|
|
510
|
+
const ch = hostRect.height;
|
|
511
|
+
if (cw === 0 || ch === 0) return null;
|
|
512
|
+
const scale = Math.min(cw / v.videoWidth, ch / v.videoHeight);
|
|
513
|
+
const w = v.videoWidth * scale;
|
|
514
|
+
const h = v.videoHeight * scale;
|
|
515
|
+
return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Permanent rAF that (a) sizes + positions the active wrapper to
|
|
519
|
+
* the output frame, and (b) writes the keyframe transform onto the
|
|
520
|
+
* inner video. Negligible cost — three style writes per frame max.
|
|
521
|
+
*/
|
|
522
|
+
startTransformLoop() {
|
|
523
|
+
const tick = () => {
|
|
524
|
+
this.applyTransforms();
|
|
525
|
+
this.transformRaf = requestAnimationFrame(tick);
|
|
526
|
+
};
|
|
527
|
+
this.transformRaf = requestAnimationFrame(tick);
|
|
528
|
+
}
|
|
529
|
+
applyTransforms() {
|
|
530
|
+
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
531
|
+
const outRect = this.baseFrameRect();
|
|
532
|
+
if (clip && outRect) {
|
|
533
|
+
const s = this.sources.get(clip.sourceId);
|
|
534
|
+
if (s) {
|
|
535
|
+
Object.assign(s.wrapper.style, {
|
|
536
|
+
left: `${outRect.x}px`,
|
|
537
|
+
top: `${outRect.y}px`,
|
|
538
|
+
width: `${outRect.w}px`,
|
|
539
|
+
height: `${outRect.h}px`
|
|
540
|
+
});
|
|
541
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
542
|
+
s.video.style.transform = `translate(${t.panX.toFixed(2)}px, ${t.panY.toFixed(2)}px) scale(${t.scale.toFixed(4)})`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const [id, s] of this.sources) {
|
|
546
|
+
if (clip && id === clip.sourceId) continue;
|
|
547
|
+
if (s.video.style.transform) s.video.style.transform = "";
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
destroy() {
|
|
551
|
+
this.stopTickLoop();
|
|
552
|
+
if (this.transformRaf != null) {
|
|
553
|
+
cancelAnimationFrame(this.transformRaf);
|
|
554
|
+
this.transformRaf = null;
|
|
555
|
+
}
|
|
556
|
+
for (const s of this.sources.values()) {
|
|
557
|
+
s.video.pause();
|
|
558
|
+
s.video.removeAttribute("src");
|
|
559
|
+
s.video.load();
|
|
560
|
+
s.wrapper.remove();
|
|
561
|
+
}
|
|
562
|
+
this.sources.clear();
|
|
563
|
+
this.mount.remove();
|
|
564
|
+
}
|
|
565
|
+
// --- internals -------------------------------------------------------
|
|
566
|
+
syncSources() {
|
|
567
|
+
const wanted = new Set(this.project.sources.map((s) => s.id));
|
|
568
|
+
for (const [id, s] of this.sources) {
|
|
569
|
+
if (!wanted.has(id)) {
|
|
570
|
+
s.video.pause();
|
|
571
|
+
s.wrapper.remove();
|
|
572
|
+
this.sources.delete(id);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
for (const src of this.project.sources) {
|
|
576
|
+
if (src.kind !== "video") continue;
|
|
577
|
+
if (this.sources.has(src.id)) continue;
|
|
578
|
+
const wrapper = document.createElement("div");
|
|
579
|
+
wrapper.className = "aicut-preview-clip";
|
|
580
|
+
Object.assign(wrapper.style, {
|
|
581
|
+
position: "absolute",
|
|
582
|
+
overflow: "hidden",
|
|
583
|
+
visibility: "hidden",
|
|
584
|
+
// Initial bounds — applyTransforms overrides each frame.
|
|
585
|
+
left: "0",
|
|
586
|
+
top: "0",
|
|
587
|
+
width: "0",
|
|
588
|
+
height: "0"
|
|
589
|
+
});
|
|
590
|
+
const v = document.createElement("video");
|
|
591
|
+
v.preload = "auto";
|
|
592
|
+
v.playsInline = true;
|
|
593
|
+
v.muted = false;
|
|
594
|
+
v.src = src.url;
|
|
595
|
+
Object.assign(v.style, {
|
|
596
|
+
position: "absolute",
|
|
597
|
+
inset: "0",
|
|
598
|
+
width: "100%",
|
|
599
|
+
height: "100%",
|
|
600
|
+
objectFit: "fill",
|
|
601
|
+
// Transform origin at center so scale() scales around the
|
|
602
|
+
// video's centroid, not its top-left corner.
|
|
603
|
+
transformOrigin: "50% 50%"
|
|
604
|
+
});
|
|
605
|
+
const sourceId = src.id;
|
|
606
|
+
v.addEventListener(
|
|
607
|
+
"error",
|
|
608
|
+
() => this.onError?.(new Error(`Failed to load ${src.url}`))
|
|
609
|
+
);
|
|
610
|
+
v.addEventListener("loadedmetadata", () => {
|
|
611
|
+
this.onReady?.();
|
|
612
|
+
const durMs = Math.round(v.duration * 1e3);
|
|
613
|
+
if (Number.isFinite(durMs) && durMs > 0) {
|
|
614
|
+
this.onSourceMetadata?.(sourceId, durMs);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
wrapper.appendChild(v);
|
|
618
|
+
this.mount.appendChild(wrapper);
|
|
619
|
+
this.sources.set(src.id, { wrapper, video: v });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
activate(clip) {
|
|
623
|
+
if (clip?.id === this.currentClipId) return;
|
|
624
|
+
if (this.currentClipId) {
|
|
625
|
+
const prev = this.clipById(this.currentClipId);
|
|
626
|
+
if (prev) {
|
|
627
|
+
const s = this.sources.get(prev.sourceId);
|
|
628
|
+
if (s) {
|
|
629
|
+
s.video.pause();
|
|
630
|
+
s.wrapper.style.visibility = "hidden";
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
this.currentClipId = clip ? clip.id : null;
|
|
635
|
+
if (clip) {
|
|
636
|
+
const s = this.sources.get(clip.sourceId);
|
|
637
|
+
if (s) s.wrapper.style.visibility = "visible";
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
seekVideoToClipOffset(clip, offsetMs) {
|
|
641
|
+
const s = this.sources.get(clip.sourceId);
|
|
642
|
+
if (!s) return;
|
|
643
|
+
const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
|
|
644
|
+
if (Math.abs(s.video.currentTime - target) > 0.05) {
|
|
645
|
+
s.video.currentTime = target;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
clipById(id) {
|
|
649
|
+
for (const t of this.project.tracks) {
|
|
650
|
+
for (const c of t.clips) if (c.id === id) return c;
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Find the clip whose timeline range contains `timeMs`, searching
|
|
656
|
+
* across ALL video tracks. If multiple tracks have a clip at this
|
|
657
|
+
* moment, the lowest-index track wins.
|
|
658
|
+
*/
|
|
659
|
+
clipAtTime(timeMs) {
|
|
660
|
+
for (const t of this.project.tracks) {
|
|
661
|
+
if (t.kind !== "video") continue;
|
|
662
|
+
for (const c of t.clips) {
|
|
663
|
+
if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
/** Earliest clip starting at-or-after `timeMs` across all video tracks. */
|
|
669
|
+
nextClipAfterTime(timeMs) {
|
|
670
|
+
let best = null;
|
|
671
|
+
for (const t of this.project.tracks) {
|
|
672
|
+
if (t.kind !== "video") continue;
|
|
673
|
+
for (const c of t.clips) {
|
|
674
|
+
if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return best;
|
|
678
|
+
}
|
|
679
|
+
/** Max clip end across all video tracks. */
|
|
680
|
+
totalDuration() {
|
|
681
|
+
let max = 0;
|
|
682
|
+
for (const t of this.project.tracks) {
|
|
683
|
+
if (t.kind !== "video") continue;
|
|
684
|
+
for (const c of t.clips) {
|
|
685
|
+
const e = c.start + (c.out - c.in);
|
|
686
|
+
if (e > max) max = e;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return max;
|
|
690
|
+
}
|
|
691
|
+
startTickLoop() {
|
|
692
|
+
this.lastFrameTs = performance.now();
|
|
693
|
+
const tick = (now) => {
|
|
694
|
+
if (!this.playing) return;
|
|
695
|
+
const dtMs = now - this.lastFrameTs;
|
|
696
|
+
this.lastFrameTs = now;
|
|
697
|
+
this.advance(dtMs);
|
|
698
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
699
|
+
};
|
|
700
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
701
|
+
}
|
|
702
|
+
stopTickLoop() {
|
|
703
|
+
if (this.rafHandle != null) {
|
|
704
|
+
cancelAnimationFrame(this.rafHandle);
|
|
705
|
+
this.rafHandle = null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
advance(dtMs) {
|
|
709
|
+
if (this.project.tracks.length === 0) return;
|
|
710
|
+
this.timeMs += dtMs;
|
|
711
|
+
const totalDur = this.totalDuration();
|
|
712
|
+
if (this.timeMs >= totalDur) {
|
|
713
|
+
this.timeMs = totalDur;
|
|
714
|
+
this.onTimeUpdate?.(this.timeMs);
|
|
715
|
+
this.pause();
|
|
716
|
+
this.onEnded?.();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const clip = this.clipAtTime(this.timeMs);
|
|
720
|
+
if (!clip) {
|
|
721
|
+
const next = this.nextClipAfterTime(this.timeMs);
|
|
722
|
+
if (next) {
|
|
723
|
+
this.timeMs = next.start;
|
|
724
|
+
this.activate(next);
|
|
725
|
+
this.seekVideoToClipOffset(next, 0);
|
|
726
|
+
const s = this.sources.get(next.sourceId);
|
|
727
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
728
|
+
} else {
|
|
729
|
+
this.pause();
|
|
730
|
+
this.onEnded?.();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
} else if (clip.id !== this.currentClipId) {
|
|
734
|
+
this.activate(clip);
|
|
735
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
736
|
+
const s = this.sources.get(clip.sourceId);
|
|
737
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
738
|
+
}
|
|
739
|
+
this.onTimeUpdate?.(this.timeMs);
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
var htmlVideoEngineFactory = (opts) => new HtmlVideoEngine(opts);
|
|
743
|
+
|
|
744
|
+
// src/playback/canvas-compositor.ts
|
|
745
|
+
var CanvasCompositorEngine = class {
|
|
746
|
+
host;
|
|
747
|
+
mount;
|
|
748
|
+
canvas;
|
|
749
|
+
ctx;
|
|
750
|
+
/** Only created when constructed with `debug: true`. */
|
|
751
|
+
badge = null;
|
|
752
|
+
videos = /* @__PURE__ */ new Map();
|
|
753
|
+
project;
|
|
754
|
+
currentClipId = null;
|
|
755
|
+
playing = false;
|
|
756
|
+
timeMs = 0;
|
|
757
|
+
rafHandle = null;
|
|
758
|
+
lastFrameTs = 0;
|
|
759
|
+
paintedFrames = 0;
|
|
760
|
+
/** Output frame rect (no transform) — fixed bounds. */
|
|
761
|
+
lastOutputRect = null;
|
|
762
|
+
/** Post-transform content rect. */
|
|
763
|
+
lastFrameRect = null;
|
|
764
|
+
onTimeUpdate;
|
|
765
|
+
onEnded;
|
|
766
|
+
onError;
|
|
767
|
+
onReady;
|
|
768
|
+
onSourceMetadata;
|
|
769
|
+
constructor(opts) {
|
|
770
|
+
this.host = opts.host;
|
|
771
|
+
this.project = opts.project;
|
|
772
|
+
this.mount = document.createElement("div");
|
|
773
|
+
this.mount.className = "aicut-preview aicut-preview--canvas";
|
|
774
|
+
Object.assign(this.mount.style, {
|
|
775
|
+
position: "absolute",
|
|
776
|
+
inset: "0",
|
|
777
|
+
width: "100%",
|
|
778
|
+
height: "100%"
|
|
779
|
+
});
|
|
780
|
+
this.canvas = document.createElement("canvas");
|
|
781
|
+
Object.assign(this.canvas.style, {
|
|
782
|
+
position: "absolute",
|
|
783
|
+
inset: "0",
|
|
784
|
+
width: "100%",
|
|
785
|
+
height: "100%",
|
|
786
|
+
// Stretch with letterboxing handled by the draw loop.
|
|
787
|
+
objectFit: "contain",
|
|
788
|
+
// Black until the first frame is drawn so the swap from the
|
|
789
|
+
// previous engine doesn't flash the host background.
|
|
790
|
+
background: "#000"
|
|
791
|
+
});
|
|
792
|
+
this.mount.appendChild(this.canvas);
|
|
793
|
+
const ctx = this.canvas.getContext("2d");
|
|
794
|
+
if (!ctx) throw new Error("CanvasCompositorEngine: 2d context unavailable");
|
|
795
|
+
this.ctx = ctx;
|
|
796
|
+
if (opts.debug) {
|
|
797
|
+
const badge = document.createElement("div");
|
|
798
|
+
badge.className = "aicut-preview__badge";
|
|
799
|
+
Object.assign(badge.style, {
|
|
800
|
+
position: "absolute",
|
|
801
|
+
top: "8px",
|
|
802
|
+
left: "8px",
|
|
803
|
+
padding: "4px 8px",
|
|
804
|
+
borderRadius: "6px",
|
|
805
|
+
background: "rgba(0, 0, 0, 0.55)",
|
|
806
|
+
color: "rgba(255, 255, 255, 0.92)",
|
|
807
|
+
font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
808
|
+
pointerEvents: "none",
|
|
809
|
+
zIndex: "2",
|
|
810
|
+
letterSpacing: "0.02em"
|
|
811
|
+
});
|
|
812
|
+
badge.textContent = "engine: canvas compositor";
|
|
813
|
+
this.mount.appendChild(badge);
|
|
814
|
+
this.badge = badge;
|
|
815
|
+
}
|
|
816
|
+
this.host.appendChild(this.mount);
|
|
817
|
+
this.syncSources();
|
|
818
|
+
this.resizeCanvas();
|
|
819
|
+
this.startTickLoop();
|
|
820
|
+
}
|
|
821
|
+
setProject(next) {
|
|
822
|
+
this.project = next;
|
|
823
|
+
this.syncSources();
|
|
824
|
+
const clip = this.clipAtTime(this.timeMs);
|
|
825
|
+
if (!clip) {
|
|
826
|
+
this.timeMs = 0;
|
|
827
|
+
this.activate(null);
|
|
828
|
+
this.onTimeUpdate?.(0);
|
|
829
|
+
} else {
|
|
830
|
+
this.activate(clip);
|
|
831
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
play() {
|
|
835
|
+
if (this.playing) return;
|
|
836
|
+
if (this.totalDuration() <= 0) return;
|
|
837
|
+
const clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
|
|
838
|
+
if (!clip) return;
|
|
839
|
+
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
840
|
+
this.activate(clip);
|
|
841
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
842
|
+
const v = this.videos.get(clip.sourceId);
|
|
843
|
+
if (!v) return;
|
|
844
|
+
void v.play().catch((err) => this.onError?.(err));
|
|
845
|
+
this.playing = true;
|
|
846
|
+
this.lastFrameTs = performance.now();
|
|
847
|
+
}
|
|
848
|
+
pause() {
|
|
849
|
+
if (!this.playing) return;
|
|
850
|
+
this.playing = false;
|
|
851
|
+
if (this.currentClipId) {
|
|
852
|
+
const clip = this.clipById(this.currentClipId);
|
|
853
|
+
if (clip) this.videos.get(clip.sourceId)?.pause();
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
isPlaying() {
|
|
857
|
+
return this.playing;
|
|
858
|
+
}
|
|
859
|
+
getTime() {
|
|
860
|
+
return this.timeMs;
|
|
861
|
+
}
|
|
862
|
+
seek(timeMs) {
|
|
863
|
+
const total = this.totalDuration();
|
|
864
|
+
if (total <= 0) {
|
|
865
|
+
this.timeMs = 0;
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const clamped = Math.max(0, Math.min(timeMs, total));
|
|
869
|
+
this.timeMs = clamped;
|
|
870
|
+
const clip = this.clipAtTime(clamped);
|
|
871
|
+
if (clip) {
|
|
872
|
+
this.activate(clip);
|
|
873
|
+
this.seekVideoToClipOffset(clip, clamped - clip.start);
|
|
874
|
+
} else {
|
|
875
|
+
this.activate(null);
|
|
876
|
+
}
|
|
877
|
+
this.onTimeUpdate?.(clamped);
|
|
878
|
+
}
|
|
344
879
|
destroy() {
|
|
345
880
|
this.stopTickLoop();
|
|
346
881
|
for (const v of this.videos.values()) {
|
|
347
882
|
v.pause();
|
|
348
883
|
v.removeAttribute("src");
|
|
349
884
|
v.load();
|
|
350
|
-
v.remove();
|
|
351
885
|
}
|
|
352
886
|
this.videos.clear();
|
|
353
887
|
this.mount.remove();
|
|
@@ -358,7 +892,6 @@ var PlaybackEngine = class {
|
|
|
358
892
|
for (const [id, v] of this.videos) {
|
|
359
893
|
if (!wanted.has(id)) {
|
|
360
894
|
v.pause();
|
|
361
|
-
v.remove();
|
|
362
895
|
this.videos.delete(id);
|
|
363
896
|
}
|
|
364
897
|
}
|
|
@@ -370,12 +903,6 @@ var PlaybackEngine = class {
|
|
|
370
903
|
v.playsInline = true;
|
|
371
904
|
v.muted = false;
|
|
372
905
|
v.src = src.url;
|
|
373
|
-
v.style.position = "absolute";
|
|
374
|
-
v.style.inset = "0";
|
|
375
|
-
v.style.width = "100%";
|
|
376
|
-
v.style.height = "100%";
|
|
377
|
-
v.style.objectFit = "contain";
|
|
378
|
-
v.style.visibility = "hidden";
|
|
379
906
|
const sourceId = src.id;
|
|
380
907
|
v.addEventListener(
|
|
381
908
|
"error",
|
|
@@ -388,7 +915,6 @@ var PlaybackEngine = class {
|
|
|
388
915
|
this.onSourceMetadata?.(sourceId, durMs);
|
|
389
916
|
}
|
|
390
917
|
});
|
|
391
|
-
this.mount.appendChild(v);
|
|
392
918
|
this.videos.set(src.id, v);
|
|
393
919
|
}
|
|
394
920
|
}
|
|
@@ -396,19 +922,9 @@ var PlaybackEngine = class {
|
|
|
396
922
|
if (clip?.id === this.currentClipId) return;
|
|
397
923
|
if (this.currentClipId) {
|
|
398
924
|
const prev = this.clipById(this.currentClipId);
|
|
399
|
-
if (prev)
|
|
400
|
-
const v = this.videos.get(prev.sourceId);
|
|
401
|
-
if (v) {
|
|
402
|
-
v.pause();
|
|
403
|
-
v.style.visibility = "hidden";
|
|
404
|
-
}
|
|
405
|
-
}
|
|
925
|
+
if (prev) this.videos.get(prev.sourceId)?.pause();
|
|
406
926
|
}
|
|
407
927
|
this.currentClipId = clip ? clip.id : null;
|
|
408
|
-
if (clip) {
|
|
409
|
-
const v = this.videos.get(clip.sourceId);
|
|
410
|
-
if (v) v.style.visibility = "visible";
|
|
411
|
-
}
|
|
412
928
|
}
|
|
413
929
|
seekVideoToClipOffset(clip, offsetMs) {
|
|
414
930
|
const v = this.videos.get(clip.sourceId);
|
|
@@ -424,14 +940,6 @@ var PlaybackEngine = class {
|
|
|
424
940
|
}
|
|
425
941
|
return null;
|
|
426
942
|
}
|
|
427
|
-
/**
|
|
428
|
-
* Find the clip whose timeline range contains `timeMs`, searching
|
|
429
|
-
* across ALL video tracks. If multiple tracks have a clip at this
|
|
430
|
-
* moment, the lowest-index track wins (matches the "Track 1 is
|
|
431
|
-
* background" convention used in the auto-split UX — overlapping
|
|
432
|
-
* placements would have created a new track on top, but here we
|
|
433
|
-
* fall back to the underlying clip).
|
|
434
|
-
*/
|
|
435
943
|
clipAtTime(timeMs) {
|
|
436
944
|
for (const t of this.project.tracks) {
|
|
437
945
|
if (t.kind !== "video") continue;
|
|
@@ -441,7 +949,6 @@ var PlaybackEngine = class {
|
|
|
441
949
|
}
|
|
442
950
|
return null;
|
|
443
951
|
}
|
|
444
|
-
/** Earliest clip starting at-or-after `timeMs` across all video tracks. */
|
|
445
952
|
nextClipAfterTime(timeMs) {
|
|
446
953
|
let best = null;
|
|
447
954
|
for (const t of this.project.tracks) {
|
|
@@ -452,7 +959,6 @@ var PlaybackEngine = class {
|
|
|
452
959
|
}
|
|
453
960
|
return best;
|
|
454
961
|
}
|
|
455
|
-
/** Max clip end across all video tracks. */
|
|
456
962
|
totalDuration() {
|
|
457
963
|
let max = 0;
|
|
458
964
|
for (const t of this.project.tracks) {
|
|
@@ -464,13 +970,26 @@ var PlaybackEngine = class {
|
|
|
464
970
|
}
|
|
465
971
|
return max;
|
|
466
972
|
}
|
|
973
|
+
resizeCanvas() {
|
|
974
|
+
const rect = this.mount.getBoundingClientRect();
|
|
975
|
+
const dpr = window.devicePixelRatio || 1;
|
|
976
|
+
const w = Math.max(1, Math.floor(rect.width * dpr));
|
|
977
|
+
const h = Math.max(1, Math.floor(rect.height * dpr));
|
|
978
|
+
if (this.canvas.width !== w || this.canvas.height !== h) {
|
|
979
|
+
this.canvas.width = w;
|
|
980
|
+
this.canvas.height = h;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
467
983
|
startTickLoop() {
|
|
468
984
|
this.lastFrameTs = performance.now();
|
|
469
985
|
const tick = (now) => {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
986
|
+
this.resizeCanvas();
|
|
987
|
+
if (this.playing) {
|
|
988
|
+
const dtMs = now - this.lastFrameTs;
|
|
989
|
+
this.lastFrameTs = now;
|
|
990
|
+
this.advance(dtMs);
|
|
991
|
+
}
|
|
992
|
+
this.paint();
|
|
474
993
|
this.rafHandle = requestAnimationFrame(tick);
|
|
475
994
|
};
|
|
476
995
|
this.rafHandle = requestAnimationFrame(tick);
|
|
@@ -514,7 +1033,74 @@ var PlaybackEngine = class {
|
|
|
514
1033
|
}
|
|
515
1034
|
this.onTimeUpdate?.(this.timeMs);
|
|
516
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* One paint per rAF — clears the canvas, draws the current active
|
|
1038
|
+
* video frame letterboxed to fit, then refreshes the HUD. Done
|
|
1039
|
+
* unconditionally (not just on `playing`) so the HUD frame counter
|
|
1040
|
+
* and the seek preview both update when paused.
|
|
1041
|
+
*/
|
|
1042
|
+
paint() {
|
|
1043
|
+
const cw = this.canvas.width;
|
|
1044
|
+
const ch = this.canvas.height;
|
|
1045
|
+
this.ctx.clearRect(0, 0, cw, ch);
|
|
1046
|
+
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
1047
|
+
const v = clip ? this.videos.get(clip.sourceId) : null;
|
|
1048
|
+
if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
|
|
1049
|
+
const vw = v.videoWidth;
|
|
1050
|
+
const vh = v.videoHeight;
|
|
1051
|
+
const baseScale = Math.min(cw / vw, ch / vh);
|
|
1052
|
+
const dw = vw * baseScale;
|
|
1053
|
+
const dh = vh * baseScale;
|
|
1054
|
+
const cx = cw / 2;
|
|
1055
|
+
const cy = ch / 2;
|
|
1056
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1057
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
1058
|
+
const outX = (cw - dw) / 2;
|
|
1059
|
+
const outY = (ch - dh) / 2;
|
|
1060
|
+
this.ctx.save();
|
|
1061
|
+
this.ctx.beginPath();
|
|
1062
|
+
this.ctx.rect(outX, outY, dw, dh);
|
|
1063
|
+
this.ctx.clip();
|
|
1064
|
+
this.ctx.translate(cx + t.panX * dpr, cy + t.panY * dpr);
|
|
1065
|
+
this.ctx.scale(t.scale, t.scale);
|
|
1066
|
+
this.ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
|
|
1067
|
+
this.ctx.restore();
|
|
1068
|
+
this.paintedFrames += 1;
|
|
1069
|
+
this.lastOutputRect = {
|
|
1070
|
+
x: outX / dpr,
|
|
1071
|
+
y: outY / dpr,
|
|
1072
|
+
w: dw / dpr,
|
|
1073
|
+
h: dh / dpr
|
|
1074
|
+
};
|
|
1075
|
+
const cssCx = cw / (2 * dpr) + t.panX;
|
|
1076
|
+
const cssCy = ch / (2 * dpr) + t.panY;
|
|
1077
|
+
const cssW = dw * t.scale / dpr;
|
|
1078
|
+
const cssH = dh * t.scale / dpr;
|
|
1079
|
+
this.lastFrameRect = {
|
|
1080
|
+
x: cssCx - cssW / 2,
|
|
1081
|
+
y: cssCy - cssH / 2,
|
|
1082
|
+
w: cssW,
|
|
1083
|
+
h: cssH
|
|
1084
|
+
};
|
|
1085
|
+
} else {
|
|
1086
|
+
this.lastFrameRect = null;
|
|
1087
|
+
this.lastOutputRect = null;
|
|
1088
|
+
}
|
|
1089
|
+
this.updateBadge();
|
|
1090
|
+
}
|
|
1091
|
+
getOutputFrameRect() {
|
|
1092
|
+
return this.lastOutputRect;
|
|
1093
|
+
}
|
|
1094
|
+
getFrameRect() {
|
|
1095
|
+
return this.lastFrameRect;
|
|
1096
|
+
}
|
|
1097
|
+
updateBadge() {
|
|
1098
|
+
if (!this.badge) return;
|
|
1099
|
+
const sec = (this.timeMs / 1e3).toFixed(2);
|
|
1100
|
+
this.badge.textContent = `engine: canvas compositor \u2022 t=${sec}s \u2022 frames painted: ${this.paintedFrames}`;
|
|
1101
|
+
}
|
|
517
1102
|
};
|
|
1103
|
+
var canvasCompositorEngineFactory = (opts) => new CanvasCompositorEngine(opts);
|
|
518
1104
|
|
|
519
1105
|
// src/ui/thumbnails.ts
|
|
520
1106
|
var THUMB_HEIGHT = 44;
|
|
@@ -574,11 +1160,19 @@ var ThumbnailRibbon = class {
|
|
|
574
1160
|
/**
|
|
575
1161
|
* Paint thumbnails for the clip's visible window onto `ctx`. The
|
|
576
1162
|
* canvas is the per-clip strip — width = clip's px width, height =
|
|
577
|
-
*
|
|
578
|
-
* and the px range we're
|
|
1163
|
+
* `pxHeight` (defaults to the cached `THUMB_HEIGHT`). Source-time
|
|
1164
|
+
* range derives from the clip's `in/out` and the px range we're
|
|
1165
|
+
* drawing into.
|
|
1166
|
+
*
|
|
1167
|
+
* `pxHeight` lets the caller stretch thumbs to fill a taller clip
|
|
1168
|
+
* body when `trackHeight` is configured above the default. Aspect
|
|
1169
|
+
* ratio is already broken per-thumb (we slice variable widths from a
|
|
1170
|
+
* fixed-aspect cached bitmap), so stretching height too is fine — it
|
|
1171
|
+
* preserves the "filmstrip" look without leaving an empty bottom
|
|
1172
|
+
* band of the brand gradient showing through.
|
|
579
1173
|
*/
|
|
580
|
-
paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
|
|
581
|
-
ctx.clearRect(0, 0, pxWidth,
|
|
1174
|
+
paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth, pxHeight = THUMB_HEIGHT) {
|
|
1175
|
+
ctx.clearRect(0, 0, pxWidth, pxHeight);
|
|
582
1176
|
const st = this.sources.get(sourceId);
|
|
583
1177
|
if (!st) return;
|
|
584
1178
|
if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
|
|
@@ -591,10 +1185,10 @@ var ThumbnailRibbon = class {
|
|
|
591
1185
|
const x = Math.round(i * pxWidth / count);
|
|
592
1186
|
const w = Math.round((i + 1) * pxWidth / count) - x;
|
|
593
1187
|
if (bmp) {
|
|
594
|
-
ctx.drawImage(bmp, x, 0, w,
|
|
1188
|
+
ctx.drawImage(bmp, x, 0, w, pxHeight);
|
|
595
1189
|
} else {
|
|
596
1190
|
ctx.fillStyle = "rgba(255,255,255,0.04)";
|
|
597
|
-
ctx.fillRect(x, 0, w,
|
|
1191
|
+
ctx.fillRect(x, 0, w, pxHeight);
|
|
598
1192
|
this.enqueue(st, bucket);
|
|
599
1193
|
}
|
|
600
1194
|
}
|
|
@@ -685,7 +1279,7 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
685
1279
|
const { viewportWidth: W, viewportHeight: H } = state;
|
|
686
1280
|
ctx.fillStyle = style.bg;
|
|
687
1281
|
ctx.fillRect(0, 0, W, H);
|
|
688
|
-
const baseX = state.showHeader
|
|
1282
|
+
const baseX = contentLeftX(state.showHeader);
|
|
689
1283
|
const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
|
|
690
1284
|
const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
691
1285
|
ctx.save();
|
|
@@ -725,15 +1319,21 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
725
1319
|
ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
|
|
726
1320
|
ctx.clip();
|
|
727
1321
|
drawSnapGuide(ctx, state, style);
|
|
728
|
-
drawPlayhead(ctx, state, style);
|
|
729
1322
|
ctx.restore();
|
|
730
1323
|
drawScrollbarV(ctx, state, style);
|
|
731
1324
|
drawScrollbarH(ctx, state, style);
|
|
1325
|
+
const playheadLeft = state.showHeader ? HEADER_WIDTH : 0;
|
|
1326
|
+
ctx.save();
|
|
1327
|
+
ctx.beginPath();
|
|
1328
|
+
ctx.rect(playheadLeft, 0, W - playheadLeft, H);
|
|
1329
|
+
ctx.clip();
|
|
1330
|
+
drawPlayhead(ctx, state, style);
|
|
1331
|
+
ctx.restore();
|
|
732
1332
|
}
|
|
733
1333
|
function drawCoverageGaps(ctx, state, style) {
|
|
734
1334
|
const gaps = uncoveredIntervals(state.project);
|
|
735
1335
|
if (gaps.length === 0) return;
|
|
736
|
-
const baseX = state.showHeader
|
|
1336
|
+
const baseX = contentLeftX(state.showHeader);
|
|
737
1337
|
const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
738
1338
|
for (const [s, e] of gaps) {
|
|
739
1339
|
const x1 = Math.max(
|
|
@@ -772,7 +1372,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
|
|
|
772
1372
|
}
|
|
773
1373
|
}
|
|
774
1374
|
if (!real) return;
|
|
775
|
-
const baseX = state.showHeader
|
|
1375
|
+
const baseX = contentLeftX(state.showHeader);
|
|
776
1376
|
const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
|
|
777
1377
|
const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
778
1378
|
const overlap = ghost.wouldOverlap;
|
|
@@ -842,7 +1442,7 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
|
|
|
842
1442
|
}
|
|
843
1443
|
function drawRuler(ctx, state, style) {
|
|
844
1444
|
const { pxPerSec, scrollLeft, viewportWidth: W } = state;
|
|
845
|
-
const baseX = state.showHeader
|
|
1445
|
+
const baseX = contentLeftX(state.showHeader);
|
|
846
1446
|
const rulerW = W - baseX;
|
|
847
1447
|
ctx.fillStyle = style.bg;
|
|
848
1448
|
ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
|
|
@@ -888,7 +1488,7 @@ function drawTracks(ctx, state, style, thumbs) {
|
|
|
888
1488
|
}
|
|
889
1489
|
function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
890
1490
|
const { viewportWidth: W } = state;
|
|
891
|
-
const baseX = state.showHeader
|
|
1491
|
+
const baseX = contentLeftX(state.showHeader);
|
|
892
1492
|
const y = trackY(trackIndex);
|
|
893
1493
|
ctx.fillStyle = style.trackBg;
|
|
894
1494
|
ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
|
|
@@ -926,7 +1526,7 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
|
926
1526
|
}
|
|
927
1527
|
function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
|
|
928
1528
|
const { pxPerSec, scrollLeft } = state;
|
|
929
|
-
const baseX = state.showHeader
|
|
1529
|
+
const baseX = contentLeftX(state.showHeader);
|
|
930
1530
|
const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
|
|
931
1531
|
const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
|
|
932
1532
|
const y = trackY(trackIndex) + CLIP_INSET;
|
|
@@ -949,7 +1549,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
|
|
|
949
1549
|
roundRect(ctx, startX, y, widthPx, h, 6);
|
|
950
1550
|
ctx.clip();
|
|
951
1551
|
ctx.translate(startX, y);
|
|
952
|
-
thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
|
|
1552
|
+
thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx, h);
|
|
953
1553
|
ctx.restore();
|
|
954
1554
|
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
|
955
1555
|
ctx.lineWidth = 1;
|
|
@@ -975,6 +1575,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
|
|
|
975
1575
|
ctx.fillRect(startX + 2, y + 12, 2, h - 24);
|
|
976
1576
|
ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
|
|
977
1577
|
}
|
|
1578
|
+
if (!dim && state.keyframesEnabled && clip.keyframes && clip.keyframes.length > 0) {
|
|
1579
|
+
const diamondY = y + h / 2;
|
|
1580
|
+
const halfSize = 5;
|
|
1581
|
+
const moments = groupKeyframesByTime(clip.keyframes, 16);
|
|
1582
|
+
const ghost = state.keyframeDragGhost;
|
|
1583
|
+
for (const moment of moments) {
|
|
1584
|
+
const draggedHere = ghost ? moment.kfs.find(
|
|
1585
|
+
(k) => ghost.clipId === clip.id && ghost.keyframeId === k.id
|
|
1586
|
+
) : void 0;
|
|
1587
|
+
const effectiveTime = draggedHere ? ghost.ghostTimeMs : moment.time;
|
|
1588
|
+
const kfX = startX + effectiveTime / 1e3 * pxPerSec;
|
|
1589
|
+
if (kfX < baseX - halfSize || kfX > state.viewportWidth + halfSize) continue;
|
|
1590
|
+
const isSelected = state.selectedKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.selectedKeyframe?.keyframeId);
|
|
1591
|
+
const isHovered = state.hoveredKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.hoveredKeyframe?.keyframeId);
|
|
1592
|
+
const drawSize = isHovered ? halfSize + 1.5 : halfSize;
|
|
1593
|
+
ctx.beginPath();
|
|
1594
|
+
ctx.moveTo(kfX, diamondY - drawSize);
|
|
1595
|
+
ctx.lineTo(kfX + drawSize, diamondY);
|
|
1596
|
+
ctx.lineTo(kfX, diamondY + drawSize);
|
|
1597
|
+
ctx.lineTo(kfX - drawSize, diamondY);
|
|
1598
|
+
ctx.closePath();
|
|
1599
|
+
ctx.fillStyle = isSelected ? style.selectedRing : isHovered ? "#ffffff" : withAlpha(style.text, 0.85);
|
|
1600
|
+
ctx.fill();
|
|
1601
|
+
ctx.strokeStyle = isHovered ? style.selectedRing : "rgba(0, 0, 0, 0.65)";
|
|
1602
|
+
ctx.lineWidth = isHovered ? 1.5 : 1;
|
|
1603
|
+
ctx.stroke();
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
978
1606
|
ctx.restore();
|
|
979
1607
|
}
|
|
980
1608
|
function drawHeaders(ctx, state, style) {
|
|
@@ -1025,7 +1653,7 @@ function drawHeaders(ctx, state, style) {
|
|
|
1025
1653
|
}
|
|
1026
1654
|
}
|
|
1027
1655
|
function drawPlayhead(ctx, state, style) {
|
|
1028
|
-
const baseX = state.showHeader
|
|
1656
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1029
1657
|
const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1030
1658
|
if (x < baseX - 2 || x > state.viewportWidth + 2) return;
|
|
1031
1659
|
ctx.strokeStyle = style.playhead;
|
|
@@ -1039,7 +1667,9 @@ function drawPlayhead(ctx, state, style) {
|
|
|
1039
1667
|
const padX = 6;
|
|
1040
1668
|
const w = ctx.measureText(label).width + padX * 2;
|
|
1041
1669
|
const h = 14;
|
|
1042
|
-
const
|
|
1670
|
+
const contentRight = state.viewportWidth - SCROLLBAR_THICKNESS;
|
|
1671
|
+
const rawBx = x - w / 2;
|
|
1672
|
+
const bx = Math.max(baseX, Math.min(contentRight - w, rawBx));
|
|
1043
1673
|
const by = 2;
|
|
1044
1674
|
ctx.fillStyle = style.playhead;
|
|
1045
1675
|
roundRect(ctx, bx, by, w, h, 4);
|
|
@@ -1095,7 +1725,7 @@ function drawScrollbarV(ctx, state, style) {
|
|
|
1095
1725
|
}
|
|
1096
1726
|
function drawScrollbarH(ctx, state, style) {
|
|
1097
1727
|
if (state.scrollbarOpacityX <= 0.01) return;
|
|
1098
|
-
const baseX = state.showHeader
|
|
1728
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1099
1729
|
const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1100
1730
|
const contentW = contentWidth(state.project, state.pxPerSec);
|
|
1101
1731
|
if (contentW <= visibleW) return;
|
|
@@ -1178,11 +1808,25 @@ function parseColor(s) {
|
|
|
1178
1808
|
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1179
1809
|
return null;
|
|
1180
1810
|
}
|
|
1811
|
+
function groupKeyframesByTime(kfs, epsilonMs) {
|
|
1812
|
+
const sorted = [...kfs].sort((a, b) => a.time - b.time);
|
|
1813
|
+
const out = [];
|
|
1814
|
+
for (const k of sorted) {
|
|
1815
|
+
const last = out[out.length - 1];
|
|
1816
|
+
if (last && Math.abs(k.time - last.time) < epsilonMs) {
|
|
1817
|
+
last.kfs.push(k);
|
|
1818
|
+
} else {
|
|
1819
|
+
out.push({ time: k.time, kfs: [k] });
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return out;
|
|
1823
|
+
}
|
|
1181
1824
|
|
|
1182
1825
|
// src/timeline/hit.ts
|
|
1826
|
+
var KEYFRAME_HIT_RADIUS = 8;
|
|
1183
1827
|
function hitTest(x, y, ctx) {
|
|
1184
1828
|
if (y < 0 || x < 0) return { kind: "outside" };
|
|
1185
|
-
const baseX = ctx.showHeader
|
|
1829
|
+
const baseX = contentLeftX(ctx.showHeader);
|
|
1186
1830
|
const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1187
1831
|
const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
|
|
1188
1832
|
if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
|
|
@@ -1220,7 +1864,7 @@ function hitTest(x, y, ctx) {
|
|
|
1220
1864
|
if (track2.clips.length === 0) {
|
|
1221
1865
|
const btnSize = 18;
|
|
1222
1866
|
const btnLeft = HEADER_WIDTH - btnSize - 6;
|
|
1223
|
-
const btnTop = RULER_HEIGHT + ti2 *
|
|
1867
|
+
const btnTop = RULER_HEIGHT + ti2 * TRACK_HEIGHT + (TRACK_HEIGHT - btnSize) / 2 - ctx.scrollTop;
|
|
1224
1868
|
if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
|
|
1225
1869
|
return { kind: "header-delete", trackIndex: ti2 };
|
|
1226
1870
|
}
|
|
@@ -1234,6 +1878,23 @@ function hitTest(x, y, ctx) {
|
|
|
1234
1878
|
if (ti < 0) return { kind: "outside" };
|
|
1235
1879
|
const track = ctx.project.tracks[ti];
|
|
1236
1880
|
const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
|
|
1881
|
+
if (ctx.keyframesEnabled) {
|
|
1882
|
+
for (const clip of track.clips) {
|
|
1883
|
+
if (!clip.keyframes || clip.keyframes.length === 0) continue;
|
|
1884
|
+
const startX = msToXLocal(clip.start, ctx);
|
|
1885
|
+
for (const kf of clip.keyframes) {
|
|
1886
|
+
const kfX = startX + kf.time / 1e3 * ctx.pxPerSec;
|
|
1887
|
+
if (Math.abs(x - kfX) <= KEYFRAME_HIT_RADIUS) {
|
|
1888
|
+
return {
|
|
1889
|
+
kind: "keyframe",
|
|
1890
|
+
trackIndex: ti,
|
|
1891
|
+
clipId: clip.id,
|
|
1892
|
+
keyframeId: kf.id
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1237
1898
|
for (const clip of track.clips) {
|
|
1238
1899
|
const start = clip.start;
|
|
1239
1900
|
const end = clip.start + (clip.out - clip.in);
|
|
@@ -1252,8 +1913,7 @@ function hitTest(x, y, ctx) {
|
|
|
1252
1913
|
return { kind: "track-empty", trackIndex: ti };
|
|
1253
1914
|
}
|
|
1254
1915
|
function msToXLocal(ms, ctx) {
|
|
1255
|
-
|
|
1256
|
-
return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1916
|
+
return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1257
1917
|
}
|
|
1258
1918
|
|
|
1259
1919
|
// src/timeline/index.ts
|
|
@@ -1287,6 +1947,8 @@ var Timeline = class _Timeline {
|
|
|
1287
1947
|
readOnly;
|
|
1288
1948
|
autoFitEnabled;
|
|
1289
1949
|
locale;
|
|
1950
|
+
keyframesEnabled = false;
|
|
1951
|
+
selectedKeyframe = null;
|
|
1290
1952
|
scrollLeft = 0;
|
|
1291
1953
|
scrollTop = 0;
|
|
1292
1954
|
viewportWidth = 0;
|
|
@@ -1305,6 +1967,7 @@ var Timeline = class _Timeline {
|
|
|
1305
1967
|
scrollbarDrag = null;
|
|
1306
1968
|
hoveredClipId = null;
|
|
1307
1969
|
hoveredTrackIndex = null;
|
|
1970
|
+
hoveredKeyframe = null;
|
|
1308
1971
|
hoverCursor = "default";
|
|
1309
1972
|
dropTargetTrackIndex = null;
|
|
1310
1973
|
snapX = null;
|
|
@@ -1343,6 +2006,8 @@ var Timeline = class _Timeline {
|
|
|
1343
2006
|
this.readOnly = opts.readOnly === true;
|
|
1344
2007
|
this.autoFitEnabled = opts.autoFit !== false;
|
|
1345
2008
|
this.locale = mergeLocale(opts.locale);
|
|
2009
|
+
this.keyframesEnabled = opts.keyframesEnabled === true;
|
|
2010
|
+
this.selectedKeyframe = opts.selectedKeyframe ?? null;
|
|
1346
2011
|
this.root.classList.add("aicut-timeline-canvas");
|
|
1347
2012
|
this.root.innerHTML = "";
|
|
1348
2013
|
this.root.style.position = this.root.style.position || "relative";
|
|
@@ -1390,6 +2055,7 @@ var Timeline = class _Timeline {
|
|
|
1390
2055
|
this.thumbs.syncSources(this.project.sources);
|
|
1391
2056
|
this.attachPointer();
|
|
1392
2057
|
this.attachWheel();
|
|
2058
|
+
this.attachKeyboard();
|
|
1393
2059
|
this.attachResize();
|
|
1394
2060
|
this.resizeCanvas();
|
|
1395
2061
|
this.scheduleRender();
|
|
@@ -1470,7 +2136,7 @@ var Timeline = class _Timeline {
|
|
|
1470
2136
|
* Exposed publicly so React/Vue wrappers can forward it to a ref.
|
|
1471
2137
|
*/
|
|
1472
2138
|
getDebugInfo() {
|
|
1473
|
-
const baseX = this.showHeader
|
|
2139
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1474
2140
|
const clips = [];
|
|
1475
2141
|
for (let ti = 0; ti < this.project.tracks.length; ti++) {
|
|
1476
2142
|
const t = this.project.tracks[ti];
|
|
@@ -1520,21 +2186,20 @@ var Timeline = class _Timeline {
|
|
|
1520
2186
|
const dpr = window.devicePixelRatio || 1;
|
|
1521
2187
|
this.canvas.width = Math.floor(this.viewportWidth * dpr);
|
|
1522
2188
|
this.canvas.height = Math.floor(this.viewportHeight * dpr);
|
|
1523
|
-
this.canvas.style.height = `${this.viewportHeight}px`;
|
|
1524
2189
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1525
2190
|
}
|
|
1526
2191
|
computeFitScale() {
|
|
1527
|
-
const baseX = this.showHeader
|
|
2192
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1528
2193
|
const w = this.viewportWidth - baseX - 24;
|
|
1529
2194
|
const dur = projectDuration(this.project);
|
|
1530
2195
|
if (w <= 0 || dur <= 0) return null;
|
|
1531
2196
|
return clampScale(w / (dur / 1e3));
|
|
1532
2197
|
}
|
|
1533
2198
|
maxScrollLeft() {
|
|
1534
|
-
const baseX = this.showHeader
|
|
2199
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1535
2200
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1536
2201
|
const cw = contentWidth(this.project, this.pxPerSec);
|
|
1537
|
-
return Math.max(0, cw - visibleW +
|
|
2202
|
+
return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
|
|
1538
2203
|
}
|
|
1539
2204
|
maxScrollTop() {
|
|
1540
2205
|
const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
@@ -1636,9 +2301,36 @@ var Timeline = class _Timeline {
|
|
|
1636
2301
|
scrollbarOpacityX: this.scrollbarOpacity("h"),
|
|
1637
2302
|
scrollbarActiveY: this.scrollbarDrag?.axis === "v",
|
|
1638
2303
|
scrollbarActiveX: this.scrollbarDrag?.axis === "h",
|
|
1639
|
-
locale: this.locale
|
|
2304
|
+
locale: this.locale,
|
|
2305
|
+
keyframesEnabled: this.keyframesEnabled,
|
|
2306
|
+
selectedKeyframe: this.selectedKeyframe,
|
|
2307
|
+
hoveredKeyframe: this.hoveredKeyframe,
|
|
2308
|
+
keyframeDragGhost: this.drag?.kind === "keyframe-drag" ? {
|
|
2309
|
+
clipId: this.drag.clipId,
|
|
2310
|
+
keyframeId: this.drag.keyframeId,
|
|
2311
|
+
ghostTimeMs: this.drag.ghostTimeMs
|
|
2312
|
+
} : null
|
|
1640
2313
|
};
|
|
1641
2314
|
}
|
|
2315
|
+
/** Host-pushed state — Editor calls this when its keyframe mode
|
|
2316
|
+
* changes or when a keyframe is selected/deselected externally. */
|
|
2317
|
+
setKeyframeState(state) {
|
|
2318
|
+
let dirty = false;
|
|
2319
|
+
if (state.enabled !== void 0 && state.enabled !== this.keyframesEnabled) {
|
|
2320
|
+
this.keyframesEnabled = state.enabled;
|
|
2321
|
+
dirty = true;
|
|
2322
|
+
}
|
|
2323
|
+
if (state.selected !== void 0) {
|
|
2324
|
+
const a = this.selectedKeyframe;
|
|
2325
|
+
const b = state.selected;
|
|
2326
|
+
const same = a?.clipId === b?.clipId && a?.keyframeId === b?.keyframeId;
|
|
2327
|
+
if (!same) {
|
|
2328
|
+
this.selectedKeyframe = b;
|
|
2329
|
+
dirty = true;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
if (dirty) this.scheduleRender();
|
|
2333
|
+
}
|
|
1642
2334
|
readStyle() {
|
|
1643
2335
|
const cs = getComputedStyle(this.root);
|
|
1644
2336
|
const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
|
|
@@ -1667,6 +2359,7 @@ var Timeline = class _Timeline {
|
|
|
1667
2359
|
this.canvas.addEventListener("pointerleave", () => {
|
|
1668
2360
|
if (!this.drag && !this.scrollbarDrag) {
|
|
1669
2361
|
this.hoveredClipId = null;
|
|
2362
|
+
this.hoveredKeyframe = null;
|
|
1670
2363
|
this.hoverCursor = "default";
|
|
1671
2364
|
this.hoverScrollbarY = false;
|
|
1672
2365
|
this.hoverScrollbarX = false;
|
|
@@ -1707,7 +2400,7 @@ var Timeline = class _Timeline {
|
|
|
1707
2400
|
return;
|
|
1708
2401
|
}
|
|
1709
2402
|
if (target.kind === "scrollbar-track-h") {
|
|
1710
|
-
const baseX = this.showHeader
|
|
2403
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1711
2404
|
const page = Math.max(
|
|
1712
2405
|
80,
|
|
1713
2406
|
this.viewportWidth - baseX - SCROLLBAR_THICKNESS
|
|
@@ -1747,6 +2440,33 @@ var Timeline = class _Timeline {
|
|
|
1747
2440
|
this.scheduleRender();
|
|
1748
2441
|
return;
|
|
1749
2442
|
}
|
|
2443
|
+
if (target.kind === "keyframe") {
|
|
2444
|
+
const found = findClip(this.project, target.clipId);
|
|
2445
|
+
const kf = found?.clip.keyframes?.find((k) => k.id === target.keyframeId);
|
|
2446
|
+
if (!found || !kf) return;
|
|
2447
|
+
this.selectedKeyframe = {
|
|
2448
|
+
clipId: target.clipId,
|
|
2449
|
+
keyframeId: target.keyframeId
|
|
2450
|
+
};
|
|
2451
|
+
this.opts.onSelectKeyframe?.({
|
|
2452
|
+
clipId: target.clipId,
|
|
2453
|
+
keyframeId: target.keyframeId
|
|
2454
|
+
});
|
|
2455
|
+
const absMs = found.clip.start + kf.time;
|
|
2456
|
+
this.timeMs = absMs;
|
|
2457
|
+
this.opts.onSeek?.(absMs);
|
|
2458
|
+
this.drag = {
|
|
2459
|
+
kind: "keyframe-drag",
|
|
2460
|
+
clipId: target.clipId,
|
|
2461
|
+
keyframeId: target.keyframeId,
|
|
2462
|
+
trackIndex: target.trackIndex,
|
|
2463
|
+
pointerStartX: x,
|
|
2464
|
+
originalTimeMs: kf.time,
|
|
2465
|
+
ghostTimeMs: kf.time
|
|
2466
|
+
};
|
|
2467
|
+
this.scheduleRender();
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
1750
2470
|
if (target.kind === "clip") {
|
|
1751
2471
|
const found = findClip(this.project, target.clipId);
|
|
1752
2472
|
if (!found) return;
|
|
@@ -1813,7 +2533,7 @@ var Timeline = class _Timeline {
|
|
|
1813
2533
|
const ratio = maxScroll / free;
|
|
1814
2534
|
this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
|
|
1815
2535
|
} else {
|
|
1816
|
-
const baseX = this.showHeader
|
|
2536
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1817
2537
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1818
2538
|
const contentW = contentWidth(this.project, this.pxPerSec);
|
|
1819
2539
|
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
@@ -1837,10 +2557,19 @@ var Timeline = class _Timeline {
|
|
|
1837
2557
|
let cursor = "default";
|
|
1838
2558
|
let onScrollbarV = false;
|
|
1839
2559
|
let onScrollbarH = false;
|
|
2560
|
+
let nextHoverKeyframe = null;
|
|
1840
2561
|
if (target.kind === "clip") {
|
|
1841
2562
|
nextHover = target.clipId;
|
|
1842
2563
|
nextHoverTrack = target.trackIndex;
|
|
1843
2564
|
cursor = this.readOnly ? "pointer" : "grab";
|
|
2565
|
+
} else if (target.kind === "keyframe") {
|
|
2566
|
+
nextHover = target.clipId;
|
|
2567
|
+
nextHoverTrack = target.trackIndex;
|
|
2568
|
+
nextHoverKeyframe = {
|
|
2569
|
+
clipId: target.clipId,
|
|
2570
|
+
keyframeId: target.keyframeId
|
|
2571
|
+
};
|
|
2572
|
+
cursor = "pointer";
|
|
1844
2573
|
} else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
|
|
1845
2574
|
nextHover = target.clipId;
|
|
1846
2575
|
nextHoverTrack = target.trackIndex;
|
|
@@ -1864,12 +2593,14 @@ var Timeline = class _Timeline {
|
|
|
1864
2593
|
cursor = "default";
|
|
1865
2594
|
}
|
|
1866
2595
|
const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
|
|
1867
|
-
|
|
2596
|
+
const kfHoverChanged = (nextHoverKeyframe?.clipId ?? null) !== (this.hoveredKeyframe?.clipId ?? null) || (nextHoverKeyframe?.keyframeId ?? null) !== (this.hoveredKeyframe?.keyframeId ?? null);
|
|
2597
|
+
if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged || kfHoverChanged) {
|
|
1868
2598
|
this.hoveredClipId = nextHover;
|
|
1869
2599
|
this.hoveredTrackIndex = nextHoverTrack;
|
|
1870
2600
|
this.hoverCursor = cursor;
|
|
1871
2601
|
this.hoverScrollbarY = onScrollbarV;
|
|
1872
2602
|
this.hoverScrollbarX = onScrollbarH;
|
|
2603
|
+
this.hoveredKeyframe = nextHoverKeyframe;
|
|
1873
2604
|
this.scheduleRender();
|
|
1874
2605
|
}
|
|
1875
2606
|
return;
|
|
@@ -1892,6 +2623,26 @@ var Timeline = class _Timeline {
|
|
|
1892
2623
|
this.maybeStartDragAutoScroll();
|
|
1893
2624
|
return;
|
|
1894
2625
|
}
|
|
2626
|
+
if (this.drag.kind === "keyframe-drag") {
|
|
2627
|
+
const found = findClip(this.project, this.drag.clipId);
|
|
2628
|
+
if (!found) return;
|
|
2629
|
+
const clip = found.clip;
|
|
2630
|
+
const duration = clip.out - clip.in;
|
|
2631
|
+
const dxPx = x - this.drag.pointerStartX;
|
|
2632
|
+
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
2633
|
+
const nextLocal = Math.max(
|
|
2634
|
+
0,
|
|
2635
|
+
Math.min(duration, this.drag.originalTimeMs + dxMs)
|
|
2636
|
+
);
|
|
2637
|
+
const snappedAbs = this.applySnap(clip.start + nextLocal, null);
|
|
2638
|
+
const snappedLocal = Math.max(
|
|
2639
|
+
0,
|
|
2640
|
+
Math.min(duration, snappedAbs - clip.start)
|
|
2641
|
+
);
|
|
2642
|
+
this.drag.ghostTimeMs = Math.round(snappedLocal);
|
|
2643
|
+
this.scheduleRender();
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
1895
2646
|
if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
|
|
1896
2647
|
const dxPx = x - this.drag.pointerStartX;
|
|
1897
2648
|
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
@@ -1937,7 +2688,8 @@ var Timeline = class _Timeline {
|
|
|
1937
2688
|
const tiRaw = this.trackIndexAtY(y);
|
|
1938
2689
|
const phantomIdx = this.project.tracks.length;
|
|
1939
2690
|
const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
|
|
1940
|
-
const
|
|
2691
|
+
const viewportBottom = this.viewportHeight - SCROLLBAR_THICKNESS;
|
|
2692
|
+
const onPhantom = y >= phantomScreenY && y < Math.max(phantomScreenY + TRACK_HEIGHT, viewportBottom);
|
|
1941
2693
|
const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
|
|
1942
2694
|
let ghostTrackIndex = intendedTrackIndex;
|
|
1943
2695
|
let overlap = false;
|
|
@@ -2043,6 +2795,14 @@ var Timeline = class _Timeline {
|
|
|
2043
2795
|
});
|
|
2044
2796
|
this.opts.onChange?.(this.getProject());
|
|
2045
2797
|
}
|
|
2798
|
+
} else if (drag.kind === "keyframe-drag") {
|
|
2799
|
+
if (drag.ghostTimeMs !== drag.originalTimeMs) {
|
|
2800
|
+
this.opts.onMoveKeyframe?.(
|
|
2801
|
+
drag.clipId,
|
|
2802
|
+
drag.keyframeId,
|
|
2803
|
+
drag.ghostTimeMs
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2046
2806
|
} else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
|
|
2047
2807
|
const found = findClip(this.project, drag.clipId);
|
|
2048
2808
|
if (found) {
|
|
@@ -2056,6 +2816,25 @@ var Timeline = class _Timeline {
|
|
|
2056
2816
|
}
|
|
2057
2817
|
this.scheduleRender();
|
|
2058
2818
|
}
|
|
2819
|
+
attachKeyboard() {
|
|
2820
|
+
this.canvas.tabIndex = -1;
|
|
2821
|
+
this.canvas.style.outline = "none";
|
|
2822
|
+
this.canvas.addEventListener("keydown", (e) => {
|
|
2823
|
+
if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
|
|
2824
|
+
e.preventDefault();
|
|
2825
|
+
const step = e.shiftKey ? bigFrameStepMs(this.project) : frameStepMs(this.project);
|
|
2826
|
+
const dir = e.code === "ArrowLeft" ? -1 : 1;
|
|
2827
|
+
const dur = projectDuration(this.project);
|
|
2828
|
+
const next = Math.max(0, Math.min(dur, this.timeMs + dir * step));
|
|
2829
|
+
if (next === this.timeMs) return;
|
|
2830
|
+
this.timeMs = next;
|
|
2831
|
+
this.opts.onSeek?.(next);
|
|
2832
|
+
this.scheduleRender();
|
|
2833
|
+
});
|
|
2834
|
+
this.canvas.addEventListener("pointerdown", () => {
|
|
2835
|
+
if (document.activeElement !== this.canvas) this.canvas.focus();
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2059
2838
|
attachWheel() {
|
|
2060
2839
|
this.canvas.addEventListener(
|
|
2061
2840
|
"wheel",
|
|
@@ -2076,7 +2855,7 @@ var Timeline = class _Timeline {
|
|
|
2076
2855
|
if (Math.abs(next - this.pxPerSec) < 0.01) return;
|
|
2077
2856
|
this.pxPerSec = next;
|
|
2078
2857
|
this.hasAutoFitted = true;
|
|
2079
|
-
const baseX = this.showHeader
|
|
2858
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2080
2859
|
this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
|
|
2081
2860
|
this.clampScroll();
|
|
2082
2861
|
this.touchScrollbar("h");
|
|
@@ -2110,64 +2889,768 @@ var Timeline = class _Timeline {
|
|
|
2110
2889
|
{ passive: false }
|
|
2111
2890
|
);
|
|
2112
2891
|
}
|
|
2113
|
-
attachResize() {
|
|
2114
|
-
if (typeof ResizeObserver === "undefined") return;
|
|
2115
|
-
this.resizeObs = new ResizeObserver(() => {
|
|
2116
|
-
this.resizeCanvas();
|
|
2117
|
-
if (!this.hasAutoFitted && this.autoFitEnabled) {
|
|
2118
|
-
const fit = this.computeFitScale();
|
|
2119
|
-
if (fit != null) {
|
|
2120
|
-
this.pxPerSec = fit;
|
|
2121
|
-
this.opts.onScaleChange?.(fit);
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
this.scheduleRender();
|
|
2892
|
+
attachResize() {
|
|
2893
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
2894
|
+
this.resizeObs = new ResizeObserver(() => {
|
|
2895
|
+
this.resizeCanvas();
|
|
2896
|
+
if (!this.hasAutoFitted && this.autoFitEnabled) {
|
|
2897
|
+
const fit = this.computeFitScale();
|
|
2898
|
+
if (fit != null) {
|
|
2899
|
+
this.pxPerSec = fit;
|
|
2900
|
+
this.opts.onScaleChange?.(fit);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
this.scheduleRender();
|
|
2904
|
+
});
|
|
2905
|
+
this.resizeObs.observe(this.root);
|
|
2906
|
+
}
|
|
2907
|
+
// ---- helpers --------------------------------------------------------
|
|
2908
|
+
localCoords(e) {
|
|
2909
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
2910
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
2911
|
+
}
|
|
2912
|
+
hitTarget(x, y) {
|
|
2913
|
+
return hitTest(x, y, {
|
|
2914
|
+
project: this.project,
|
|
2915
|
+
pxPerSec: this.pxPerSec,
|
|
2916
|
+
scrollLeft: this.scrollLeft,
|
|
2917
|
+
scrollTop: this.scrollTop,
|
|
2918
|
+
showHeader: this.showHeader,
|
|
2919
|
+
viewportWidth: this.viewportWidth,
|
|
2920
|
+
viewportHeight: this.viewportHeight,
|
|
2921
|
+
isDragging: this.drag?.kind === "move",
|
|
2922
|
+
keyframesEnabled: this.keyframesEnabled
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
trackIndexAtY(y) {
|
|
2926
|
+
return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
|
|
2927
|
+
}
|
|
2928
|
+
applySnap(ms, ignoreClipId) {
|
|
2929
|
+
if (!this.snapEnabled) {
|
|
2930
|
+
this.snapX = null;
|
|
2931
|
+
return ms;
|
|
2932
|
+
}
|
|
2933
|
+
const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
|
|
2934
|
+
const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
|
|
2935
|
+
let best = ms;
|
|
2936
|
+
let bestDist = tolMs;
|
|
2937
|
+
for (const t of targets) {
|
|
2938
|
+
const d = Math.abs(t - ms);
|
|
2939
|
+
if (d < bestDist) {
|
|
2940
|
+
bestDist = d;
|
|
2941
|
+
best = t;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
if (best !== ms) {
|
|
2945
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2946
|
+
this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
2947
|
+
} else {
|
|
2948
|
+
this.snapX = null;
|
|
2949
|
+
}
|
|
2950
|
+
return best;
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
// src/ui/keyframe-overlay.ts
|
|
2955
|
+
var KeyframeOverlay = class _KeyframeOverlay {
|
|
2956
|
+
editor;
|
|
2957
|
+
host;
|
|
2958
|
+
root;
|
|
2959
|
+
frameBody;
|
|
2960
|
+
handles;
|
|
2961
|
+
rafHandle = null;
|
|
2962
|
+
destroyed = false;
|
|
2963
|
+
drag = null;
|
|
2964
|
+
capturedPointerId = null;
|
|
2965
|
+
/** Timer handle for the wheel-burst → interaction commit. */
|
|
2966
|
+
wheelInteractionTimer = null;
|
|
2967
|
+
/** Snap-target threshold in CSS px — the same feel as the timeline. */
|
|
2968
|
+
static SNAP_PX = 8;
|
|
2969
|
+
constructor(host, editor) {
|
|
2970
|
+
this.host = host;
|
|
2971
|
+
this.editor = editor;
|
|
2972
|
+
this.root = document.createElement("div");
|
|
2973
|
+
this.root.className = "aicut-keyframe-overlay";
|
|
2974
|
+
this.root.setAttribute("data-testid", "aicut-keyframe-overlay");
|
|
2975
|
+
this.root.style.display = "none";
|
|
2976
|
+
this.frameBody = document.createElement("div");
|
|
2977
|
+
this.frameBody.className = "aicut-keyframe-overlay__frame";
|
|
2978
|
+
this.frameBody.setAttribute("data-testid", "aicut-keyframe-frame");
|
|
2979
|
+
this.frameBody.addEventListener("pointerdown", (e) => this.onTransStart(e));
|
|
2980
|
+
this.frameBody.addEventListener(
|
|
2981
|
+
"wheel",
|
|
2982
|
+
(e) => this.onPinchScale(e),
|
|
2983
|
+
{ passive: false }
|
|
2984
|
+
);
|
|
2985
|
+
this.root.appendChild(this.frameBody);
|
|
2986
|
+
this.handles = {
|
|
2987
|
+
tl: this.makeHandle("tl"),
|
|
2988
|
+
tr: this.makeHandle("tr"),
|
|
2989
|
+
bl: this.makeHandle("bl"),
|
|
2990
|
+
br: this.makeHandle("br")
|
|
2991
|
+
};
|
|
2992
|
+
host.appendChild(this.root);
|
|
2993
|
+
this.startTick();
|
|
2994
|
+
}
|
|
2995
|
+
destroy() {
|
|
2996
|
+
this.destroyed = true;
|
|
2997
|
+
if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
|
|
2998
|
+
if (this.wheelInteractionTimer != null) {
|
|
2999
|
+
clearTimeout(this.wheelInteractionTimer);
|
|
3000
|
+
this.wheelInteractionTimer = null;
|
|
3001
|
+
this.editor.endInteraction();
|
|
3002
|
+
}
|
|
3003
|
+
this.root.remove();
|
|
3004
|
+
}
|
|
3005
|
+
// ---- frame body drag (translate) -------------------------------------
|
|
3006
|
+
onTransStart(e) {
|
|
3007
|
+
if (e.button !== 0) return;
|
|
3008
|
+
const ctx = this.ensureSelectedClip();
|
|
3009
|
+
if (!ctx) return;
|
|
3010
|
+
e.preventDefault();
|
|
3011
|
+
e.stopPropagation();
|
|
3012
|
+
this.frameBody.setPointerCapture(e.pointerId);
|
|
3013
|
+
this.capturedPointerId = e.pointerId;
|
|
3014
|
+
this.drag = {
|
|
3015
|
+
kind: "translate",
|
|
3016
|
+
clipId: ctx.clip.id,
|
|
3017
|
+
pointerStartX: e.clientX,
|
|
3018
|
+
pointerStartY: e.clientY,
|
|
3019
|
+
startPanX: ctx.transform.panX,
|
|
3020
|
+
startPanY: ctx.transform.panY
|
|
3021
|
+
};
|
|
3022
|
+
this.editor.beginInteraction();
|
|
3023
|
+
this.frameBody.addEventListener("pointermove", this.onPointerMove);
|
|
3024
|
+
this.frameBody.addEventListener("pointerup", this.onPointerUp);
|
|
3025
|
+
this.frameBody.addEventListener("pointercancel", this.onPointerUp);
|
|
3026
|
+
}
|
|
3027
|
+
// ---- pinch-to-scale --------------------------------------------------
|
|
3028
|
+
onPinchScale(e) {
|
|
3029
|
+
if (!e.ctrlKey) return;
|
|
3030
|
+
const ctx = this.ensureSelectedClip();
|
|
3031
|
+
if (!ctx) return;
|
|
3032
|
+
e.preventDefault();
|
|
3033
|
+
e.stopPropagation();
|
|
3034
|
+
const step = Math.max(-50, Math.min(50, -e.deltaY));
|
|
3035
|
+
const factor = Math.exp(step * 0.01);
|
|
3036
|
+
const next = Math.max(
|
|
3037
|
+
0.05,
|
|
3038
|
+
Math.min(16, ctx.transform.scale * factor)
|
|
3039
|
+
);
|
|
3040
|
+
if (this.wheelInteractionTimer == null) {
|
|
3041
|
+
this.editor.beginInteraction();
|
|
3042
|
+
} else {
|
|
3043
|
+
clearTimeout(this.wheelInteractionTimer);
|
|
3044
|
+
}
|
|
3045
|
+
this.wheelInteractionTimer = window.setTimeout(() => {
|
|
3046
|
+
this.wheelInteractionTimer = null;
|
|
3047
|
+
this.editor.endInteraction();
|
|
3048
|
+
}, 200);
|
|
3049
|
+
this.editor.setValueAtPlayhead(
|
|
3050
|
+
ctx.clip.id,
|
|
3051
|
+
"scale",
|
|
3052
|
+
Math.round(next * 100) / 100
|
|
3053
|
+
);
|
|
3054
|
+
}
|
|
3055
|
+
// ---- corner-handle drag (scale) --------------------------------------
|
|
3056
|
+
onScaleStart(corner, e) {
|
|
3057
|
+
if (e.button !== 0) return;
|
|
3058
|
+
const ctx = this.ensureSelectedClip();
|
|
3059
|
+
if (!ctx) return;
|
|
3060
|
+
e.preventDefault();
|
|
3061
|
+
e.stopPropagation();
|
|
3062
|
+
const rect = this.editor.getActiveOutputFrameRect() ?? this.editor.getActiveFrameRect();
|
|
3063
|
+
if (!rect) return;
|
|
3064
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
3065
|
+
const cx = hostRect.left + rect.x + rect.w / 2;
|
|
3066
|
+
const cy = hostRect.top + rect.y + rect.h / 2;
|
|
3067
|
+
const startDist = Math.hypot(e.clientX - cx, e.clientY - cy);
|
|
3068
|
+
if (startDist < 1) return;
|
|
3069
|
+
const target = this.handles[corner];
|
|
3070
|
+
target.setPointerCapture(e.pointerId);
|
|
3071
|
+
this.capturedPointerId = e.pointerId;
|
|
3072
|
+
this.drag = {
|
|
3073
|
+
kind: "scale",
|
|
3074
|
+
clipId: ctx.clip.id,
|
|
3075
|
+
centerX: cx,
|
|
3076
|
+
centerY: cy,
|
|
3077
|
+
startDistance: startDist,
|
|
3078
|
+
startScale: ctx.transform.scale
|
|
3079
|
+
};
|
|
3080
|
+
this.editor.beginInteraction();
|
|
3081
|
+
target.addEventListener("pointermove", this.onPointerMove);
|
|
3082
|
+
target.addEventListener("pointerup", this.onPointerUp);
|
|
3083
|
+
target.addEventListener("pointercancel", this.onPointerUp);
|
|
3084
|
+
}
|
|
3085
|
+
onPointerMove = (e) => {
|
|
3086
|
+
if (!this.drag) return;
|
|
3087
|
+
if (this.drag.kind === "translate") {
|
|
3088
|
+
const dx = e.clientX - this.drag.pointerStartX;
|
|
3089
|
+
const dy = e.clientY - this.drag.pointerStartY;
|
|
3090
|
+
const rawPanX = this.drag.startPanX + dx;
|
|
3091
|
+
const rawPanY = this.drag.startPanY + dy;
|
|
3092
|
+
const snapped = this.applySnap(this.drag.clipId, rawPanX, rawPanY);
|
|
3093
|
+
this.editor.setValueAtPlayhead(
|
|
3094
|
+
this.drag.clipId,
|
|
3095
|
+
"panX",
|
|
3096
|
+
Math.round(snapped.panX)
|
|
3097
|
+
);
|
|
3098
|
+
this.editor.setValueAtPlayhead(
|
|
3099
|
+
this.drag.clipId,
|
|
3100
|
+
"panY",
|
|
3101
|
+
Math.round(snapped.panY)
|
|
3102
|
+
);
|
|
3103
|
+
} else {
|
|
3104
|
+
const dist = Math.hypot(
|
|
3105
|
+
e.clientX - this.drag.centerX,
|
|
3106
|
+
e.clientY - this.drag.centerY
|
|
3107
|
+
);
|
|
3108
|
+
const ratio = dist / this.drag.startDistance;
|
|
3109
|
+
const next = Math.max(
|
|
3110
|
+
0.05,
|
|
3111
|
+
Math.min(16, this.drag.startScale * ratio)
|
|
3112
|
+
);
|
|
3113
|
+
this.editor.setValueAtPlayhead(
|
|
3114
|
+
this.drag.clipId,
|
|
3115
|
+
"scale",
|
|
3116
|
+
Math.round(next * 100) / 100
|
|
3117
|
+
);
|
|
3118
|
+
}
|
|
3119
|
+
};
|
|
3120
|
+
/**
|
|
3121
|
+
* Snap raw pan to: centered (panX/Y = 0) and the four edge-alignment
|
|
3122
|
+
* stops (content's L/R/T/B edge flush with the output's matching
|
|
3123
|
+
* edge). When content is smaller than output, the edge stops collapse
|
|
3124
|
+
* to the same point as 0 — harmless dup. Threshold = 8 CSS px.
|
|
3125
|
+
*/
|
|
3126
|
+
applySnap(clipId, rawPanX, rawPanY) {
|
|
3127
|
+
const out = this.editor.getActiveOutputFrameRect();
|
|
3128
|
+
if (!out) return { panX: rawPanX, panY: rawPanY };
|
|
3129
|
+
const clip = this.findClip(clipId);
|
|
3130
|
+
if (!clip) return { panX: rawPanX, panY: rawPanY };
|
|
3131
|
+
const t = (() => {
|
|
3132
|
+
try {
|
|
3133
|
+
const transformer = this.editor.getActiveFrameRect();
|
|
3134
|
+
if (!transformer) return null;
|
|
3135
|
+
return { w: transformer.w, h: transformer.h };
|
|
3136
|
+
} catch {
|
|
3137
|
+
return null;
|
|
3138
|
+
}
|
|
3139
|
+
})();
|
|
3140
|
+
const contentW = t?.w ?? out.w;
|
|
3141
|
+
const contentH = t?.h ?? out.h;
|
|
3142
|
+
const edgeX = (contentW - out.w) / 2;
|
|
3143
|
+
const edgeY = (contentH - out.h) / 2;
|
|
3144
|
+
const xTargets = [0, edgeX, -edgeX];
|
|
3145
|
+
const yTargets = [0, edgeY, -edgeY];
|
|
3146
|
+
const px = nearestSnap(rawPanX, xTargets, _KeyframeOverlay.SNAP_PX);
|
|
3147
|
+
const py = nearestSnap(rawPanY, yTargets, _KeyframeOverlay.SNAP_PX);
|
|
3148
|
+
return { panX: px, panY: py };
|
|
3149
|
+
}
|
|
3150
|
+
findClip(clipId) {
|
|
3151
|
+
const project = this.editor.getProject();
|
|
3152
|
+
for (const t of project.tracks) {
|
|
3153
|
+
const c = t.clips.find((cl) => cl.id === clipId);
|
|
3154
|
+
if (c) return c;
|
|
3155
|
+
}
|
|
3156
|
+
return null;
|
|
3157
|
+
}
|
|
3158
|
+
onPointerUp = (e) => {
|
|
3159
|
+
if (!this.drag) return;
|
|
3160
|
+
const targetEl = e.currentTarget;
|
|
3161
|
+
if (targetEl && this.capturedPointerId === e.pointerId) {
|
|
3162
|
+
try {
|
|
3163
|
+
targetEl.releasePointerCapture(e.pointerId);
|
|
3164
|
+
} catch {
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
targetEl?.removeEventListener("pointermove", this.onPointerMove);
|
|
3168
|
+
targetEl?.removeEventListener("pointerup", this.onPointerUp);
|
|
3169
|
+
targetEl?.removeEventListener("pointercancel", this.onPointerUp);
|
|
3170
|
+
this.drag = null;
|
|
3171
|
+
this.capturedPointerId = null;
|
|
3172
|
+
this.editor.endInteraction();
|
|
3173
|
+
};
|
|
3174
|
+
// ---- per-frame layout ------------------------------------------------
|
|
3175
|
+
startTick() {
|
|
3176
|
+
const tick = () => {
|
|
3177
|
+
if (this.destroyed) return;
|
|
3178
|
+
this.layout();
|
|
3179
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
3180
|
+
};
|
|
3181
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
3182
|
+
}
|
|
3183
|
+
layout() {
|
|
3184
|
+
const enabled = this.editor.isKeyframesEnabled();
|
|
3185
|
+
if (!enabled) {
|
|
3186
|
+
this.root.style.display = "none";
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
const outRect = this.editor.getActiveOutputFrameRect();
|
|
3190
|
+
const contentRect = this.editor.getActiveFrameRect() ?? outRect;
|
|
3191
|
+
if (!outRect) {
|
|
3192
|
+
this.root.style.display = "none";
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
this.root.style.display = "block";
|
|
3196
|
+
Object.assign(this.frameBody.style, {
|
|
3197
|
+
left: `${outRect.x}px`,
|
|
3198
|
+
top: `${outRect.y}px`,
|
|
3199
|
+
width: `${outRect.w}px`,
|
|
3200
|
+
height: `${outRect.h}px`
|
|
3201
|
+
});
|
|
3202
|
+
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;
|
|
3203
|
+
this.frameBody.classList.toggle(
|
|
3204
|
+
"aicut-keyframe-overlay__frame--warn",
|
|
3205
|
+
!fullyCovered
|
|
3206
|
+
);
|
|
3207
|
+
const halfHandle = 6;
|
|
3208
|
+
const r = contentRect ?? outRect;
|
|
3209
|
+
const fbLeft = r.x;
|
|
3210
|
+
const fbTop = r.y;
|
|
3211
|
+
const fbRight = r.x + r.w;
|
|
3212
|
+
const fbBottom = r.y + r.h;
|
|
3213
|
+
const place = (el, cx, cy) => {
|
|
3214
|
+
el.style.left = `${cx - halfHandle}px`;
|
|
3215
|
+
el.style.top = `${cy - halfHandle}px`;
|
|
3216
|
+
};
|
|
3217
|
+
place(this.handles.tl, fbLeft, fbTop);
|
|
3218
|
+
place(this.handles.tr, fbRight, fbTop);
|
|
3219
|
+
place(this.handles.bl, fbLeft, fbBottom);
|
|
3220
|
+
place(this.handles.br, fbRight, fbBottom);
|
|
3221
|
+
}
|
|
3222
|
+
// ---- helpers ---------------------------------------------------------
|
|
3223
|
+
makeHandle(name) {
|
|
3224
|
+
const el = document.createElement("div");
|
|
3225
|
+
el.className = `aicut-keyframe-overlay__handle aicut-keyframe-overlay__handle--${name}`;
|
|
3226
|
+
el.setAttribute("data-testid", `aicut-keyframe-handle-${name}`);
|
|
3227
|
+
el.addEventListener("pointerdown", (e) => this.onScaleStart(name, e));
|
|
3228
|
+
this.root.appendChild(el);
|
|
3229
|
+
return el;
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Resolve the currently selected clip + its current effective
|
|
3233
|
+
* transform (so drag baselines are correct). Returns null when no
|
|
3234
|
+
* clip is selected or the playhead isn't over it.
|
|
3235
|
+
*/
|
|
3236
|
+
ensureSelectedClip() {
|
|
3237
|
+
const selectedClipId = this.editor.getSelection();
|
|
3238
|
+
if (!selectedClipId) return null;
|
|
3239
|
+
const project = this.editor.getProject();
|
|
3240
|
+
let clip = null;
|
|
3241
|
+
for (const t of project.tracks) {
|
|
3242
|
+
const c = t.clips.find((cl) => cl.id === selectedClipId);
|
|
3243
|
+
if (c) {
|
|
3244
|
+
clip = c;
|
|
3245
|
+
break;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
if (!clip) return null;
|
|
3249
|
+
const playheadLocal = this.editor.getTime() - clip.start;
|
|
3250
|
+
if (playheadLocal < 0 || playheadLocal > clip.out - clip.in) {
|
|
3251
|
+
return null;
|
|
3252
|
+
}
|
|
3253
|
+
const transform = getEffectiveTransform(clip, playheadLocal);
|
|
3254
|
+
return { clip, transform };
|
|
3255
|
+
}
|
|
3256
|
+
};
|
|
3257
|
+
function nearestSnap(raw, targets, threshold) {
|
|
3258
|
+
let best = raw;
|
|
3259
|
+
let bestDist = threshold;
|
|
3260
|
+
for (const t of targets) {
|
|
3261
|
+
const d = Math.abs(raw - t);
|
|
3262
|
+
if (d < bestDist) {
|
|
3263
|
+
bestDist = d;
|
|
3264
|
+
best = t;
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
return best;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// src/ui/keyframe-panel.ts
|
|
3271
|
+
var EASING_VALUES = [
|
|
3272
|
+
"linear",
|
|
3273
|
+
"easeIn",
|
|
3274
|
+
"easeOut",
|
|
3275
|
+
"easeInOut"
|
|
3276
|
+
];
|
|
3277
|
+
function easingLabel(value, locale) {
|
|
3278
|
+
switch (value) {
|
|
3279
|
+
case "linear":
|
|
3280
|
+
return locale.keyframeEasingLinear;
|
|
3281
|
+
case "easeIn":
|
|
3282
|
+
return locale.keyframeEasingEaseIn;
|
|
3283
|
+
case "easeOut":
|
|
3284
|
+
return locale.keyframeEasingEaseOut;
|
|
3285
|
+
case "easeInOut":
|
|
3286
|
+
return locale.keyframeEasingEaseInOut;
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
var TIME_EPS_MS = 16;
|
|
3290
|
+
var KeyframePanel = class {
|
|
3291
|
+
editor;
|
|
3292
|
+
locale;
|
|
3293
|
+
root;
|
|
3294
|
+
inputs;
|
|
3295
|
+
kfBadges;
|
|
3296
|
+
timeLabel;
|
|
3297
|
+
titleLabel;
|
|
3298
|
+
resetBtn;
|
|
3299
|
+
easingTrigger;
|
|
3300
|
+
easingTriggerLabel;
|
|
3301
|
+
easingMenu;
|
|
3302
|
+
easingItems;
|
|
3303
|
+
easingValue = "linear";
|
|
3304
|
+
easingDisabled = false;
|
|
3305
|
+
easingOpen = false;
|
|
3306
|
+
easingLabelEl;
|
|
3307
|
+
rowLabels;
|
|
3308
|
+
lastSyncKey = "";
|
|
3309
|
+
// Bound once so add/remove listener pairs reference the same fn.
|
|
3310
|
+
boundOutsideClick = null;
|
|
3311
|
+
boundDocKeydown = null;
|
|
3312
|
+
constructor(host, editor, locale) {
|
|
3313
|
+
this.editor = editor;
|
|
3314
|
+
this.locale = locale;
|
|
3315
|
+
this.root = document.createElement("div");
|
|
3316
|
+
this.root.className = "aicut-keyframe-panel";
|
|
3317
|
+
this.root.setAttribute("data-testid", "aicut-keyframe-panel");
|
|
3318
|
+
this.root.style.display = "none";
|
|
3319
|
+
this.root.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
3320
|
+
this.root.addEventListener("wheel", (e) => e.stopPropagation());
|
|
3321
|
+
const title = document.createElement("div");
|
|
3322
|
+
title.className = "aicut-keyframe-panel__title";
|
|
3323
|
+
this.titleLabel = document.createElement("span");
|
|
3324
|
+
this.timeLabel = document.createElement("span");
|
|
3325
|
+
this.timeLabel.className = "aicut-keyframe-panel__time";
|
|
3326
|
+
title.append(this.titleLabel, this.timeLabel);
|
|
3327
|
+
this.root.appendChild(title);
|
|
3328
|
+
const xRow = this.makeRow("kf-x", "panX", 1);
|
|
3329
|
+
const yRow = this.makeRow("kf-y", "panY", 1);
|
|
3330
|
+
const scaleRow = this.makeRow("kf-scale", "scale", 0.05);
|
|
3331
|
+
this.inputs = {
|
|
3332
|
+
panX: xRow.input,
|
|
3333
|
+
panY: yRow.input,
|
|
3334
|
+
scale: scaleRow.input
|
|
3335
|
+
};
|
|
3336
|
+
this.rowLabels = {
|
|
3337
|
+
panX: xRow.label,
|
|
3338
|
+
panY: yRow.label,
|
|
3339
|
+
scale: scaleRow.label
|
|
3340
|
+
};
|
|
3341
|
+
this.kfBadges = {
|
|
3342
|
+
panX: this.makeBadge(this.inputs.panX),
|
|
3343
|
+
panY: this.makeBadge(this.inputs.panY),
|
|
3344
|
+
scale: this.makeBadge(this.inputs.scale)
|
|
3345
|
+
};
|
|
3346
|
+
const easingRow = document.createElement("div");
|
|
3347
|
+
easingRow.className = "aicut-keyframe-panel__row aicut-keyframe-panel__row--easing";
|
|
3348
|
+
this.easingLabelEl = document.createElement("label");
|
|
3349
|
+
const dd = document.createElement("div");
|
|
3350
|
+
dd.className = "aicut-keyframe-panel__dropdown";
|
|
3351
|
+
dd.setAttribute("data-testid", "aicut-kf-easing");
|
|
3352
|
+
this.easingTrigger = document.createElement("button");
|
|
3353
|
+
this.easingTrigger.type = "button";
|
|
3354
|
+
this.easingTrigger.className = "aicut-keyframe-panel__dropdown-trigger";
|
|
3355
|
+
this.easingTrigger.setAttribute("aria-haspopup", "listbox");
|
|
3356
|
+
this.easingTrigger.setAttribute("aria-expanded", "false");
|
|
3357
|
+
this.easingTriggerLabel = document.createElement("span");
|
|
3358
|
+
this.easingTriggerLabel.className = "aicut-keyframe-panel__dropdown-trigger-label";
|
|
3359
|
+
const chevron = document.createElement("span");
|
|
3360
|
+
chevron.className = "aicut-keyframe-panel__dropdown-chevron";
|
|
3361
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
3362
|
+
this.easingTrigger.append(this.easingTriggerLabel, chevron);
|
|
3363
|
+
this.easingTrigger.addEventListener("click", (e) => {
|
|
3364
|
+
e.stopPropagation();
|
|
3365
|
+
if (this.easingDisabled) return;
|
|
3366
|
+
this.toggleEasingMenu();
|
|
3367
|
+
});
|
|
3368
|
+
this.easingTrigger.addEventListener("keydown", (e) => {
|
|
3369
|
+
if (this.easingDisabled) return;
|
|
3370
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
3371
|
+
e.preventDefault();
|
|
3372
|
+
if (!this.easingOpen) this.openEasingMenu();
|
|
3373
|
+
this.easingItems.get(this.easingValue)?.focus();
|
|
3374
|
+
}
|
|
3375
|
+
});
|
|
3376
|
+
this.easingMenu = document.createElement("ul");
|
|
3377
|
+
this.easingMenu.className = "aicut-keyframe-panel__dropdown-menu";
|
|
3378
|
+
this.easingMenu.setAttribute("role", "listbox");
|
|
3379
|
+
this.easingMenu.style.display = "none";
|
|
3380
|
+
this.easingItems = /* @__PURE__ */ new Map();
|
|
3381
|
+
for (const value of EASING_VALUES) {
|
|
3382
|
+
const li = document.createElement("li");
|
|
3383
|
+
li.className = "aicut-keyframe-panel__dropdown-item";
|
|
3384
|
+
li.setAttribute("role", "option");
|
|
3385
|
+
li.setAttribute("data-value", value);
|
|
3386
|
+
li.setAttribute("tabindex", "-1");
|
|
3387
|
+
li.addEventListener("click", (e) => {
|
|
3388
|
+
e.stopPropagation();
|
|
3389
|
+
this.selectEasing(value);
|
|
3390
|
+
});
|
|
3391
|
+
li.addEventListener("keydown", (e) => this.onMenuKeydown(e, value));
|
|
3392
|
+
this.easingItems.set(value, li);
|
|
3393
|
+
this.easingMenu.appendChild(li);
|
|
3394
|
+
}
|
|
3395
|
+
dd.append(this.easingTrigger, this.easingMenu);
|
|
3396
|
+
easingRow.append(this.easingLabelEl, dd);
|
|
3397
|
+
this.root.appendChild(easingRow);
|
|
3398
|
+
const actions = document.createElement("div");
|
|
3399
|
+
actions.className = "aicut-keyframe-panel__actions";
|
|
3400
|
+
this.resetBtn = document.createElement("button");
|
|
3401
|
+
this.resetBtn.type = "button";
|
|
3402
|
+
this.resetBtn.className = "aicut-keyframe-panel__reset";
|
|
3403
|
+
this.resetBtn.setAttribute("data-testid", "aicut-keyframe-reset");
|
|
3404
|
+
this.resetBtn.addEventListener("click", () => this.onReset());
|
|
3405
|
+
actions.appendChild(this.resetBtn);
|
|
3406
|
+
this.root.appendChild(actions);
|
|
3407
|
+
this.applyLocaleText();
|
|
3408
|
+
host.appendChild(this.root);
|
|
3409
|
+
}
|
|
3410
|
+
setLocale(locale) {
|
|
3411
|
+
this.locale = locale;
|
|
3412
|
+
this.applyLocaleText();
|
|
3413
|
+
this.lastSyncKey = "";
|
|
3414
|
+
this.render();
|
|
3415
|
+
}
|
|
3416
|
+
applyLocaleText() {
|
|
3417
|
+
this.titleLabel.textContent = this.locale.keyframePanelTitle;
|
|
3418
|
+
this.rowLabels.panX.textContent = this.locale.keyframePanelLabelX;
|
|
3419
|
+
this.rowLabels.panY.textContent = this.locale.keyframePanelLabelY;
|
|
3420
|
+
this.rowLabels.scale.textContent = this.locale.keyframePanelLabelScale;
|
|
3421
|
+
this.easingLabelEl.textContent = this.locale.keyframePanelLabelEasing;
|
|
3422
|
+
this.resetBtn.textContent = this.locale.keyframePanelReset;
|
|
3423
|
+
this.resetBtn.title = this.locale.keyframePanelResetTitle;
|
|
3424
|
+
for (const [value, li] of this.easingItems) {
|
|
3425
|
+
li.textContent = easingLabel(value, this.locale);
|
|
3426
|
+
}
|
|
3427
|
+
this.easingTriggerLabel.textContent = easingLabel(
|
|
3428
|
+
this.easingValue,
|
|
3429
|
+
this.locale
|
|
3430
|
+
);
|
|
3431
|
+
}
|
|
3432
|
+
destroy() {
|
|
3433
|
+
this.closeEasingMenu();
|
|
3434
|
+
this.root.remove();
|
|
3435
|
+
}
|
|
3436
|
+
render() {
|
|
3437
|
+
const enabled = this.editor.isKeyframesEnabled();
|
|
3438
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3439
|
+
if (!enabled || !sel) {
|
|
3440
|
+
this.root.style.display = "none";
|
|
3441
|
+
this.lastSyncKey = "";
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3444
|
+
const clip = this.findClip(sel.clipId);
|
|
3445
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3446
|
+
if (!clip || !anchorKf) {
|
|
3447
|
+
this.root.style.display = "none";
|
|
3448
|
+
this.lastSyncKey = "";
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
const time = anchorKf.time;
|
|
3452
|
+
const moment = (clip.keyframes ?? []).filter(
|
|
3453
|
+
(k) => Math.abs(k.time - time) < TIME_EPS_MS
|
|
3454
|
+
);
|
|
3455
|
+
const interp = getEffectiveTransform(clip, time);
|
|
3456
|
+
const valueOf = (prop) => {
|
|
3457
|
+
const m = moment.find((k) => k.prop === prop);
|
|
3458
|
+
if (m) return m.value;
|
|
3459
|
+
return interp[prop];
|
|
3460
|
+
};
|
|
3461
|
+
const v = {
|
|
3462
|
+
panX: valueOf("panX"),
|
|
3463
|
+
panY: valueOf("panY"),
|
|
3464
|
+
scale: valueOf("scale")
|
|
3465
|
+
};
|
|
3466
|
+
const sharedEasing = (() => {
|
|
3467
|
+
if (moment.length === 0) return "linear";
|
|
3468
|
+
const anchor = moment.find((k) => k.id === sel.keyframeId) ?? moment[0];
|
|
3469
|
+
return anchor.easing ?? "linear";
|
|
3470
|
+
})();
|
|
3471
|
+
const syncKey = `${clip.id}|${time}|${v.panX.toFixed(2)}|${v.panY.toFixed(2)}|${v.scale.toFixed(4)}|${moment.map((m) => m.prop).join(",")}|${sharedEasing}`;
|
|
3472
|
+
this.root.style.display = "flex";
|
|
3473
|
+
if (syncKey === this.lastSyncKey) return;
|
|
3474
|
+
this.lastSyncKey = syncKey;
|
|
3475
|
+
this.setIfBlur(this.inputs.panX, String(Math.round(v.panX)));
|
|
3476
|
+
this.setIfBlur(this.inputs.panY, String(Math.round(v.panY)));
|
|
3477
|
+
this.setIfBlur(this.inputs.scale, v.scale.toFixed(2));
|
|
3478
|
+
this.timeLabel.textContent = `${(time / 1e3).toFixed(2)}${this.locale.keyframePanelTimeSuffix}`;
|
|
3479
|
+
this.setEasingValue(sharedEasing);
|
|
3480
|
+
this.setEasingDisabled(moment.length === 0);
|
|
3481
|
+
for (const p of ["panX", "panY", "scale"]) {
|
|
3482
|
+
const animated = moment.some((k) => k.prop === p) || hasKeyframesForProp(clip, p);
|
|
3483
|
+
const pinned = moment.some((k) => k.prop === p);
|
|
3484
|
+
this.kfBadges[p].classList.toggle(
|
|
3485
|
+
"aicut-keyframe-panel__badge--on",
|
|
3486
|
+
pinned
|
|
3487
|
+
);
|
|
3488
|
+
this.kfBadges[p].title = pinned ? this.locale.keyframePanelBadgePinned : animated ? this.locale.keyframePanelBadgeAnimated : this.locale.keyframePanelBadgeStatic;
|
|
3489
|
+
}
|
|
3490
|
+
this.resetBtn.disabled = false;
|
|
3491
|
+
}
|
|
3492
|
+
// ---- internals ------------------------------------------------------
|
|
3493
|
+
makeRow(testId, prop, step) {
|
|
3494
|
+
const row = document.createElement("div");
|
|
3495
|
+
row.className = "aicut-keyframe-panel__row";
|
|
3496
|
+
const lab = document.createElement("label");
|
|
3497
|
+
const input = document.createElement("input");
|
|
3498
|
+
input.type = "number";
|
|
3499
|
+
input.step = String(step);
|
|
3500
|
+
input.setAttribute("data-testid", `aicut-${testId}`);
|
|
3501
|
+
input.addEventListener("blur", () => this.commit(prop, input.value));
|
|
3502
|
+
input.addEventListener("keydown", (e) => {
|
|
3503
|
+
if (e.key === "Enter") input.blur();
|
|
2125
3504
|
});
|
|
2126
|
-
|
|
3505
|
+
row.append(lab, input);
|
|
3506
|
+
this.root.appendChild(row);
|
|
3507
|
+
return { input, label: lab };
|
|
3508
|
+
}
|
|
3509
|
+
makeBadge(input) {
|
|
3510
|
+
const dot = document.createElement("span");
|
|
3511
|
+
dot.className = "aicut-keyframe-panel__badge";
|
|
3512
|
+
input.parentElement?.appendChild(dot);
|
|
3513
|
+
return dot;
|
|
3514
|
+
}
|
|
3515
|
+
commit(prop, raw) {
|
|
3516
|
+
const num = Number(raw);
|
|
3517
|
+
if (!Number.isFinite(num)) return;
|
|
3518
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3519
|
+
if (!sel) return;
|
|
3520
|
+
const clip = this.findClip(sel.clipId);
|
|
3521
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3522
|
+
if (!clip || !anchorKf) return;
|
|
3523
|
+
this.editor.addKeyframe(sel.clipId, prop, {
|
|
3524
|
+
time: anchorKf.time,
|
|
3525
|
+
value: num
|
|
3526
|
+
});
|
|
3527
|
+
if (anchorKf.prop !== prop) {
|
|
3528
|
+
const refreshedClip = this.findClip(sel.clipId);
|
|
3529
|
+
const created = (refreshedClip?.keyframes ?? []).find(
|
|
3530
|
+
(k) => k.prop === prop && Math.abs(k.time - anchorKf.time) < TIME_EPS_MS
|
|
3531
|
+
);
|
|
3532
|
+
if (created) {
|
|
3533
|
+
this.editor.setSelectedKeyframe({
|
|
3534
|
+
clipId: sel.clipId,
|
|
3535
|
+
keyframeId: created.id
|
|
3536
|
+
});
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
2127
3539
|
}
|
|
2128
|
-
// ----
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
3540
|
+
// ---- custom dropdown -------------------------------------------------
|
|
3541
|
+
setEasingValue(value) {
|
|
3542
|
+
if (this.easingValue === value) return;
|
|
3543
|
+
this.easingValue = value;
|
|
3544
|
+
this.easingTriggerLabel.textContent = easingLabel(value, this.locale);
|
|
3545
|
+
for (const [v, li] of this.easingItems) {
|
|
3546
|
+
li.classList.toggle(
|
|
3547
|
+
"aicut-keyframe-panel__dropdown-item--selected",
|
|
3548
|
+
v === value
|
|
3549
|
+
);
|
|
3550
|
+
li.setAttribute("aria-selected", v === value ? "true" : "false");
|
|
3551
|
+
}
|
|
2132
3552
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
3553
|
+
setEasingDisabled(disabled) {
|
|
3554
|
+
if (this.easingDisabled === disabled) return;
|
|
3555
|
+
this.easingDisabled = disabled;
|
|
3556
|
+
this.easingTrigger.disabled = disabled;
|
|
3557
|
+
this.easingTrigger.classList.toggle(
|
|
3558
|
+
"aicut-keyframe-panel__dropdown-trigger--disabled",
|
|
3559
|
+
disabled
|
|
3560
|
+
);
|
|
3561
|
+
if (disabled && this.easingOpen) this.closeEasingMenu();
|
|
3562
|
+
}
|
|
3563
|
+
toggleEasingMenu() {
|
|
3564
|
+
if (this.easingOpen) this.closeEasingMenu();
|
|
3565
|
+
else this.openEasingMenu();
|
|
3566
|
+
}
|
|
3567
|
+
openEasingMenu() {
|
|
3568
|
+
if (this.easingOpen || this.easingDisabled) return;
|
|
3569
|
+
this.easingOpen = true;
|
|
3570
|
+
this.easingMenu.style.display = "";
|
|
3571
|
+
this.easingTrigger.setAttribute("aria-expanded", "true");
|
|
3572
|
+
this.easingTrigger.classList.add(
|
|
3573
|
+
"aicut-keyframe-panel__dropdown-trigger--open"
|
|
3574
|
+
);
|
|
3575
|
+
requestAnimationFrame(() => {
|
|
3576
|
+
if (!this.easingOpen) return;
|
|
3577
|
+
this.boundOutsideClick = (e) => {
|
|
3578
|
+
if (!this.easingMenu.contains(e.target) && !this.easingTrigger.contains(e.target)) {
|
|
3579
|
+
this.closeEasingMenu();
|
|
3580
|
+
}
|
|
3581
|
+
};
|
|
3582
|
+
this.boundDocKeydown = (e) => {
|
|
3583
|
+
if (e.key === "Escape") {
|
|
3584
|
+
e.stopPropagation();
|
|
3585
|
+
this.closeEasingMenu();
|
|
3586
|
+
this.easingTrigger.focus();
|
|
3587
|
+
} else if (e.key === "Tab") {
|
|
3588
|
+
this.closeEasingMenu();
|
|
3589
|
+
}
|
|
3590
|
+
};
|
|
3591
|
+
document.addEventListener("click", this.boundOutsideClick, true);
|
|
3592
|
+
document.addEventListener("keydown", this.boundDocKeydown);
|
|
2143
3593
|
});
|
|
2144
3594
|
}
|
|
2145
|
-
|
|
2146
|
-
|
|
3595
|
+
closeEasingMenu() {
|
|
3596
|
+
if (!this.easingOpen) return;
|
|
3597
|
+
this.easingOpen = false;
|
|
3598
|
+
this.easingMenu.style.display = "none";
|
|
3599
|
+
this.easingTrigger.setAttribute("aria-expanded", "false");
|
|
3600
|
+
this.easingTrigger.classList.remove(
|
|
3601
|
+
"aicut-keyframe-panel__dropdown-trigger--open"
|
|
3602
|
+
);
|
|
3603
|
+
if (this.boundOutsideClick) {
|
|
3604
|
+
document.removeEventListener("click", this.boundOutsideClick, true);
|
|
3605
|
+
this.boundOutsideClick = null;
|
|
3606
|
+
}
|
|
3607
|
+
if (this.boundDocKeydown) {
|
|
3608
|
+
document.removeEventListener("keydown", this.boundDocKeydown);
|
|
3609
|
+
this.boundDocKeydown = null;
|
|
3610
|
+
}
|
|
2147
3611
|
}
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
3612
|
+
selectEasing(value) {
|
|
3613
|
+
this.closeEasingMenu();
|
|
3614
|
+
this.easingTrigger.focus();
|
|
3615
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3616
|
+
if (!sel) return;
|
|
3617
|
+
const clip = this.findClip(sel.clipId);
|
|
3618
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3619
|
+
if (!clip || !anchorKf) return;
|
|
3620
|
+
this.editor.setKeyframesEasingAtTime(sel.clipId, anchorKf.time, value);
|
|
3621
|
+
}
|
|
3622
|
+
onMenuKeydown(e, value) {
|
|
3623
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
3624
|
+
e.preventDefault();
|
|
3625
|
+
this.selectEasing(value);
|
|
3626
|
+
return;
|
|
2152
3627
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
const d = Math.abs(t - ms);
|
|
2159
|
-
if (d < bestDist) {
|
|
2160
|
-
bestDist = d;
|
|
2161
|
-
best = t;
|
|
2162
|
-
}
|
|
3628
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
3629
|
+
e.preventDefault();
|
|
3630
|
+
const idx = EASING_VALUES.indexOf(value);
|
|
3631
|
+
const next = e.key === "ArrowDown" ? EASING_VALUES[(idx + 1) % EASING_VALUES.length] : EASING_VALUES[(idx - 1 + EASING_VALUES.length) % EASING_VALUES.length];
|
|
3632
|
+
this.easingItems.get(next)?.focus();
|
|
2163
3633
|
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
3634
|
+
}
|
|
3635
|
+
onReset() {
|
|
3636
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3637
|
+
if (!sel) return;
|
|
3638
|
+
const clip = this.findClip(sel.clipId);
|
|
3639
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3640
|
+
if (!clip || !anchorKf) return;
|
|
3641
|
+
this.editor.resetKeyframesAtTime(sel.clipId, anchorKf.time);
|
|
3642
|
+
}
|
|
3643
|
+
setIfBlur(input, value) {
|
|
3644
|
+
if (document.activeElement === input) return;
|
|
3645
|
+
if (input.value !== value) input.value = value;
|
|
3646
|
+
}
|
|
3647
|
+
findClip(clipId) {
|
|
3648
|
+
const project = this.editor.getProject();
|
|
3649
|
+
for (const t of project.tracks) {
|
|
3650
|
+
const c = t.clips.find((cl) => cl.id === clipId);
|
|
3651
|
+
if (c) return c;
|
|
2169
3652
|
}
|
|
2170
|
-
return
|
|
3653
|
+
return null;
|
|
2171
3654
|
}
|
|
2172
3655
|
};
|
|
2173
3656
|
|
|
@@ -2214,6 +3697,24 @@ var ICONS = {
|
|
|
2214
3697
|
trash: wrap(
|
|
2215
3698
|
`<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>`
|
|
2216
3699
|
),
|
|
3700
|
+
/** "Skip to start" — vertical bar + left-pointing triangle. Sits to
|
|
3701
|
+
* the left of the keyframe diamond so the clip-edge nav cluster
|
|
3702
|
+
* reads as [|◀ ◇ ▶|] = "go to clip start / add kf / go to clip end". */
|
|
3703
|
+
seekClipStart: wrap(
|
|
3704
|
+
`<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>`
|
|
3705
|
+
),
|
|
3706
|
+
/** "Skip to end" — mirror of seekClipStart. */
|
|
3707
|
+
seekClipEnd: wrap(
|
|
3708
|
+
`<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>`
|
|
3709
|
+
),
|
|
3710
|
+
/** Outlined diamond (rotated square) — "add keyframe" affordance. */
|
|
3711
|
+
keyframeOutline: wrap(
|
|
3712
|
+
`<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>`
|
|
3713
|
+
),
|
|
3714
|
+
/** Filled diamond — shown when a keyframe already exists at playhead. */
|
|
3715
|
+
keyframeFilled: wrap(
|
|
3716
|
+
`<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>`
|
|
3717
|
+
),
|
|
2217
3718
|
/** Counter-clockwise circular arrow — "reset to initial layout". */
|
|
2218
3719
|
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>`
|
|
2219
3720
|
};
|
|
@@ -2240,6 +3741,9 @@ var Toolbar = class {
|
|
|
2240
3741
|
splitBtn;
|
|
2241
3742
|
trimLeftBtn;
|
|
2242
3743
|
trimRightBtn;
|
|
3744
|
+
seekClipStartBtn;
|
|
3745
|
+
seekClipEndBtn;
|
|
3746
|
+
keyframeBtn;
|
|
2243
3747
|
playBtn;
|
|
2244
3748
|
playIcon;
|
|
2245
3749
|
timeLabel;
|
|
@@ -2265,9 +3769,37 @@ var Toolbar = class {
|
|
|
2265
3769
|
this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
|
|
2266
3770
|
this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
|
|
2267
3771
|
this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
3772
|
+
this.seekClipStartBtn = mkIconButton(
|
|
3773
|
+
"seekClipStart",
|
|
3774
|
+
locale.seekClipStart,
|
|
3775
|
+
() => cb.onSeekClipStart(),
|
|
3776
|
+
"aicut-seek-clip-start"
|
|
3777
|
+
);
|
|
3778
|
+
this.seekClipStartBtn.style.display = "none";
|
|
3779
|
+
this.keyframeBtn = mkIconButton(
|
|
3780
|
+
"keyframeOutline",
|
|
3781
|
+
locale.keyframeAdd,
|
|
3782
|
+
() => cb.onKeyframeToggle(),
|
|
3783
|
+
"aicut-keyframe"
|
|
3784
|
+
);
|
|
3785
|
+
this.keyframeBtn.style.display = "none";
|
|
3786
|
+
this.seekClipEndBtn = mkIconButton(
|
|
3787
|
+
"seekClipEnd",
|
|
3788
|
+
locale.seekClipEnd,
|
|
3789
|
+
() => cb.onSeekClipEnd(),
|
|
3790
|
+
"aicut-seek-clip-end"
|
|
3791
|
+
);
|
|
3792
|
+
this.seekClipEndBtn.style.display = "none";
|
|
3793
|
+
left.append(
|
|
3794
|
+
this.undoBtn,
|
|
3795
|
+
this.redoBtn,
|
|
3796
|
+
this.splitBtn,
|
|
3797
|
+
this.trimLeftBtn,
|
|
3798
|
+
this.trimRightBtn,
|
|
3799
|
+
this.seekClipStartBtn,
|
|
3800
|
+
this.keyframeBtn,
|
|
3801
|
+
this.seekClipEndBtn
|
|
3802
|
+
);
|
|
2271
3803
|
const center = mkGroup("aicut-toolbar-center");
|
|
2272
3804
|
this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
|
|
2273
3805
|
this.playBtn = document.createElement("button");
|
|
@@ -2344,11 +3876,38 @@ var Toolbar = class {
|
|
|
2344
3876
|
this.trimLeftBtn.disabled = !state.canTrim;
|
|
2345
3877
|
this.trimRightBtn.disabled = !state.canTrim;
|
|
2346
3878
|
}
|
|
3879
|
+
if (!this.lastState || this.lastState.clipEdgeNavEnabled !== state.clipEdgeNavEnabled) {
|
|
3880
|
+
const display = state.clipEdgeNavEnabled ? "" : "none";
|
|
3881
|
+
this.seekClipStartBtn.style.display = display;
|
|
3882
|
+
this.seekClipEndBtn.style.display = display;
|
|
3883
|
+
}
|
|
3884
|
+
if (!this.lastState || this.lastState.canSeekClipEdge !== state.canSeekClipEdge) {
|
|
3885
|
+
this.seekClipStartBtn.disabled = !state.canSeekClipEdge;
|
|
3886
|
+
this.seekClipEndBtn.disabled = !state.canSeekClipEdge;
|
|
3887
|
+
}
|
|
2347
3888
|
if (!this.lastState || this.lastState.snap !== state.snap) {
|
|
2348
3889
|
this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
|
|
2349
3890
|
this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
|
|
2350
3891
|
this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
|
|
2351
3892
|
}
|
|
3893
|
+
if (!this.lastState || this.lastState.keyframesEnabled !== state.keyframesEnabled) {
|
|
3894
|
+
this.keyframeBtn.style.display = state.keyframesEnabled ? "" : "none";
|
|
3895
|
+
}
|
|
3896
|
+
if (state.keyframesEnabled) {
|
|
3897
|
+
if (!this.lastState || this.lastState.hasKeyframeAtPlayhead !== state.hasKeyframeAtPlayhead) {
|
|
3898
|
+
this.keyframeBtn.innerHTML = state.hasKeyframeAtPlayhead ? ICONS.keyframeFilled : ICONS.keyframeOutline;
|
|
3899
|
+
const title = state.hasKeyframeAtPlayhead ? this.locale.keyframeRemove : this.locale.keyframeAdd;
|
|
3900
|
+
this.keyframeBtn.title = title;
|
|
3901
|
+
this.keyframeBtn.setAttribute("aria-label", title);
|
|
3902
|
+
this.keyframeBtn.setAttribute(
|
|
3903
|
+
"data-state",
|
|
3904
|
+
state.hasKeyframeAtPlayhead ? "on" : "off"
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
if (!this.lastState || this.lastState.canKeyframe !== state.canKeyframe) {
|
|
3908
|
+
this.keyframeBtn.disabled = !state.canKeyframe;
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
2352
3911
|
if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
|
|
2353
3912
|
const ratio = scaleToSlider(state.pxPerSec);
|
|
2354
3913
|
const nextVal = String(Math.round(ratio * 100));
|
|
@@ -2381,12 +3940,20 @@ var Toolbar = class {
|
|
|
2381
3940
|
applyTitle(this.splitBtn, locale.split);
|
|
2382
3941
|
applyTitle(this.trimLeftBtn, locale.trimLeft);
|
|
2383
3942
|
applyTitle(this.trimRightBtn, locale.trimRight);
|
|
3943
|
+
applyTitle(this.seekClipStartBtn, locale.seekClipStart);
|
|
3944
|
+
applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
|
|
2384
3945
|
applyTitle(this.playBtn, locale.playPause);
|
|
2385
3946
|
applyTitle(this.fullscreenBtn, locale.fullscreen);
|
|
2386
3947
|
applyTitle(this.snapBtn, locale.snap);
|
|
2387
3948
|
applyTitle(this.zoomOutBtn, locale.zoomOut);
|
|
2388
3949
|
applyTitle(this.zoomInBtn, locale.zoomIn);
|
|
2389
3950
|
applyTitle(this.resetBtn, locale.reset);
|
|
3951
|
+
if (this.keyframeBtn) {
|
|
3952
|
+
const hasKf = this.lastState?.hasKeyframeAtPlayhead === true;
|
|
3953
|
+
const t = hasKf ? locale.keyframeRemove : locale.keyframeAdd;
|
|
3954
|
+
this.keyframeBtn.title = t;
|
|
3955
|
+
this.keyframeBtn.setAttribute("aria-label", t);
|
|
3956
|
+
}
|
|
2390
3957
|
if (this.lastState) {
|
|
2391
3958
|
this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
|
|
2392
3959
|
}
|
|
@@ -2446,6 +4013,8 @@ var EditorUI = class {
|
|
|
2446
4013
|
toolbar;
|
|
2447
4014
|
timelineHost;
|
|
2448
4015
|
timeline;
|
|
4016
|
+
keyframePanel;
|
|
4017
|
+
keyframeOverlay;
|
|
2449
4018
|
fullscreen = false;
|
|
2450
4019
|
onDocKeydown = null;
|
|
2451
4020
|
constructor(root, editor, cb) {
|
|
@@ -2492,10 +4061,14 @@ var EditorUI = class {
|
|
|
2492
4061
|
snap: editor.getSnap(),
|
|
2493
4062
|
autoFit: true,
|
|
2494
4063
|
locale,
|
|
4064
|
+
keyframesEnabled: editor.isKeyframesEnabled(),
|
|
4065
|
+
selectedKeyframe: editor.getSelectedKeyframe(),
|
|
2495
4066
|
onSeek: cb.onSeek,
|
|
2496
4067
|
onSelectClip: cb.onSelectClip,
|
|
2497
4068
|
onMoveClip: cb.onMoveClip,
|
|
2498
4069
|
onResizeClip: cb.onResizeClip,
|
|
4070
|
+
onSelectKeyframe: cb.onSelectKeyframe,
|
|
4071
|
+
onMoveKeyframe: cb.onMoveKeyframe,
|
|
2499
4072
|
onScaleChange: cb.onScaleChange,
|
|
2500
4073
|
onDeleteTrack: (trackId) => editor.removeTrack(trackId),
|
|
2501
4074
|
// Mirror the editor's smart routing into the drag preview so
|
|
@@ -2520,6 +4093,8 @@ var EditorUI = class {
|
|
|
2520
4093
|
};
|
|
2521
4094
|
}
|
|
2522
4095
|
});
|
|
4096
|
+
this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
|
|
4097
|
+
this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
|
|
2523
4098
|
this.attachKeyboard(cb);
|
|
2524
4099
|
}
|
|
2525
4100
|
// ---- fullscreen -----------------------------------------------------
|
|
@@ -2569,6 +4144,13 @@ var EditorUI = class {
|
|
|
2569
4144
|
const selectedClipId = this.editor.getSelection();
|
|
2570
4145
|
const pxPerSec = this.editor.getScale();
|
|
2571
4146
|
const snap = this.editor.getSnap();
|
|
4147
|
+
const kfEnabled = this.editor.isKeyframesEnabled();
|
|
4148
|
+
const kfState = this.computeKeyframeToolbarState(
|
|
4149
|
+
project,
|
|
4150
|
+
selectedClipId,
|
|
4151
|
+
time,
|
|
4152
|
+
kfEnabled
|
|
4153
|
+
);
|
|
2572
4154
|
this.toolbar.render({
|
|
2573
4155
|
playing: this.editor.isPlaying(),
|
|
2574
4156
|
time,
|
|
@@ -2577,18 +4159,34 @@ var EditorUI = class {
|
|
|
2577
4159
|
canRedo: this.editor.canRedo(),
|
|
2578
4160
|
canSplit: this.canSplitAt(time),
|
|
2579
4161
|
canTrim: this.canTrimAt(time, selectedClipId),
|
|
4162
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4163
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
2580
4164
|
snap,
|
|
2581
|
-
pxPerSec
|
|
4165
|
+
pxPerSec,
|
|
4166
|
+
...kfState
|
|
2582
4167
|
});
|
|
2583
4168
|
this.timeline.setProject(project);
|
|
2584
4169
|
this.timeline.setTime(time);
|
|
2585
4170
|
this.timeline.setScale(pxPerSec);
|
|
2586
4171
|
this.timeline.setSelection(selectedClipId);
|
|
2587
4172
|
this.timeline.setSnap(snap);
|
|
4173
|
+
this.timeline.setKeyframeState({
|
|
4174
|
+
enabled: this.editor.isKeyframesEnabled(),
|
|
4175
|
+
selected: this.editor.getSelectedKeyframe()
|
|
4176
|
+
});
|
|
4177
|
+
this.keyframePanel.render();
|
|
2588
4178
|
}
|
|
2589
4179
|
/** Playback-fast path: nudge playhead + toolbar time label only. */
|
|
2590
4180
|
onTimeTick(timeMs) {
|
|
2591
4181
|
this.timeline.setTime(timeMs);
|
|
4182
|
+
const selectedClipId = this.editor.getSelection();
|
|
4183
|
+
const kfEnabled = this.editor.isKeyframesEnabled();
|
|
4184
|
+
const kfState = this.computeKeyframeToolbarState(
|
|
4185
|
+
this.editor.getProject(),
|
|
4186
|
+
selectedClipId,
|
|
4187
|
+
timeMs,
|
|
4188
|
+
kfEnabled
|
|
4189
|
+
);
|
|
2592
4190
|
this.toolbar.render({
|
|
2593
4191
|
playing: this.editor.isPlaying(),
|
|
2594
4192
|
time: timeMs,
|
|
@@ -2596,9 +4194,12 @@ var EditorUI = class {
|
|
|
2596
4194
|
canUndo: this.editor.canUndo(),
|
|
2597
4195
|
canRedo: this.editor.canRedo(),
|
|
2598
4196
|
canSplit: this.canSplitAt(timeMs),
|
|
2599
|
-
canTrim: this.canTrimAt(timeMs,
|
|
4197
|
+
canTrim: this.canTrimAt(timeMs, selectedClipId),
|
|
4198
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4199
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
2600
4200
|
snap: this.editor.getSnap(),
|
|
2601
|
-
pxPerSec: this.editor.getScale()
|
|
4201
|
+
pxPerSec: this.editor.getScale(),
|
|
4202
|
+
...kfState
|
|
2602
4203
|
});
|
|
2603
4204
|
}
|
|
2604
4205
|
/** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
|
|
@@ -2610,6 +4211,7 @@ var EditorUI = class {
|
|
|
2610
4211
|
this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
|
|
2611
4212
|
this.fullscreenExitBtn.textContent = locale.exitFullscreen;
|
|
2612
4213
|
this.timeline.setLocale(locale);
|
|
4214
|
+
this.keyframePanel.setLocale(locale);
|
|
2613
4215
|
this.render();
|
|
2614
4216
|
}
|
|
2615
4217
|
destroy() {
|
|
@@ -2619,10 +4221,56 @@ var EditorUI = class {
|
|
|
2619
4221
|
}
|
|
2620
4222
|
this.toolbar.destroy();
|
|
2621
4223
|
this.timeline.destroy();
|
|
4224
|
+
this.keyframePanel.destroy();
|
|
4225
|
+
this.keyframeOverlay.destroy();
|
|
2622
4226
|
this.root.innerHTML = "";
|
|
2623
4227
|
this.root.classList.remove("aicut-root", "aicut-fullscreen");
|
|
2624
4228
|
}
|
|
2625
4229
|
// ---- helpers --------------------------------------------------------
|
|
4230
|
+
/** Walk the selected clip + playhead state to figure out (a) whether
|
|
4231
|
+
* the keyframe button should be enabled, and (b) whether a keyframe
|
|
4232
|
+
* already exists at the playhead's clip-local time (so the button
|
|
4233
|
+
* swaps to "remove" mode). */
|
|
4234
|
+
computeKeyframeToolbarState(project, selectedClipId, time, keyframesEnabled) {
|
|
4235
|
+
if (!keyframesEnabled || !selectedClipId) {
|
|
4236
|
+
return {
|
|
4237
|
+
canKeyframe: false,
|
|
4238
|
+
hasKeyframeAtPlayhead: false,
|
|
4239
|
+
keyframesEnabled
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
let clip = null;
|
|
4243
|
+
for (const t of project.tracks) {
|
|
4244
|
+
const c = t.clips.find((cl) => cl.id === selectedClipId);
|
|
4245
|
+
if (c) {
|
|
4246
|
+
clip = c;
|
|
4247
|
+
break;
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
if (!clip) {
|
|
4251
|
+
return {
|
|
4252
|
+
canKeyframe: false,
|
|
4253
|
+
hasKeyframeAtPlayhead: false,
|
|
4254
|
+
keyframesEnabled
|
|
4255
|
+
};
|
|
4256
|
+
}
|
|
4257
|
+
const localMs = time - clip.start;
|
|
4258
|
+
const duration = clipDuration(clip);
|
|
4259
|
+
if (localMs < 0 || localMs > duration) {
|
|
4260
|
+
return {
|
|
4261
|
+
canKeyframe: false,
|
|
4262
|
+
hasKeyframeAtPlayhead: false,
|
|
4263
|
+
keyframesEnabled
|
|
4264
|
+
};
|
|
4265
|
+
}
|
|
4266
|
+
const roundedLocal = Math.round(localMs);
|
|
4267
|
+
const hasKf = clip.keyframes?.some((k) => k.time === roundedLocal) ?? false;
|
|
4268
|
+
return {
|
|
4269
|
+
canKeyframe: true,
|
|
4270
|
+
hasKeyframeAtPlayhead: hasKf,
|
|
4271
|
+
keyframesEnabled
|
|
4272
|
+
};
|
|
4273
|
+
}
|
|
2626
4274
|
canSplitAt(timeMs) {
|
|
2627
4275
|
const project = this.editor.getProject();
|
|
2628
4276
|
for (const t of project.tracks) {
|
|
@@ -2664,6 +4312,22 @@ var EditorUI = class {
|
|
|
2664
4312
|
} else if (e.code === "KeyW") {
|
|
2665
4313
|
e.preventDefault();
|
|
2666
4314
|
cb.onTrimRight();
|
|
4315
|
+
} else if (e.code === "KeyI" && this.editor.isClipEdgeNavEnabled()) {
|
|
4316
|
+
e.preventDefault();
|
|
4317
|
+
cb.onSeekClipStart();
|
|
4318
|
+
} else if (e.code === "KeyO" && this.editor.isClipEdgeNavEnabled()) {
|
|
4319
|
+
e.preventDefault();
|
|
4320
|
+
cb.onSeekClipEnd();
|
|
4321
|
+
} else if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
|
|
4322
|
+
e.preventDefault();
|
|
4323
|
+
const project = this.editor.getProject();
|
|
4324
|
+
const step = e.shiftKey ? bigFrameStepMs(project) : frameStepMs(project);
|
|
4325
|
+
const dir = e.code === "ArrowLeft" ? -1 : 1;
|
|
4326
|
+
const next = Math.max(
|
|
4327
|
+
0,
|
|
4328
|
+
Math.min(this.editor.getDuration(), this.editor.getTime() + dir * step)
|
|
4329
|
+
);
|
|
4330
|
+
cb.onSeek(next);
|
|
2667
4331
|
} else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
|
|
2668
4332
|
e.preventDefault();
|
|
2669
4333
|
if (e.shiftKey) cb.onRedo();
|
|
@@ -2692,6 +4356,13 @@ var Editor = class _Editor {
|
|
|
2692
4356
|
bus = new EventBus();
|
|
2693
4357
|
history = new HistoryStack();
|
|
2694
4358
|
selectedClipId = null;
|
|
4359
|
+
selectedKeyframe = null;
|
|
4360
|
+
keyframesEnabled;
|
|
4361
|
+
clipEdgeNavEnabled;
|
|
4362
|
+
/** Drag-session bookkeeping for ripple-merge undo. See
|
|
4363
|
+
* beginInteraction / endInteraction docs on EditorApi. */
|
|
4364
|
+
interactionDepth = 0;
|
|
4365
|
+
interactionStartSnapshot = null;
|
|
2695
4366
|
pxPerSec;
|
|
2696
4367
|
snap;
|
|
2697
4368
|
locale;
|
|
@@ -2702,7 +4373,21 @@ var Editor = class _Editor {
|
|
|
2702
4373
|
this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
|
|
2703
4374
|
this.snap = opts.initialSnap !== false;
|
|
2704
4375
|
this.locale = mergeLocale(opts.locale);
|
|
4376
|
+
this.keyframesEnabled = opts.keyframes?.enabled === true;
|
|
4377
|
+
this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
|
|
4378
|
+
if (opts.trackHeight != null || opts.rulerHeight != null) {
|
|
4379
|
+
setTimelineMetrics({
|
|
4380
|
+
...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
|
|
4381
|
+
...opts.rulerHeight != null ? { rulerHeight: opts.rulerHeight } : {}
|
|
4382
|
+
});
|
|
4383
|
+
}
|
|
2705
4384
|
applyTheme(this.container, opts.theme);
|
|
4385
|
+
if (opts.timelineHeight != null && opts.timelineHeight > 0) {
|
|
4386
|
+
this.container.style.setProperty(
|
|
4387
|
+
"--aicut-timeline-height",
|
|
4388
|
+
`${Math.round(opts.timelineHeight)}px`
|
|
4389
|
+
);
|
|
4390
|
+
}
|
|
2706
4391
|
this.ui = new EditorUI(this.container, this, {
|
|
2707
4392
|
onPlayToggle: () => this.togglePlay(),
|
|
2708
4393
|
onSplit: () => this.split(),
|
|
@@ -2718,9 +4403,18 @@ var Editor = class _Editor {
|
|
|
2718
4403
|
onSelectClip: (id) => this.setSelection(id),
|
|
2719
4404
|
onDeleteClip: (id) => this.removeClip(id),
|
|
2720
4405
|
onMoveClip: (id, opts2) => this.moveClip(id, opts2),
|
|
2721
|
-
onResizeClip: (id, edits) => this.resizeClip(id, edits)
|
|
4406
|
+
onResizeClip: (id, edits) => this.resizeClip(id, edits),
|
|
4407
|
+
onSelectKeyframe: (target) => this.setSelectedKeyframe(target),
|
|
4408
|
+
onMoveKeyframe: (clipId, keyframeId, timeMs) => this.moveKeyframe(clipId, keyframeId, timeMs),
|
|
4409
|
+
onKeyframeToggle: () => this.toggleKeyframeAtPlayhead(),
|
|
4410
|
+
onSeekClipStart: () => this.seekToSelectedClipEdge("start"),
|
|
4411
|
+
onSeekClipEnd: () => this.seekToSelectedClipEdge("end")
|
|
4412
|
+
});
|
|
4413
|
+
const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
|
|
4414
|
+
this.engine = engineFactory({
|
|
4415
|
+
host: this.ui.previewHost,
|
|
4416
|
+
project: this.project
|
|
2722
4417
|
});
|
|
2723
|
-
this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
|
|
2724
4418
|
this.engine.onTimeUpdate = (ms) => {
|
|
2725
4419
|
this.bus.emit("time", { timeMs: ms });
|
|
2726
4420
|
this.ui.onTimeTick(ms);
|
|
@@ -3138,6 +4832,9 @@ var Editor = class _Editor {
|
|
|
3138
4832
|
for (const c of t.clips) {
|
|
3139
4833
|
if (c.id === ignoreClipId) continue;
|
|
3140
4834
|
targets.push(c.start, clipEnd(c));
|
|
4835
|
+
if (c.keyframes) {
|
|
4836
|
+
for (const kf of c.keyframes) targets.push(c.start + kf.time);
|
|
4837
|
+
}
|
|
3141
4838
|
}
|
|
3142
4839
|
}
|
|
3143
4840
|
let best = timeMs;
|
|
@@ -3159,6 +4856,304 @@ var Editor = class _Editor {
|
|
|
3159
4856
|
if (clipId === this.selectedClipId) return;
|
|
3160
4857
|
this.selectedClipId = clipId;
|
|
3161
4858
|
this.bus.emit("selectionChange", { clipId });
|
|
4859
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId !== clipId) {
|
|
4860
|
+
this.selectedKeyframe = null;
|
|
4861
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
4862
|
+
}
|
|
4863
|
+
this.ui.render();
|
|
4864
|
+
}
|
|
4865
|
+
// ---- keyframes ------------------------------------------------------
|
|
4866
|
+
isKeyframesEnabled() {
|
|
4867
|
+
return this.keyframesEnabled;
|
|
4868
|
+
}
|
|
4869
|
+
/**
|
|
4870
|
+
* Screen-space CSS-pixel rect of the actively painted frame
|
|
4871
|
+
* (post-transform), relative to the editor's preview element.
|
|
4872
|
+
* Null when no clip is active, the engine doesn't expose
|
|
4873
|
+
* `getFrameRect`, or the rect isn't computed yet. Used by the
|
|
4874
|
+
* library's keyframe-editing overlay.
|
|
4875
|
+
*/
|
|
4876
|
+
getActiveFrameRect() {
|
|
4877
|
+
return this.engine.getFrameRect?.() ?? null;
|
|
4878
|
+
}
|
|
4879
|
+
/**
|
|
4880
|
+
* Screen-space CSS-pixel rect of the OUTPUT FRAME (the fixed
|
|
4881
|
+
* stage that clips the rendered video). Different from
|
|
4882
|
+
* `getActiveFrameRect` which includes the keyframe transform —
|
|
4883
|
+
* this one stays put as the user drags / scales the content.
|
|
4884
|
+
* Used by the overlay to anchor the dashed border + drag body.
|
|
4885
|
+
*/
|
|
4886
|
+
getActiveOutputFrameRect() {
|
|
4887
|
+
return this.engine.getOutputFrameRect?.() ?? null;
|
|
4888
|
+
}
|
|
4889
|
+
setKeyframesEnabled(enabled) {
|
|
4890
|
+
if (enabled === this.keyframesEnabled) return;
|
|
4891
|
+
this.keyframesEnabled = enabled;
|
|
4892
|
+
if (!enabled && this.selectedKeyframe) {
|
|
4893
|
+
this.selectedKeyframe = null;
|
|
4894
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
4895
|
+
}
|
|
4896
|
+
this.bus.emit("keyframesEnabledChange", { enabled });
|
|
4897
|
+
this.ui.render();
|
|
4898
|
+
}
|
|
4899
|
+
isClipEdgeNavEnabled() {
|
|
4900
|
+
return this.clipEdgeNavEnabled;
|
|
4901
|
+
}
|
|
4902
|
+
setClipEdgeNavEnabled(enabled) {
|
|
4903
|
+
if (enabled === this.clipEdgeNavEnabled) return;
|
|
4904
|
+
this.clipEdgeNavEnabled = enabled;
|
|
4905
|
+
this.bus.emit("clipEdgeNavEnabledChange", { enabled });
|
|
4906
|
+
this.ui.render();
|
|
4907
|
+
}
|
|
4908
|
+
addKeyframe(clipId, prop, opts = {}) {
|
|
4909
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
4910
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
4911
|
+
if (!trk || !cl) return null;
|
|
4912
|
+
const duration = clipDuration(cl);
|
|
4913
|
+
const playheadLocal = this.engine.getTime() - cl.start;
|
|
4914
|
+
const rawTime = opts.time ?? playheadLocal;
|
|
4915
|
+
const time = Math.max(0, Math.min(duration, Math.round(rawTime)));
|
|
4916
|
+
const value = opts.value ?? interpolateProp(cl, prop, time);
|
|
4917
|
+
this.pushHistory();
|
|
4918
|
+
cl.keyframes = upsertKeyframe(
|
|
4919
|
+
cl.keyframes,
|
|
4920
|
+
prop,
|
|
4921
|
+
time,
|
|
4922
|
+
value,
|
|
4923
|
+
() => createId("kf")
|
|
4924
|
+
);
|
|
4925
|
+
cl.keyframes.sort((a, b) => {
|
|
4926
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
4927
|
+
return a.time - b.time;
|
|
4928
|
+
});
|
|
4929
|
+
this.afterMutation();
|
|
4930
|
+
const created = cl.keyframes.find(
|
|
4931
|
+
(k) => k.prop === prop && Math.abs(k.time - time) < 16
|
|
4932
|
+
);
|
|
4933
|
+
return created?.id ?? null;
|
|
4934
|
+
}
|
|
4935
|
+
removeKeyframe(clipId, keyframeId) {
|
|
4936
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
4937
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
4938
|
+
if (!trk || !cl || !cl.keyframes) return false;
|
|
4939
|
+
const idx = cl.keyframes.findIndex((k) => k.id === keyframeId);
|
|
4940
|
+
if (idx < 0) return false;
|
|
4941
|
+
this.pushHistory();
|
|
4942
|
+
const next = cl.keyframes.slice();
|
|
4943
|
+
next.splice(idx, 1);
|
|
4944
|
+
cl.keyframes = next.length > 0 ? next : void 0;
|
|
4945
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId && this.selectedKeyframe.keyframeId === keyframeId) {
|
|
4946
|
+
this.selectedKeyframe = null;
|
|
4947
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
4948
|
+
}
|
|
4949
|
+
this.afterMutation();
|
|
4950
|
+
return true;
|
|
4951
|
+
}
|
|
4952
|
+
moveKeyframe(clipId, keyframeId, timeMs) {
|
|
4953
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
4954
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
4955
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
4956
|
+
if (!trk || !cl || !kf || !cl.keyframes) return false;
|
|
4957
|
+
const duration = clipDuration(cl);
|
|
4958
|
+
const clamped = Math.max(0, Math.min(duration, Math.round(timeMs)));
|
|
4959
|
+
if (clamped === kf.time) return false;
|
|
4960
|
+
if (cl.keyframes.some(
|
|
4961
|
+
(k) => k.id !== keyframeId && k.prop === kf.prop && k.time === clamped
|
|
4962
|
+
)) {
|
|
4963
|
+
return false;
|
|
4964
|
+
}
|
|
4965
|
+
this.pushHistory();
|
|
4966
|
+
kf.time = clamped;
|
|
4967
|
+
cl.keyframes.sort((a, b) => {
|
|
4968
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
4969
|
+
return a.time - b.time;
|
|
4970
|
+
});
|
|
4971
|
+
this.afterMutation();
|
|
4972
|
+
return true;
|
|
4973
|
+
}
|
|
4974
|
+
setKeyframeValue(clipId, keyframeId, value) {
|
|
4975
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
4976
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
4977
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
4978
|
+
if (!trk || !cl || !kf) return false;
|
|
4979
|
+
if (Math.abs(kf.value - value) < 1e-9) return false;
|
|
4980
|
+
this.pushHistory();
|
|
4981
|
+
kf.value = value;
|
|
4982
|
+
this.afterMutation();
|
|
4983
|
+
return true;
|
|
4984
|
+
}
|
|
4985
|
+
setKeyframeEasing(clipId, keyframeId, easing) {
|
|
4986
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
4987
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
4988
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
4989
|
+
if (!trk || !cl || !kf) return false;
|
|
4990
|
+
const current = kf.easing ?? "linear";
|
|
4991
|
+
if (current === easing) return false;
|
|
4992
|
+
this.pushHistory();
|
|
4993
|
+
if (easing === "linear") {
|
|
4994
|
+
delete kf.easing;
|
|
4995
|
+
} else {
|
|
4996
|
+
kf.easing = easing;
|
|
4997
|
+
}
|
|
4998
|
+
this.afterMutation();
|
|
4999
|
+
return true;
|
|
5000
|
+
}
|
|
5001
|
+
setKeyframesEasingAtTime(clipId, timeMs, easing) {
|
|
5002
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5003
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5004
|
+
if (!trk || !cl || !cl.keyframes) return false;
|
|
5005
|
+
const t = Math.round(timeMs);
|
|
5006
|
+
const matches = cl.keyframes.filter((k) => Math.abs(k.time - t) < 16);
|
|
5007
|
+
if (matches.length === 0) return false;
|
|
5008
|
+
const anyChange = matches.some((k) => (k.easing ?? "linear") !== easing);
|
|
5009
|
+
if (!anyChange) return false;
|
|
5010
|
+
this.pushHistory();
|
|
5011
|
+
for (const kf of matches) {
|
|
5012
|
+
if (easing === "linear") delete kf.easing;
|
|
5013
|
+
else kf.easing = easing;
|
|
5014
|
+
}
|
|
5015
|
+
this.afterMutation();
|
|
5016
|
+
return true;
|
|
5017
|
+
}
|
|
5018
|
+
setValueAtPlayhead(clipId, prop, value) {
|
|
5019
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5020
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5021
|
+
if (!trk || !cl) return false;
|
|
5022
|
+
const duration = clipDuration(cl);
|
|
5023
|
+
const playheadLocal = this.engine.getTime() - cl.start;
|
|
5024
|
+
const time = Math.max(0, Math.min(duration, Math.round(playheadLocal)));
|
|
5025
|
+
const hasKf = cl.keyframes?.some((k) => k.prop === prop) ?? false;
|
|
5026
|
+
if (hasKf) {
|
|
5027
|
+
this.pushHistory();
|
|
5028
|
+
cl.keyframes = upsertKeyframe(
|
|
5029
|
+
cl.keyframes,
|
|
5030
|
+
prop,
|
|
5031
|
+
time,
|
|
5032
|
+
value,
|
|
5033
|
+
() => createId("kf")
|
|
5034
|
+
);
|
|
5035
|
+
cl.keyframes.sort((a, b) => {
|
|
5036
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5037
|
+
return a.time - b.time;
|
|
5038
|
+
});
|
|
5039
|
+
this.afterMutation();
|
|
5040
|
+
return true;
|
|
5041
|
+
}
|
|
5042
|
+
if ((cl[prop] ?? (prop === "scale" ? 1 : 0)) === value) return false;
|
|
5043
|
+
this.pushHistory();
|
|
5044
|
+
cl[prop] = value;
|
|
5045
|
+
this.afterMutation();
|
|
5046
|
+
return true;
|
|
5047
|
+
}
|
|
5048
|
+
getSelectedKeyframe() {
|
|
5049
|
+
return this.selectedKeyframe;
|
|
5050
|
+
}
|
|
5051
|
+
resetClipTransform(clipId) {
|
|
5052
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5053
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5054
|
+
if (!trk || !cl) return false;
|
|
5055
|
+
const dirty = cl.keyframes && cl.keyframes.length > 0 || cl.panX !== void 0 || cl.panY !== void 0 || cl.scale !== void 0;
|
|
5056
|
+
if (!dirty) return false;
|
|
5057
|
+
this.pushHistory();
|
|
5058
|
+
delete cl.panX;
|
|
5059
|
+
delete cl.panY;
|
|
5060
|
+
delete cl.scale;
|
|
5061
|
+
cl.keyframes = void 0;
|
|
5062
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId) {
|
|
5063
|
+
this.selectedKeyframe = null;
|
|
5064
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5065
|
+
}
|
|
5066
|
+
this.afterMutation();
|
|
5067
|
+
return true;
|
|
5068
|
+
}
|
|
5069
|
+
resetKeyframesAtTime(clipId, timeMs) {
|
|
5070
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5071
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5072
|
+
if (!trk || !cl) return false;
|
|
5073
|
+
const duration = clipDuration(cl);
|
|
5074
|
+
const t = Math.max(0, Math.min(duration, Math.round(timeMs)));
|
|
5075
|
+
this.pushHistory();
|
|
5076
|
+
let kfs = cl.keyframes ?? [];
|
|
5077
|
+
kfs = upsertKeyframe(kfs, "panX", t, 0, () => createId("kf"));
|
|
5078
|
+
kfs = upsertKeyframe(kfs, "panY", t, 0, () => createId("kf"));
|
|
5079
|
+
kfs = upsertKeyframe(kfs, "scale", t, 1, () => createId("kf"));
|
|
5080
|
+
kfs.sort((a, b) => {
|
|
5081
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5082
|
+
return a.time - b.time;
|
|
5083
|
+
});
|
|
5084
|
+
cl.keyframes = kfs;
|
|
5085
|
+
this.afterMutation();
|
|
5086
|
+
return true;
|
|
5087
|
+
}
|
|
5088
|
+
seekToClipEdge(clipId, edge) {
|
|
5089
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5090
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5091
|
+
if (!trk || !cl) return false;
|
|
5092
|
+
const target = edge === "start" ? cl.start : Math.max(cl.start, clipEnd(cl) - 1);
|
|
5093
|
+
if (this.engine.getTime() === target) return false;
|
|
5094
|
+
this.seek(target);
|
|
5095
|
+
return true;
|
|
5096
|
+
}
|
|
5097
|
+
seekToSelectedClipEdge(edge) {
|
|
5098
|
+
if (!this.selectedClipId) return false;
|
|
5099
|
+
return this.seekToClipEdge(this.selectedClipId, edge);
|
|
5100
|
+
}
|
|
5101
|
+
toggleKeyframeAtPlayhead() {
|
|
5102
|
+
const clipId = this.selectedClipId;
|
|
5103
|
+
if (!clipId) return false;
|
|
5104
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5105
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5106
|
+
if (!trk || !cl) return false;
|
|
5107
|
+
const localMs = this.engine.getTime() - cl.start;
|
|
5108
|
+
const duration = clipDuration(cl);
|
|
5109
|
+
if (localMs < 0 || localMs > duration) return false;
|
|
5110
|
+
const t = Math.round(localMs);
|
|
5111
|
+
const existing = cl.keyframes?.filter((k) => Math.abs(k.time - t) < 16);
|
|
5112
|
+
if (existing && existing.length > 0) {
|
|
5113
|
+
this.pushHistory();
|
|
5114
|
+
const ids = new Set(existing.map((k) => k.id));
|
|
5115
|
+
const next = cl.keyframes.filter((k) => !ids.has(k.id));
|
|
5116
|
+
cl.keyframes = next.length > 0 ? next : void 0;
|
|
5117
|
+
if (this.selectedKeyframe && ids.has(this.selectedKeyframe.keyframeId)) {
|
|
5118
|
+
this.selectedKeyframe = null;
|
|
5119
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5120
|
+
}
|
|
5121
|
+
this.afterMutation();
|
|
5122
|
+
return true;
|
|
5123
|
+
}
|
|
5124
|
+
const current = getEffectiveTransform(cl, t);
|
|
5125
|
+
this.pushHistory();
|
|
5126
|
+
let kfs = cl.keyframes ?? [];
|
|
5127
|
+
kfs = upsertKeyframe(kfs, "panX", t, current.panX, () => createId("kf"));
|
|
5128
|
+
kfs = upsertKeyframe(kfs, "panY", t, current.panY, () => createId("kf"));
|
|
5129
|
+
kfs = upsertKeyframe(kfs, "scale", t, current.scale, () => createId("kf"));
|
|
5130
|
+
kfs.sort((a, b) => {
|
|
5131
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5132
|
+
return a.time - b.time;
|
|
5133
|
+
});
|
|
5134
|
+
cl.keyframes = kfs;
|
|
5135
|
+
const anchor = kfs.find(
|
|
5136
|
+
(k) => k.prop === "panX" && Math.abs(k.time - t) < 16
|
|
5137
|
+
);
|
|
5138
|
+
if (anchor) {
|
|
5139
|
+
this.selectedKeyframe = { clipId, keyframeId: anchor.id };
|
|
5140
|
+
this.bus.emit("keyframeSelectionChange", {
|
|
5141
|
+
target: this.selectedKeyframe
|
|
5142
|
+
});
|
|
5143
|
+
}
|
|
5144
|
+
this.afterMutation();
|
|
5145
|
+
return true;
|
|
5146
|
+
}
|
|
5147
|
+
setSelectedKeyframe(target) {
|
|
5148
|
+
if (target?.clipId === this.selectedKeyframe?.clipId && target?.keyframeId === this.selectedKeyframe?.keyframeId) {
|
|
5149
|
+
return;
|
|
5150
|
+
}
|
|
5151
|
+
this.selectedKeyframe = target;
|
|
5152
|
+
if (target && target.clipId !== this.selectedClipId) {
|
|
5153
|
+
this.selectedClipId = target.clipId;
|
|
5154
|
+
this.bus.emit("selectionChange", { clipId: target.clipId });
|
|
5155
|
+
}
|
|
5156
|
+
this.bus.emit("keyframeSelectionChange", { target });
|
|
3162
5157
|
this.ui.render();
|
|
3163
5158
|
}
|
|
3164
5159
|
// ---- history --------------------------------------------------------
|
|
@@ -3172,6 +5167,7 @@ var Editor = class _Editor {
|
|
|
3172
5167
|
const prev = this.history.undo(this.project);
|
|
3173
5168
|
if (!prev) return false;
|
|
3174
5169
|
this.project = prev;
|
|
5170
|
+
this.reconcileSelectionsWithProject();
|
|
3175
5171
|
this.engine.setProject(this.project);
|
|
3176
5172
|
this.bus.emit("change", { project: this.getProject() });
|
|
3177
5173
|
this.emitHistory();
|
|
@@ -3182,12 +5178,57 @@ var Editor = class _Editor {
|
|
|
3182
5178
|
const next = this.history.redo(this.project);
|
|
3183
5179
|
if (!next) return false;
|
|
3184
5180
|
this.project = next;
|
|
5181
|
+
this.reconcileSelectionsWithProject();
|
|
3185
5182
|
this.engine.setProject(this.project);
|
|
3186
5183
|
this.bus.emit("change", { project: this.getProject() });
|
|
3187
5184
|
this.emitHistory();
|
|
3188
5185
|
this.ui.render();
|
|
3189
5186
|
return true;
|
|
3190
5187
|
}
|
|
5188
|
+
beginInteraction() {
|
|
5189
|
+
this.interactionDepth += 1;
|
|
5190
|
+
}
|
|
5191
|
+
endInteraction() {
|
|
5192
|
+
if (this.interactionDepth === 0) return;
|
|
5193
|
+
this.interactionDepth -= 1;
|
|
5194
|
+
if (this.interactionDepth > 0) return;
|
|
5195
|
+
const snapshot = this.interactionStartSnapshot;
|
|
5196
|
+
this.interactionStartSnapshot = null;
|
|
5197
|
+
if (snapshot == null) return;
|
|
5198
|
+
const now = JSON.stringify(this.project);
|
|
5199
|
+
if (now === snapshot) return;
|
|
5200
|
+
this.history.push(JSON.parse(snapshot));
|
|
5201
|
+
this.emitHistory();
|
|
5202
|
+
}
|
|
5203
|
+
/**
|
|
5204
|
+
* Selections (clipId + selectedKeyframe) live OUTSIDE the project
|
|
5205
|
+
* snapshot, so undo / redo can leave them pointing at ids that no
|
|
5206
|
+
* longer exist. Defend against dangling refs by clearing anything
|
|
5207
|
+
* the restored project doesn't actually contain — and emit the
|
|
5208
|
+
* paired change events so panels / overlays hide cleanly instead
|
|
5209
|
+
* of holding zombie references.
|
|
5210
|
+
*/
|
|
5211
|
+
reconcileSelectionsWithProject() {
|
|
5212
|
+
if (this.selectedKeyframe) {
|
|
5213
|
+
const trk = findTrackOfClip(this.project, this.selectedKeyframe.clipId);
|
|
5214
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedKeyframe.clipId);
|
|
5215
|
+
const kf = cl?.keyframes?.find(
|
|
5216
|
+
(k) => k.id === this.selectedKeyframe.keyframeId
|
|
5217
|
+
);
|
|
5218
|
+
if (!kf) {
|
|
5219
|
+
this.selectedKeyframe = null;
|
|
5220
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5223
|
+
if (this.selectedClipId) {
|
|
5224
|
+
const trk = findTrackOfClip(this.project, this.selectedClipId);
|
|
5225
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
|
|
5226
|
+
if (!cl) {
|
|
5227
|
+
this.selectedClipId = null;
|
|
5228
|
+
this.bus.emit("selectionChange", { clipId: null });
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
}
|
|
3191
5232
|
// ---- events ---------------------------------------------------------
|
|
3192
5233
|
on(event, handler) {
|
|
3193
5234
|
return this.bus.on(event, handler);
|
|
@@ -3224,6 +5265,12 @@ var Editor = class _Editor {
|
|
|
3224
5265
|
return null;
|
|
3225
5266
|
}
|
|
3226
5267
|
pushHistory() {
|
|
5268
|
+
if (this.interactionDepth > 0) {
|
|
5269
|
+
if (this.interactionStartSnapshot == null) {
|
|
5270
|
+
this.interactionStartSnapshot = JSON.stringify(this.project);
|
|
5271
|
+
}
|
|
5272
|
+
return;
|
|
5273
|
+
}
|
|
3227
5274
|
this.history.push(this.project);
|
|
3228
5275
|
this.emitHistory();
|
|
3229
5276
|
}
|
|
@@ -3265,6 +5312,6 @@ function clampScale2(s) {
|
|
|
3265
5312
|
return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
|
|
3266
5313
|
}
|
|
3267
5314
|
|
|
3268
|
-
export { Editor, HEADER_WIDTH, RULER_HEIGHT, TRACK_HEIGHT, Timeline, createEmptyProject, createId, normalizeProject };
|
|
5315
|
+
export { CanvasCompositorEngine, Editor, HEADER_WIDTH, HtmlVideoEngine, RULER_HEIGHT, TRACK_HEIGHT, Timeline, canvasCompositorEngineFactory, createEmptyProject, createId, htmlVideoEngineFactory, normalizeProject, setTimelineMetrics };
|
|
3269
5316
|
//# sourceMappingURL=index.js.map
|
|
3270
5317
|
//# sourceMappingURL=index.js.map
|