@aicut/core 0.5.0 → 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 +66 -0
- 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-B-DFWgKe.d.cts → i18n-B24k4XVG.d.cts} +34 -1
- package/dist/{i18n-B-DFWgKe.d.ts → i18n-B24k4XVG.d.ts} +34 -1
- package/dist/index.cjs +1917 -96
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +348 -25
- package/dist/index.d.ts +348 -25
- package/dist/index.js +1779 -86
- 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 -2
- package/dist/lighting/index.d.ts +2 -2
- package/dist/lighting/index.js +1 -1
- package/dist/playback/webcodecs/index.cjs +113 -6
- package/dist/playback/webcodecs/index.cjs.map +1 -1
- package/dist/playback/webcodecs/index.d.cts +18 -2
- package/dist/playback/webcodecs/index.d.ts +18 -2
- package/dist/playback/webcodecs/index.js +46 -6
- package/dist/playback/webcodecs/index.js.map +1 -1
- package/dist/{types-DvKlxylu.d.cts → types-BbZjOQLz.d.ts} +34 -1
- package/dist/{types-rwZx6FxE.d.ts → types-CjvRUPtZ.d.cts} +34 -1
- package/dist/types-CmS-UIEr.d.cts +137 -0
- package/dist/types-CmS-UIEr.d.ts +137 -0
- package/package.json +1 -1
- package/styles/theme.css +351 -0
- package/dist/chunk-CCDON7CU.js +0 -87
- package/dist/chunk-CCDON7CU.js.map +0 -1
- package/dist/types-CHplD9V5.d.cts +0 -70
- package/dist/types-CHplD9V5.d.ts +0 -70
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,11 @@ 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
|
+
}
|
|
155
263
|
function setTimelineMetrics(opts) {
|
|
156
264
|
if (opts.trackHeight != null && opts.trackHeight > 0) {
|
|
157
265
|
TRACK_HEIGHT = Math.round(opts.trackHeight);
|
|
@@ -187,8 +295,10 @@ function trackIndexAt(y, trackCount, scrollTop = 0) {
|
|
|
187
295
|
return idx;
|
|
188
296
|
}
|
|
189
297
|
function xToMs(x, pxPerSec, scrollLeft, showHeader) {
|
|
190
|
-
|
|
191
|
-
|
|
298
|
+
return Math.max(
|
|
299
|
+
0,
|
|
300
|
+
(x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
|
|
301
|
+
);
|
|
192
302
|
}
|
|
193
303
|
function niceTickSeconds(targetSec) {
|
|
194
304
|
if (targetSec <= 0) return 1;
|
|
@@ -216,6 +326,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
|
|
|
216
326
|
for (const c of t.clips) {
|
|
217
327
|
if (c.id === ignoreClipId) continue;
|
|
218
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
|
+
}
|
|
219
332
|
}
|
|
220
333
|
}
|
|
221
334
|
return arr;
|
|
@@ -266,13 +379,16 @@ function uncoveredIntervals(project) {
|
|
|
266
379
|
var HtmlVideoEngine = class {
|
|
267
380
|
host;
|
|
268
381
|
mount;
|
|
269
|
-
|
|
382
|
+
sources = /* @__PURE__ */ new Map();
|
|
270
383
|
project;
|
|
271
384
|
currentClipId = null;
|
|
272
385
|
playing = false;
|
|
273
386
|
timeMs = 0;
|
|
274
387
|
rafHandle = null;
|
|
275
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;
|
|
276
392
|
/** Public event hooks — set by Editor. */
|
|
277
393
|
onTimeUpdate;
|
|
278
394
|
onEnded;
|
|
@@ -284,8 +400,15 @@ var HtmlVideoEngine = class {
|
|
|
284
400
|
this.project = opts.project;
|
|
285
401
|
this.mount = document.createElement("div");
|
|
286
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
|
+
});
|
|
287
409
|
this.host.appendChild(this.mount);
|
|
288
410
|
this.syncSources();
|
|
411
|
+
this.startTransformLoop();
|
|
289
412
|
}
|
|
290
413
|
setProject(next) {
|
|
291
414
|
this.project = next;
|
|
@@ -308,9 +431,9 @@ var HtmlVideoEngine = class {
|
|
|
308
431
|
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
309
432
|
this.activate(clip);
|
|
310
433
|
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
311
|
-
const
|
|
312
|
-
if (!
|
|
313
|
-
void
|
|
434
|
+
const s = this.sources.get(clip.sourceId);
|
|
435
|
+
if (!s) return;
|
|
436
|
+
void s.video.play().catch((err) => this.onError?.(err));
|
|
314
437
|
this.playing = true;
|
|
315
438
|
this.startTickLoop();
|
|
316
439
|
}
|
|
@@ -321,8 +444,7 @@ var HtmlVideoEngine = class {
|
|
|
321
444
|
if (this.currentClipId) {
|
|
322
445
|
const clip = this.clipById(this.currentClipId);
|
|
323
446
|
if (clip) {
|
|
324
|
-
|
|
325
|
-
v?.pause();
|
|
447
|
+
this.sources.get(clip.sourceId)?.video.pause();
|
|
326
448
|
}
|
|
327
449
|
}
|
|
328
450
|
}
|
|
@@ -349,41 +471,137 @@ var HtmlVideoEngine = class {
|
|
|
349
471
|
}
|
|
350
472
|
this.onTimeUpdate?.(clamped);
|
|
351
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
|
+
}
|
|
352
550
|
destroy() {
|
|
353
551
|
this.stopTickLoop();
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
v.load();
|
|
358
|
-
v.remove();
|
|
552
|
+
if (this.transformRaf != null) {
|
|
553
|
+
cancelAnimationFrame(this.transformRaf);
|
|
554
|
+
this.transformRaf = null;
|
|
359
555
|
}
|
|
360
|
-
this.
|
|
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();
|
|
361
563
|
this.mount.remove();
|
|
362
564
|
}
|
|
363
565
|
// --- internals -------------------------------------------------------
|
|
364
566
|
syncSources() {
|
|
365
567
|
const wanted = new Set(this.project.sources.map((s) => s.id));
|
|
366
|
-
for (const [id,
|
|
568
|
+
for (const [id, s] of this.sources) {
|
|
367
569
|
if (!wanted.has(id)) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
this.
|
|
570
|
+
s.video.pause();
|
|
571
|
+
s.wrapper.remove();
|
|
572
|
+
this.sources.delete(id);
|
|
371
573
|
}
|
|
372
574
|
}
|
|
373
575
|
for (const src of this.project.sources) {
|
|
374
576
|
if (src.kind !== "video") continue;
|
|
375
|
-
if (this.
|
|
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
|
+
});
|
|
376
590
|
const v = document.createElement("video");
|
|
377
591
|
v.preload = "auto";
|
|
378
592
|
v.playsInline = true;
|
|
379
593
|
v.muted = false;
|
|
380
594
|
v.src = src.url;
|
|
381
|
-
v.style
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
});
|
|
387
605
|
const sourceId = src.id;
|
|
388
606
|
v.addEventListener(
|
|
389
607
|
"error",
|
|
@@ -396,8 +614,9 @@ var HtmlVideoEngine = class {
|
|
|
396
614
|
this.onSourceMetadata?.(sourceId, durMs);
|
|
397
615
|
}
|
|
398
616
|
});
|
|
399
|
-
|
|
400
|
-
this.
|
|
617
|
+
wrapper.appendChild(v);
|
|
618
|
+
this.mount.appendChild(wrapper);
|
|
619
|
+
this.sources.set(src.id, { wrapper, video: v });
|
|
401
620
|
}
|
|
402
621
|
}
|
|
403
622
|
activate(clip) {
|
|
@@ -405,25 +624,25 @@ var HtmlVideoEngine = class {
|
|
|
405
624
|
if (this.currentClipId) {
|
|
406
625
|
const prev = this.clipById(this.currentClipId);
|
|
407
626
|
if (prev) {
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
627
|
+
const s = this.sources.get(prev.sourceId);
|
|
628
|
+
if (s) {
|
|
629
|
+
s.video.pause();
|
|
630
|
+
s.wrapper.style.visibility = "hidden";
|
|
412
631
|
}
|
|
413
632
|
}
|
|
414
633
|
}
|
|
415
634
|
this.currentClipId = clip ? clip.id : null;
|
|
416
635
|
if (clip) {
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
636
|
+
const s = this.sources.get(clip.sourceId);
|
|
637
|
+
if (s) s.wrapper.style.visibility = "visible";
|
|
419
638
|
}
|
|
420
639
|
}
|
|
421
640
|
seekVideoToClipOffset(clip, offsetMs) {
|
|
422
|
-
const
|
|
423
|
-
if (!
|
|
641
|
+
const s = this.sources.get(clip.sourceId);
|
|
642
|
+
if (!s) return;
|
|
424
643
|
const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
|
|
425
|
-
if (Math.abs(
|
|
426
|
-
|
|
644
|
+
if (Math.abs(s.video.currentTime - target) > 0.05) {
|
|
645
|
+
s.video.currentTime = target;
|
|
427
646
|
}
|
|
428
647
|
}
|
|
429
648
|
clipById(id) {
|
|
@@ -435,10 +654,7 @@ var HtmlVideoEngine = class {
|
|
|
435
654
|
/**
|
|
436
655
|
* Find the clip whose timeline range contains `timeMs`, searching
|
|
437
656
|
* across ALL video tracks. If multiple tracks have a clip at this
|
|
438
|
-
* moment, the lowest-index track wins
|
|
439
|
-
* background" convention used in the auto-split UX — overlapping
|
|
440
|
-
* placements would have created a new track on top, but here we
|
|
441
|
-
* fall back to the underlying clip).
|
|
657
|
+
* moment, the lowest-index track wins.
|
|
442
658
|
*/
|
|
443
659
|
clipAtTime(timeMs) {
|
|
444
660
|
for (const t of this.project.tracks) {
|
|
@@ -507,8 +723,8 @@ var HtmlVideoEngine = class {
|
|
|
507
723
|
this.timeMs = next.start;
|
|
508
724
|
this.activate(next);
|
|
509
725
|
this.seekVideoToClipOffset(next, 0);
|
|
510
|
-
const
|
|
511
|
-
if (
|
|
726
|
+
const s = this.sources.get(next.sourceId);
|
|
727
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
512
728
|
} else {
|
|
513
729
|
this.pause();
|
|
514
730
|
this.onEnded?.();
|
|
@@ -517,8 +733,8 @@ var HtmlVideoEngine = class {
|
|
|
517
733
|
} else if (clip.id !== this.currentClipId) {
|
|
518
734
|
this.activate(clip);
|
|
519
735
|
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
520
|
-
const
|
|
521
|
-
if (
|
|
736
|
+
const s = this.sources.get(clip.sourceId);
|
|
737
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
522
738
|
}
|
|
523
739
|
this.onTimeUpdate?.(this.timeMs);
|
|
524
740
|
}
|
|
@@ -541,6 +757,10 @@ var CanvasCompositorEngine = class {
|
|
|
541
757
|
rafHandle = null;
|
|
542
758
|
lastFrameTs = 0;
|
|
543
759
|
paintedFrames = 0;
|
|
760
|
+
/** Output frame rect (no transform) — fixed bounds. */
|
|
761
|
+
lastOutputRect = null;
|
|
762
|
+
/** Post-transform content rect. */
|
|
763
|
+
lastFrameRect = null;
|
|
544
764
|
onTimeUpdate;
|
|
545
765
|
onEnded;
|
|
546
766
|
onError;
|
|
@@ -825,19 +1045,55 @@ var CanvasCompositorEngine = class {
|
|
|
825
1045
|
this.ctx.clearRect(0, 0, cw, ch);
|
|
826
1046
|
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
827
1047
|
const v = clip ? this.videos.get(clip.sourceId) : null;
|
|
828
|
-
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
|
|
1048
|
+
if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
|
|
829
1049
|
const vw = v.videoWidth;
|
|
830
1050
|
const vh = v.videoHeight;
|
|
831
|
-
const
|
|
832
|
-
const dw = vw *
|
|
833
|
-
const dh = vh *
|
|
834
|
-
const
|
|
835
|
-
const
|
|
836
|
-
|
|
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();
|
|
837
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;
|
|
838
1088
|
}
|
|
839
1089
|
this.updateBadge();
|
|
840
1090
|
}
|
|
1091
|
+
getOutputFrameRect() {
|
|
1092
|
+
return this.lastOutputRect;
|
|
1093
|
+
}
|
|
1094
|
+
getFrameRect() {
|
|
1095
|
+
return this.lastFrameRect;
|
|
1096
|
+
}
|
|
841
1097
|
updateBadge() {
|
|
842
1098
|
if (!this.badge) return;
|
|
843
1099
|
const sec = (this.timeMs / 1e3).toFixed(2);
|
|
@@ -1023,7 +1279,7 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
1023
1279
|
const { viewportWidth: W, viewportHeight: H } = state;
|
|
1024
1280
|
ctx.fillStyle = style.bg;
|
|
1025
1281
|
ctx.fillRect(0, 0, W, H);
|
|
1026
|
-
const baseX = state.showHeader
|
|
1282
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1027
1283
|
const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
|
|
1028
1284
|
const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1029
1285
|
ctx.save();
|
|
@@ -1063,15 +1319,21 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
1063
1319
|
ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
|
|
1064
1320
|
ctx.clip();
|
|
1065
1321
|
drawSnapGuide(ctx, state, style);
|
|
1066
|
-
drawPlayhead(ctx, state, style);
|
|
1067
1322
|
ctx.restore();
|
|
1068
1323
|
drawScrollbarV(ctx, state, style);
|
|
1069
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();
|
|
1070
1332
|
}
|
|
1071
1333
|
function drawCoverageGaps(ctx, state, style) {
|
|
1072
1334
|
const gaps = uncoveredIntervals(state.project);
|
|
1073
1335
|
if (gaps.length === 0) return;
|
|
1074
|
-
const baseX = state.showHeader
|
|
1336
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1075
1337
|
const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1076
1338
|
for (const [s, e] of gaps) {
|
|
1077
1339
|
const x1 = Math.max(
|
|
@@ -1110,7 +1372,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
|
|
|
1110
1372
|
}
|
|
1111
1373
|
}
|
|
1112
1374
|
if (!real) return;
|
|
1113
|
-
const baseX = state.showHeader
|
|
1375
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1114
1376
|
const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
|
|
1115
1377
|
const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1116
1378
|
const overlap = ghost.wouldOverlap;
|
|
@@ -1180,7 +1442,7 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
|
|
|
1180
1442
|
}
|
|
1181
1443
|
function drawRuler(ctx, state, style) {
|
|
1182
1444
|
const { pxPerSec, scrollLeft, viewportWidth: W } = state;
|
|
1183
|
-
const baseX = state.showHeader
|
|
1445
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1184
1446
|
const rulerW = W - baseX;
|
|
1185
1447
|
ctx.fillStyle = style.bg;
|
|
1186
1448
|
ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
|
|
@@ -1226,7 +1488,7 @@ function drawTracks(ctx, state, style, thumbs) {
|
|
|
1226
1488
|
}
|
|
1227
1489
|
function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
1228
1490
|
const { viewportWidth: W } = state;
|
|
1229
|
-
const baseX = state.showHeader
|
|
1491
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1230
1492
|
const y = trackY(trackIndex);
|
|
1231
1493
|
ctx.fillStyle = style.trackBg;
|
|
1232
1494
|
ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
|
|
@@ -1264,7 +1526,7 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
|
1264
1526
|
}
|
|
1265
1527
|
function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
|
|
1266
1528
|
const { pxPerSec, scrollLeft } = state;
|
|
1267
|
-
const baseX = state.showHeader
|
|
1529
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1268
1530
|
const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
|
|
1269
1531
|
const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
|
|
1270
1532
|
const y = trackY(trackIndex) + CLIP_INSET;
|
|
@@ -1313,6 +1575,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
|
|
|
1313
1575
|
ctx.fillRect(startX + 2, y + 12, 2, h - 24);
|
|
1314
1576
|
ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
|
|
1315
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
|
+
}
|
|
1316
1606
|
ctx.restore();
|
|
1317
1607
|
}
|
|
1318
1608
|
function drawHeaders(ctx, state, style) {
|
|
@@ -1363,7 +1653,7 @@ function drawHeaders(ctx, state, style) {
|
|
|
1363
1653
|
}
|
|
1364
1654
|
}
|
|
1365
1655
|
function drawPlayhead(ctx, state, style) {
|
|
1366
|
-
const baseX = state.showHeader
|
|
1656
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1367
1657
|
const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1368
1658
|
if (x < baseX - 2 || x > state.viewportWidth + 2) return;
|
|
1369
1659
|
ctx.strokeStyle = style.playhead;
|
|
@@ -1377,7 +1667,9 @@ function drawPlayhead(ctx, state, style) {
|
|
|
1377
1667
|
const padX = 6;
|
|
1378
1668
|
const w = ctx.measureText(label).width + padX * 2;
|
|
1379
1669
|
const h = 14;
|
|
1380
|
-
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));
|
|
1381
1673
|
const by = 2;
|
|
1382
1674
|
ctx.fillStyle = style.playhead;
|
|
1383
1675
|
roundRect(ctx, bx, by, w, h, 4);
|
|
@@ -1433,7 +1725,7 @@ function drawScrollbarV(ctx, state, style) {
|
|
|
1433
1725
|
}
|
|
1434
1726
|
function drawScrollbarH(ctx, state, style) {
|
|
1435
1727
|
if (state.scrollbarOpacityX <= 0.01) return;
|
|
1436
|
-
const baseX = state.showHeader
|
|
1728
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1437
1729
|
const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1438
1730
|
const contentW = contentWidth(state.project, state.pxPerSec);
|
|
1439
1731
|
if (contentW <= visibleW) return;
|
|
@@ -1516,11 +1808,25 @@ function parseColor(s) {
|
|
|
1516
1808
|
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1517
1809
|
return null;
|
|
1518
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
|
+
}
|
|
1519
1824
|
|
|
1520
1825
|
// src/timeline/hit.ts
|
|
1826
|
+
var KEYFRAME_HIT_RADIUS = 8;
|
|
1521
1827
|
function hitTest(x, y, ctx) {
|
|
1522
1828
|
if (y < 0 || x < 0) return { kind: "outside" };
|
|
1523
|
-
const baseX = ctx.showHeader
|
|
1829
|
+
const baseX = contentLeftX(ctx.showHeader);
|
|
1524
1830
|
const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1525
1831
|
const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
|
|
1526
1832
|
if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
|
|
@@ -1572,6 +1878,23 @@ function hitTest(x, y, ctx) {
|
|
|
1572
1878
|
if (ti < 0) return { kind: "outside" };
|
|
1573
1879
|
const track = ctx.project.tracks[ti];
|
|
1574
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
|
+
}
|
|
1575
1898
|
for (const clip of track.clips) {
|
|
1576
1899
|
const start = clip.start;
|
|
1577
1900
|
const end = clip.start + (clip.out - clip.in);
|
|
@@ -1590,8 +1913,7 @@ function hitTest(x, y, ctx) {
|
|
|
1590
1913
|
return { kind: "track-empty", trackIndex: ti };
|
|
1591
1914
|
}
|
|
1592
1915
|
function msToXLocal(ms, ctx) {
|
|
1593
|
-
|
|
1594
|
-
return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1916
|
+
return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1595
1917
|
}
|
|
1596
1918
|
|
|
1597
1919
|
// src/timeline/index.ts
|
|
@@ -1625,6 +1947,8 @@ var Timeline = class _Timeline {
|
|
|
1625
1947
|
readOnly;
|
|
1626
1948
|
autoFitEnabled;
|
|
1627
1949
|
locale;
|
|
1950
|
+
keyframesEnabled = false;
|
|
1951
|
+
selectedKeyframe = null;
|
|
1628
1952
|
scrollLeft = 0;
|
|
1629
1953
|
scrollTop = 0;
|
|
1630
1954
|
viewportWidth = 0;
|
|
@@ -1643,6 +1967,7 @@ var Timeline = class _Timeline {
|
|
|
1643
1967
|
scrollbarDrag = null;
|
|
1644
1968
|
hoveredClipId = null;
|
|
1645
1969
|
hoveredTrackIndex = null;
|
|
1970
|
+
hoveredKeyframe = null;
|
|
1646
1971
|
hoverCursor = "default";
|
|
1647
1972
|
dropTargetTrackIndex = null;
|
|
1648
1973
|
snapX = null;
|
|
@@ -1681,6 +2006,8 @@ var Timeline = class _Timeline {
|
|
|
1681
2006
|
this.readOnly = opts.readOnly === true;
|
|
1682
2007
|
this.autoFitEnabled = opts.autoFit !== false;
|
|
1683
2008
|
this.locale = mergeLocale(opts.locale);
|
|
2009
|
+
this.keyframesEnabled = opts.keyframesEnabled === true;
|
|
2010
|
+
this.selectedKeyframe = opts.selectedKeyframe ?? null;
|
|
1684
2011
|
this.root.classList.add("aicut-timeline-canvas");
|
|
1685
2012
|
this.root.innerHTML = "";
|
|
1686
2013
|
this.root.style.position = this.root.style.position || "relative";
|
|
@@ -1728,6 +2055,7 @@ var Timeline = class _Timeline {
|
|
|
1728
2055
|
this.thumbs.syncSources(this.project.sources);
|
|
1729
2056
|
this.attachPointer();
|
|
1730
2057
|
this.attachWheel();
|
|
2058
|
+
this.attachKeyboard();
|
|
1731
2059
|
this.attachResize();
|
|
1732
2060
|
this.resizeCanvas();
|
|
1733
2061
|
this.scheduleRender();
|
|
@@ -1808,7 +2136,7 @@ var Timeline = class _Timeline {
|
|
|
1808
2136
|
* Exposed publicly so React/Vue wrappers can forward it to a ref.
|
|
1809
2137
|
*/
|
|
1810
2138
|
getDebugInfo() {
|
|
1811
|
-
const baseX = this.showHeader
|
|
2139
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1812
2140
|
const clips = [];
|
|
1813
2141
|
for (let ti = 0; ti < this.project.tracks.length; ti++) {
|
|
1814
2142
|
const t = this.project.tracks[ti];
|
|
@@ -1861,17 +2189,17 @@ var Timeline = class _Timeline {
|
|
|
1861
2189
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1862
2190
|
}
|
|
1863
2191
|
computeFitScale() {
|
|
1864
|
-
const baseX = this.showHeader
|
|
2192
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1865
2193
|
const w = this.viewportWidth - baseX - 24;
|
|
1866
2194
|
const dur = projectDuration(this.project);
|
|
1867
2195
|
if (w <= 0 || dur <= 0) return null;
|
|
1868
2196
|
return clampScale(w / (dur / 1e3));
|
|
1869
2197
|
}
|
|
1870
2198
|
maxScrollLeft() {
|
|
1871
|
-
const baseX = this.showHeader
|
|
2199
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1872
2200
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1873
2201
|
const cw = contentWidth(this.project, this.pxPerSec);
|
|
1874
|
-
return Math.max(0, cw - visibleW +
|
|
2202
|
+
return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
|
|
1875
2203
|
}
|
|
1876
2204
|
maxScrollTop() {
|
|
1877
2205
|
const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
@@ -1973,9 +2301,36 @@ var Timeline = class _Timeline {
|
|
|
1973
2301
|
scrollbarOpacityX: this.scrollbarOpacity("h"),
|
|
1974
2302
|
scrollbarActiveY: this.scrollbarDrag?.axis === "v",
|
|
1975
2303
|
scrollbarActiveX: this.scrollbarDrag?.axis === "h",
|
|
1976
|
-
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
|
|
1977
2313
|
};
|
|
1978
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
|
+
}
|
|
1979
2334
|
readStyle() {
|
|
1980
2335
|
const cs = getComputedStyle(this.root);
|
|
1981
2336
|
const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
|
|
@@ -2004,6 +2359,7 @@ var Timeline = class _Timeline {
|
|
|
2004
2359
|
this.canvas.addEventListener("pointerleave", () => {
|
|
2005
2360
|
if (!this.drag && !this.scrollbarDrag) {
|
|
2006
2361
|
this.hoveredClipId = null;
|
|
2362
|
+
this.hoveredKeyframe = null;
|
|
2007
2363
|
this.hoverCursor = "default";
|
|
2008
2364
|
this.hoverScrollbarY = false;
|
|
2009
2365
|
this.hoverScrollbarX = false;
|
|
@@ -2044,7 +2400,7 @@ var Timeline = class _Timeline {
|
|
|
2044
2400
|
return;
|
|
2045
2401
|
}
|
|
2046
2402
|
if (target.kind === "scrollbar-track-h") {
|
|
2047
|
-
const baseX = this.showHeader
|
|
2403
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2048
2404
|
const page = Math.max(
|
|
2049
2405
|
80,
|
|
2050
2406
|
this.viewportWidth - baseX - SCROLLBAR_THICKNESS
|
|
@@ -2084,6 +2440,33 @@ var Timeline = class _Timeline {
|
|
|
2084
2440
|
this.scheduleRender();
|
|
2085
2441
|
return;
|
|
2086
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
|
+
}
|
|
2087
2470
|
if (target.kind === "clip") {
|
|
2088
2471
|
const found = findClip(this.project, target.clipId);
|
|
2089
2472
|
if (!found) return;
|
|
@@ -2150,7 +2533,7 @@ var Timeline = class _Timeline {
|
|
|
2150
2533
|
const ratio = maxScroll / free;
|
|
2151
2534
|
this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
|
|
2152
2535
|
} else {
|
|
2153
|
-
const baseX = this.showHeader
|
|
2536
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2154
2537
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
2155
2538
|
const contentW = contentWidth(this.project, this.pxPerSec);
|
|
2156
2539
|
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
@@ -2174,10 +2557,19 @@ var Timeline = class _Timeline {
|
|
|
2174
2557
|
let cursor = "default";
|
|
2175
2558
|
let onScrollbarV = false;
|
|
2176
2559
|
let onScrollbarH = false;
|
|
2560
|
+
let nextHoverKeyframe = null;
|
|
2177
2561
|
if (target.kind === "clip") {
|
|
2178
2562
|
nextHover = target.clipId;
|
|
2179
2563
|
nextHoverTrack = target.trackIndex;
|
|
2180
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";
|
|
2181
2573
|
} else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
|
|
2182
2574
|
nextHover = target.clipId;
|
|
2183
2575
|
nextHoverTrack = target.trackIndex;
|
|
@@ -2201,12 +2593,14 @@ var Timeline = class _Timeline {
|
|
|
2201
2593
|
cursor = "default";
|
|
2202
2594
|
}
|
|
2203
2595
|
const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
|
|
2204
|
-
|
|
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) {
|
|
2205
2598
|
this.hoveredClipId = nextHover;
|
|
2206
2599
|
this.hoveredTrackIndex = nextHoverTrack;
|
|
2207
2600
|
this.hoverCursor = cursor;
|
|
2208
2601
|
this.hoverScrollbarY = onScrollbarV;
|
|
2209
2602
|
this.hoverScrollbarX = onScrollbarH;
|
|
2603
|
+
this.hoveredKeyframe = nextHoverKeyframe;
|
|
2210
2604
|
this.scheduleRender();
|
|
2211
2605
|
}
|
|
2212
2606
|
return;
|
|
@@ -2229,6 +2623,26 @@ var Timeline = class _Timeline {
|
|
|
2229
2623
|
this.maybeStartDragAutoScroll();
|
|
2230
2624
|
return;
|
|
2231
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
|
+
}
|
|
2232
2646
|
if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
|
|
2233
2647
|
const dxPx = x - this.drag.pointerStartX;
|
|
2234
2648
|
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
@@ -2381,6 +2795,14 @@ var Timeline = class _Timeline {
|
|
|
2381
2795
|
});
|
|
2382
2796
|
this.opts.onChange?.(this.getProject());
|
|
2383
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
|
+
}
|
|
2384
2806
|
} else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
|
|
2385
2807
|
const found = findClip(this.project, drag.clipId);
|
|
2386
2808
|
if (found) {
|
|
@@ -2394,6 +2816,25 @@ var Timeline = class _Timeline {
|
|
|
2394
2816
|
}
|
|
2395
2817
|
this.scheduleRender();
|
|
2396
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
|
+
}
|
|
2397
2838
|
attachWheel() {
|
|
2398
2839
|
this.canvas.addEventListener(
|
|
2399
2840
|
"wheel",
|
|
@@ -2414,7 +2855,7 @@ var Timeline = class _Timeline {
|
|
|
2414
2855
|
if (Math.abs(next - this.pxPerSec) < 0.01) return;
|
|
2415
2856
|
this.pxPerSec = next;
|
|
2416
2857
|
this.hasAutoFitted = true;
|
|
2417
|
-
const baseX = this.showHeader
|
|
2858
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2418
2859
|
this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
|
|
2419
2860
|
this.clampScroll();
|
|
2420
2861
|
this.touchScrollbar("h");
|
|
@@ -2477,7 +2918,8 @@ var Timeline = class _Timeline {
|
|
|
2477
2918
|
showHeader: this.showHeader,
|
|
2478
2919
|
viewportWidth: this.viewportWidth,
|
|
2479
2920
|
viewportHeight: this.viewportHeight,
|
|
2480
|
-
isDragging: this.drag?.kind === "move"
|
|
2921
|
+
isDragging: this.drag?.kind === "move",
|
|
2922
|
+
keyframesEnabled: this.keyframesEnabled
|
|
2481
2923
|
});
|
|
2482
2924
|
}
|
|
2483
2925
|
trackIndexAtY(y) {
|
|
@@ -2500,7 +2942,7 @@ var Timeline = class _Timeline {
|
|
|
2500
2942
|
}
|
|
2501
2943
|
}
|
|
2502
2944
|
if (best !== ms) {
|
|
2503
|
-
const baseX = this.showHeader
|
|
2945
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2504
2946
|
this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
2505
2947
|
} else {
|
|
2506
2948
|
this.snapX = null;
|
|
@@ -2509,6 +2951,709 @@ var Timeline = class _Timeline {
|
|
|
2509
2951
|
}
|
|
2510
2952
|
};
|
|
2511
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();
|
|
3504
|
+
});
|
|
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
|
+
}
|
|
3539
|
+
}
|
|
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
|
+
}
|
|
3552
|
+
}
|
|
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);
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
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
|
+
}
|
|
3611
|
+
}
|
|
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;
|
|
3627
|
+
}
|
|
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();
|
|
3633
|
+
}
|
|
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;
|
|
3652
|
+
}
|
|
3653
|
+
return null;
|
|
3654
|
+
}
|
|
3655
|
+
};
|
|
3656
|
+
|
|
2512
3657
|
// src/ui/icons.ts
|
|
2513
3658
|
var wrap = (path) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">${path}</svg>`;
|
|
2514
3659
|
var ICONS = {
|
|
@@ -2552,6 +3697,24 @@ var ICONS = {
|
|
|
2552
3697
|
trash: wrap(
|
|
2553
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>`
|
|
2554
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
|
+
),
|
|
2555
3718
|
/** Counter-clockwise circular arrow — "reset to initial layout". */
|
|
2556
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>`
|
|
2557
3720
|
};
|
|
@@ -2578,6 +3741,9 @@ var Toolbar = class {
|
|
|
2578
3741
|
splitBtn;
|
|
2579
3742
|
trimLeftBtn;
|
|
2580
3743
|
trimRightBtn;
|
|
3744
|
+
seekClipStartBtn;
|
|
3745
|
+
seekClipEndBtn;
|
|
3746
|
+
keyframeBtn;
|
|
2581
3747
|
playBtn;
|
|
2582
3748
|
playIcon;
|
|
2583
3749
|
timeLabel;
|
|
@@ -2603,9 +3769,37 @@ var Toolbar = class {
|
|
|
2603
3769
|
this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
|
|
2604
3770
|
this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
|
|
2605
3771
|
this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
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
|
+
);
|
|
2609
3803
|
const center = mkGroup("aicut-toolbar-center");
|
|
2610
3804
|
this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
|
|
2611
3805
|
this.playBtn = document.createElement("button");
|
|
@@ -2682,11 +3876,38 @@ var Toolbar = class {
|
|
|
2682
3876
|
this.trimLeftBtn.disabled = !state.canTrim;
|
|
2683
3877
|
this.trimRightBtn.disabled = !state.canTrim;
|
|
2684
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
|
+
}
|
|
2685
3888
|
if (!this.lastState || this.lastState.snap !== state.snap) {
|
|
2686
3889
|
this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
|
|
2687
3890
|
this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
|
|
2688
3891
|
this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
|
|
2689
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
|
+
}
|
|
2690
3911
|
if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
|
|
2691
3912
|
const ratio = scaleToSlider(state.pxPerSec);
|
|
2692
3913
|
const nextVal = String(Math.round(ratio * 100));
|
|
@@ -2719,12 +3940,20 @@ var Toolbar = class {
|
|
|
2719
3940
|
applyTitle(this.splitBtn, locale.split);
|
|
2720
3941
|
applyTitle(this.trimLeftBtn, locale.trimLeft);
|
|
2721
3942
|
applyTitle(this.trimRightBtn, locale.trimRight);
|
|
3943
|
+
applyTitle(this.seekClipStartBtn, locale.seekClipStart);
|
|
3944
|
+
applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
|
|
2722
3945
|
applyTitle(this.playBtn, locale.playPause);
|
|
2723
3946
|
applyTitle(this.fullscreenBtn, locale.fullscreen);
|
|
2724
3947
|
applyTitle(this.snapBtn, locale.snap);
|
|
2725
3948
|
applyTitle(this.zoomOutBtn, locale.zoomOut);
|
|
2726
3949
|
applyTitle(this.zoomInBtn, locale.zoomIn);
|
|
2727
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
|
+
}
|
|
2728
3957
|
if (this.lastState) {
|
|
2729
3958
|
this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
|
|
2730
3959
|
}
|
|
@@ -2784,6 +4013,8 @@ var EditorUI = class {
|
|
|
2784
4013
|
toolbar;
|
|
2785
4014
|
timelineHost;
|
|
2786
4015
|
timeline;
|
|
4016
|
+
keyframePanel;
|
|
4017
|
+
keyframeOverlay;
|
|
2787
4018
|
fullscreen = false;
|
|
2788
4019
|
onDocKeydown = null;
|
|
2789
4020
|
constructor(root, editor, cb) {
|
|
@@ -2830,10 +4061,14 @@ var EditorUI = class {
|
|
|
2830
4061
|
snap: editor.getSnap(),
|
|
2831
4062
|
autoFit: true,
|
|
2832
4063
|
locale,
|
|
4064
|
+
keyframesEnabled: editor.isKeyframesEnabled(),
|
|
4065
|
+
selectedKeyframe: editor.getSelectedKeyframe(),
|
|
2833
4066
|
onSeek: cb.onSeek,
|
|
2834
4067
|
onSelectClip: cb.onSelectClip,
|
|
2835
4068
|
onMoveClip: cb.onMoveClip,
|
|
2836
4069
|
onResizeClip: cb.onResizeClip,
|
|
4070
|
+
onSelectKeyframe: cb.onSelectKeyframe,
|
|
4071
|
+
onMoveKeyframe: cb.onMoveKeyframe,
|
|
2837
4072
|
onScaleChange: cb.onScaleChange,
|
|
2838
4073
|
onDeleteTrack: (trackId) => editor.removeTrack(trackId),
|
|
2839
4074
|
// Mirror the editor's smart routing into the drag preview so
|
|
@@ -2858,6 +4093,8 @@ var EditorUI = class {
|
|
|
2858
4093
|
};
|
|
2859
4094
|
}
|
|
2860
4095
|
});
|
|
4096
|
+
this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
|
|
4097
|
+
this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
|
|
2861
4098
|
this.attachKeyboard(cb);
|
|
2862
4099
|
}
|
|
2863
4100
|
// ---- fullscreen -----------------------------------------------------
|
|
@@ -2907,6 +4144,13 @@ var EditorUI = class {
|
|
|
2907
4144
|
const selectedClipId = this.editor.getSelection();
|
|
2908
4145
|
const pxPerSec = this.editor.getScale();
|
|
2909
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
|
+
);
|
|
2910
4154
|
this.toolbar.render({
|
|
2911
4155
|
playing: this.editor.isPlaying(),
|
|
2912
4156
|
time,
|
|
@@ -2915,18 +4159,34 @@ var EditorUI = class {
|
|
|
2915
4159
|
canRedo: this.editor.canRedo(),
|
|
2916
4160
|
canSplit: this.canSplitAt(time),
|
|
2917
4161
|
canTrim: this.canTrimAt(time, selectedClipId),
|
|
4162
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4163
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
2918
4164
|
snap,
|
|
2919
|
-
pxPerSec
|
|
4165
|
+
pxPerSec,
|
|
4166
|
+
...kfState
|
|
2920
4167
|
});
|
|
2921
4168
|
this.timeline.setProject(project);
|
|
2922
4169
|
this.timeline.setTime(time);
|
|
2923
4170
|
this.timeline.setScale(pxPerSec);
|
|
2924
4171
|
this.timeline.setSelection(selectedClipId);
|
|
2925
4172
|
this.timeline.setSnap(snap);
|
|
4173
|
+
this.timeline.setKeyframeState({
|
|
4174
|
+
enabled: this.editor.isKeyframesEnabled(),
|
|
4175
|
+
selected: this.editor.getSelectedKeyframe()
|
|
4176
|
+
});
|
|
4177
|
+
this.keyframePanel.render();
|
|
2926
4178
|
}
|
|
2927
4179
|
/** Playback-fast path: nudge playhead + toolbar time label only. */
|
|
2928
4180
|
onTimeTick(timeMs) {
|
|
2929
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
|
+
);
|
|
2930
4190
|
this.toolbar.render({
|
|
2931
4191
|
playing: this.editor.isPlaying(),
|
|
2932
4192
|
time: timeMs,
|
|
@@ -2934,9 +4194,12 @@ var EditorUI = class {
|
|
|
2934
4194
|
canUndo: this.editor.canUndo(),
|
|
2935
4195
|
canRedo: this.editor.canRedo(),
|
|
2936
4196
|
canSplit: this.canSplitAt(timeMs),
|
|
2937
|
-
canTrim: this.canTrimAt(timeMs,
|
|
4197
|
+
canTrim: this.canTrimAt(timeMs, selectedClipId),
|
|
4198
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4199
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
2938
4200
|
snap: this.editor.getSnap(),
|
|
2939
|
-
pxPerSec: this.editor.getScale()
|
|
4201
|
+
pxPerSec: this.editor.getScale(),
|
|
4202
|
+
...kfState
|
|
2940
4203
|
});
|
|
2941
4204
|
}
|
|
2942
4205
|
/** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
|
|
@@ -2948,6 +4211,7 @@ var EditorUI = class {
|
|
|
2948
4211
|
this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
|
|
2949
4212
|
this.fullscreenExitBtn.textContent = locale.exitFullscreen;
|
|
2950
4213
|
this.timeline.setLocale(locale);
|
|
4214
|
+
this.keyframePanel.setLocale(locale);
|
|
2951
4215
|
this.render();
|
|
2952
4216
|
}
|
|
2953
4217
|
destroy() {
|
|
@@ -2957,10 +4221,56 @@ var EditorUI = class {
|
|
|
2957
4221
|
}
|
|
2958
4222
|
this.toolbar.destroy();
|
|
2959
4223
|
this.timeline.destroy();
|
|
4224
|
+
this.keyframePanel.destroy();
|
|
4225
|
+
this.keyframeOverlay.destroy();
|
|
2960
4226
|
this.root.innerHTML = "";
|
|
2961
4227
|
this.root.classList.remove("aicut-root", "aicut-fullscreen");
|
|
2962
4228
|
}
|
|
2963
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
|
+
}
|
|
2964
4274
|
canSplitAt(timeMs) {
|
|
2965
4275
|
const project = this.editor.getProject();
|
|
2966
4276
|
for (const t of project.tracks) {
|
|
@@ -3002,6 +4312,22 @@ var EditorUI = class {
|
|
|
3002
4312
|
} else if (e.code === "KeyW") {
|
|
3003
4313
|
e.preventDefault();
|
|
3004
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);
|
|
3005
4331
|
} else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
|
|
3006
4332
|
e.preventDefault();
|
|
3007
4333
|
if (e.shiftKey) cb.onRedo();
|
|
@@ -3030,6 +4356,13 @@ var Editor = class _Editor {
|
|
|
3030
4356
|
bus = new EventBus();
|
|
3031
4357
|
history = new HistoryStack();
|
|
3032
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;
|
|
3033
4366
|
pxPerSec;
|
|
3034
4367
|
snap;
|
|
3035
4368
|
locale;
|
|
@@ -3040,6 +4373,8 @@ var Editor = class _Editor {
|
|
|
3040
4373
|
this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
|
|
3041
4374
|
this.snap = opts.initialSnap !== false;
|
|
3042
4375
|
this.locale = mergeLocale(opts.locale);
|
|
4376
|
+
this.keyframesEnabled = opts.keyframes?.enabled === true;
|
|
4377
|
+
this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
|
|
3043
4378
|
if (opts.trackHeight != null || opts.rulerHeight != null) {
|
|
3044
4379
|
setTimelineMetrics({
|
|
3045
4380
|
...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
|
|
@@ -3068,7 +4403,12 @@ var Editor = class _Editor {
|
|
|
3068
4403
|
onSelectClip: (id) => this.setSelection(id),
|
|
3069
4404
|
onDeleteClip: (id) => this.removeClip(id),
|
|
3070
4405
|
onMoveClip: (id, opts2) => this.moveClip(id, opts2),
|
|
3071
|
-
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")
|
|
3072
4412
|
});
|
|
3073
4413
|
const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
|
|
3074
4414
|
this.engine = engineFactory({
|
|
@@ -3492,6 +4832,9 @@ var Editor = class _Editor {
|
|
|
3492
4832
|
for (const c of t.clips) {
|
|
3493
4833
|
if (c.id === ignoreClipId) continue;
|
|
3494
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
|
+
}
|
|
3495
4838
|
}
|
|
3496
4839
|
}
|
|
3497
4840
|
let best = timeMs;
|
|
@@ -3513,6 +4856,304 @@ var Editor = class _Editor {
|
|
|
3513
4856
|
if (clipId === this.selectedClipId) return;
|
|
3514
4857
|
this.selectedClipId = clipId;
|
|
3515
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 });
|
|
3516
5157
|
this.ui.render();
|
|
3517
5158
|
}
|
|
3518
5159
|
// ---- history --------------------------------------------------------
|
|
@@ -3526,6 +5167,7 @@ var Editor = class _Editor {
|
|
|
3526
5167
|
const prev = this.history.undo(this.project);
|
|
3527
5168
|
if (!prev) return false;
|
|
3528
5169
|
this.project = prev;
|
|
5170
|
+
this.reconcileSelectionsWithProject();
|
|
3529
5171
|
this.engine.setProject(this.project);
|
|
3530
5172
|
this.bus.emit("change", { project: this.getProject() });
|
|
3531
5173
|
this.emitHistory();
|
|
@@ -3536,12 +5178,57 @@ var Editor = class _Editor {
|
|
|
3536
5178
|
const next = this.history.redo(this.project);
|
|
3537
5179
|
if (!next) return false;
|
|
3538
5180
|
this.project = next;
|
|
5181
|
+
this.reconcileSelectionsWithProject();
|
|
3539
5182
|
this.engine.setProject(this.project);
|
|
3540
5183
|
this.bus.emit("change", { project: this.getProject() });
|
|
3541
5184
|
this.emitHistory();
|
|
3542
5185
|
this.ui.render();
|
|
3543
5186
|
return true;
|
|
3544
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
|
+
}
|
|
3545
5232
|
// ---- events ---------------------------------------------------------
|
|
3546
5233
|
on(event, handler) {
|
|
3547
5234
|
return this.bus.on(event, handler);
|
|
@@ -3578,6 +5265,12 @@ var Editor = class _Editor {
|
|
|
3578
5265
|
return null;
|
|
3579
5266
|
}
|
|
3580
5267
|
pushHistory() {
|
|
5268
|
+
if (this.interactionDepth > 0) {
|
|
5269
|
+
if (this.interactionStartSnapshot == null) {
|
|
5270
|
+
this.interactionStartSnapshot = JSON.stringify(this.project);
|
|
5271
|
+
}
|
|
5272
|
+
return;
|
|
5273
|
+
}
|
|
3581
5274
|
this.history.push(this.project);
|
|
3582
5275
|
this.emitHistory();
|
|
3583
5276
|
}
|