@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.cjs
CHANGED
|
@@ -80,7 +80,110 @@ function createId(prefix = "id") {
|
|
|
80
80
|
return `${prefix}_${t}${rand}`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// src/keyframes/types.ts
|
|
84
|
+
var IDENTITY_TRANSFORM = {
|
|
85
|
+
panX: 0,
|
|
86
|
+
panY: 0,
|
|
87
|
+
scale: 1
|
|
88
|
+
};
|
|
89
|
+
function isIdentityTransform(t) {
|
|
90
|
+
return Math.abs(t.panX) < 1e-3 && Math.abs(t.panY) < 1e-3 && Math.abs(t.scale - 1) < 1e-4;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/keyframes/interpolate.ts
|
|
94
|
+
function applyEasing(t, easing) {
|
|
95
|
+
switch (easing) {
|
|
96
|
+
case "linear":
|
|
97
|
+
return t;
|
|
98
|
+
case "easeIn":
|
|
99
|
+
return t * t * t;
|
|
100
|
+
case "easeOut": {
|
|
101
|
+
const u = 1 - t;
|
|
102
|
+
return 1 - u * u * u;
|
|
103
|
+
}
|
|
104
|
+
case "easeInOut":
|
|
105
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function defaultFor(prop) {
|
|
109
|
+
return prop === "scale" ? 1 : 0;
|
|
110
|
+
}
|
|
111
|
+
function staticValue(clip, prop) {
|
|
112
|
+
const v = clip[prop];
|
|
113
|
+
return v ?? defaultFor(prop);
|
|
114
|
+
}
|
|
115
|
+
function keyframesForProp(kfs, prop) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const k of kfs) if (k.prop === prop) out.push(k);
|
|
118
|
+
out.sort((a, b) => a.time - b.time);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function hasKeyframesForProp(clip, prop) {
|
|
122
|
+
return clip.keyframes?.some((k) => k.prop === prop) ?? false;
|
|
123
|
+
}
|
|
124
|
+
function interpolateProp(clip, prop, localMs) {
|
|
125
|
+
if (!clip.keyframes || clip.keyframes.length === 0) {
|
|
126
|
+
return staticValue(clip, prop);
|
|
127
|
+
}
|
|
128
|
+
const arr = keyframesForProp(clip.keyframes, prop);
|
|
129
|
+
if (arr.length === 0) return staticValue(clip, prop);
|
|
130
|
+
if (arr.length === 1) return arr[0].value;
|
|
131
|
+
const first = arr[0];
|
|
132
|
+
const last = arr[arr.length - 1];
|
|
133
|
+
if (localMs <= first.time) return first.value;
|
|
134
|
+
if (localMs >= last.time) return last.value;
|
|
135
|
+
for (let i = 0; i < arr.length - 1; i += 1) {
|
|
136
|
+
const a = arr[i];
|
|
137
|
+
const b = arr[i + 1];
|
|
138
|
+
if (localMs >= a.time && localMs <= b.time) {
|
|
139
|
+
if (b.time === a.time) return a.value;
|
|
140
|
+
const rawT = (localMs - a.time) / (b.time - a.time);
|
|
141
|
+
const eased = applyEasing(rawT, a.easing ?? "linear");
|
|
142
|
+
return a.value + (b.value - a.value) * eased;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return last.value;
|
|
146
|
+
}
|
|
147
|
+
function getEffectiveTransform(clip, localMs) {
|
|
148
|
+
if ((!clip.keyframes || clip.keyframes.length === 0) && clip.panX === void 0 && clip.panY === void 0 && clip.scale === void 0) {
|
|
149
|
+
return IDENTITY_TRANSFORM;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
panX: interpolateProp(clip, "panX", localMs),
|
|
153
|
+
panY: interpolateProp(clip, "panY", localMs),
|
|
154
|
+
scale: interpolateProp(clip, "scale", localMs)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function getTransformAtTimelineTime(clip, timelineMs) {
|
|
158
|
+
return getEffectiveTransform(clip, timelineMs - clip.start);
|
|
159
|
+
}
|
|
160
|
+
function upsertKeyframe(kfs, prop, time, value, idFactory) {
|
|
161
|
+
const existing = kfs ?? [];
|
|
162
|
+
const idx = existing.findIndex(
|
|
163
|
+
(k) => k.prop === prop && Math.abs(k.time - time) < 16
|
|
164
|
+
);
|
|
165
|
+
if (idx >= 0) {
|
|
166
|
+
const next = existing.slice();
|
|
167
|
+
next[idx] = { ...next[idx], value };
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
return [...existing, { id: idFactory(), prop, time, value }];
|
|
171
|
+
}
|
|
172
|
+
|
|
83
173
|
// src/model.ts
|
|
174
|
+
var KEYFRAME_PROPS = ["panX", "panY", "scale"];
|
|
175
|
+
var DEFAULT_FPS = 30;
|
|
176
|
+
var BIG_FRAME_STEP = 10;
|
|
177
|
+
function projectFps(project) {
|
|
178
|
+
const f = project.fps;
|
|
179
|
+
return f != null && f > 0 ? f : DEFAULT_FPS;
|
|
180
|
+
}
|
|
181
|
+
function frameStepMs(project) {
|
|
182
|
+
return Math.max(1, Math.round(1e3 / projectFps(project)));
|
|
183
|
+
}
|
|
184
|
+
function bigFrameStepMs(project) {
|
|
185
|
+
return Math.max(1, Math.round(BIG_FRAME_STEP * 1e3 / projectFps(project)));
|
|
186
|
+
}
|
|
84
187
|
function createEmptyProject() {
|
|
85
188
|
return {
|
|
86
189
|
version: 1,
|
|
@@ -117,11 +220,58 @@ function findTrackOfClip(project, clipId) {
|
|
|
117
220
|
function normalizeProject(project) {
|
|
118
221
|
const sources = project.sources.map((s) => ({ ...s }));
|
|
119
222
|
const tracks = project.tracks.map((t) => {
|
|
120
|
-
const clips = t.clips.filter((c) => c.out > c.in).map((c) =>
|
|
223
|
+
const clips = t.clips.filter((c) => c.out > c.in).map((c) => {
|
|
224
|
+
const out = { ...c, id: c.id || createId("clip") };
|
|
225
|
+
if (c.keyframes && c.keyframes.length > 0) {
|
|
226
|
+
const duration = c.out - c.in;
|
|
227
|
+
out.keyframes = migrateKeyframes(c.keyframes).filter((kf) => kf.time >= 0 && kf.time <= duration).map((kf) => ({ ...kf, id: kf.id || createId("kf") })).sort((a, b) => {
|
|
228
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
229
|
+
return a.time - b.time;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}).sort((a, b) => a.start - b.start);
|
|
121
234
|
return { ...t, id: t.id || createId("track"), clips };
|
|
122
235
|
});
|
|
123
236
|
return { version: 1, sources, tracks };
|
|
124
237
|
}
|
|
238
|
+
function migrateKeyframes(raw) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const kf of raw) {
|
|
241
|
+
if ("prop" in kf && "value" in kf) {
|
|
242
|
+
out.push(kf);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const tuple = kf;
|
|
246
|
+
const id = tuple.id;
|
|
247
|
+
const t = tuple.time;
|
|
248
|
+
if (typeof tuple.x === "number") {
|
|
249
|
+
out.push({
|
|
250
|
+
id: id ? `${id}-px` : createId("kf"),
|
|
251
|
+
prop: "panX",
|
|
252
|
+
time: t,
|
|
253
|
+
value: tuple.x
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (typeof tuple.y === "number") {
|
|
257
|
+
out.push({
|
|
258
|
+
id: id ? `${id}-py` : createId("kf"),
|
|
259
|
+
prop: "panY",
|
|
260
|
+
time: t,
|
|
261
|
+
value: tuple.y
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (typeof tuple.scale === "number") {
|
|
265
|
+
out.push({
|
|
266
|
+
id: id ? `${id}-s` : createId("kf"),
|
|
267
|
+
prop: "scale",
|
|
268
|
+
time: t,
|
|
269
|
+
value: tuple.scale
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
125
275
|
function splitClipAt(clip, localOffset) {
|
|
126
276
|
const sourceLen = clip.out - clip.in;
|
|
127
277
|
if (localOffset <= 0 || localOffset >= sourceLen) return null;
|
|
@@ -132,6 +282,47 @@ function splitClipAt(clip, localOffset) {
|
|
|
132
282
|
in: clip.in + localOffset,
|
|
133
283
|
start: clip.start + localOffset
|
|
134
284
|
};
|
|
285
|
+
if (clip.keyframes && clip.keyframes.length > 0) {
|
|
286
|
+
const leftKf = [];
|
|
287
|
+
const rightKf = [];
|
|
288
|
+
for (const prop of KEYFRAME_PROPS) {
|
|
289
|
+
const propKfs = clip.keyframes.filter((k) => k.prop === prop);
|
|
290
|
+
if (propKfs.length === 0) continue;
|
|
291
|
+
const boundaryValue = interpolateProp(clip, prop, localOffset);
|
|
292
|
+
let leftSeamPresent = false;
|
|
293
|
+
let rightSeamPresent = false;
|
|
294
|
+
for (const kf of propKfs) {
|
|
295
|
+
if (kf.time < localOffset) {
|
|
296
|
+
leftKf.push(kf);
|
|
297
|
+
} else if (kf.time > localOffset) {
|
|
298
|
+
rightKf.push({ ...kf, id: createId("kf"), time: kf.time - localOffset });
|
|
299
|
+
} else {
|
|
300
|
+
leftKf.push(kf);
|
|
301
|
+
leftSeamPresent = true;
|
|
302
|
+
rightKf.push({ ...kf, id: createId("kf"), time: 0 });
|
|
303
|
+
rightSeamPresent = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!leftSeamPresent) {
|
|
307
|
+
leftKf.push({
|
|
308
|
+
id: createId("kf"),
|
|
309
|
+
prop,
|
|
310
|
+
time: localOffset,
|
|
311
|
+
value: boundaryValue
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (!rightSeamPresent) {
|
|
315
|
+
rightKf.push({
|
|
316
|
+
id: createId("kf"),
|
|
317
|
+
prop,
|
|
318
|
+
time: 0,
|
|
319
|
+
value: boundaryValue
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
left.keyframes = leftKf.length > 0 ? leftKf : void 0;
|
|
324
|
+
right.keyframes = rightKf.length > 0 ? rightKf : void 0;
|
|
325
|
+
}
|
|
135
326
|
return [left, right];
|
|
136
327
|
}
|
|
137
328
|
function projectDuration(project) {
|
|
@@ -151,6 +342,11 @@ var HANDLE_PX = 8;
|
|
|
151
342
|
var CLIP_INSET = 6;
|
|
152
343
|
var SCALE_MIN = 10;
|
|
153
344
|
var SCALE_MAX = 400;
|
|
345
|
+
var TIMELINE_PAD_LEFT = 12;
|
|
346
|
+
var TIMELINE_PAD_RIGHT = 12;
|
|
347
|
+
function contentLeftX(showHeader) {
|
|
348
|
+
return (showHeader ? HEADER_WIDTH : 0) + TIMELINE_PAD_LEFT;
|
|
349
|
+
}
|
|
154
350
|
function setTimelineMetrics(opts) {
|
|
155
351
|
if (opts.trackHeight != null && opts.trackHeight > 0) {
|
|
156
352
|
exports.TRACK_HEIGHT = Math.round(opts.trackHeight);
|
|
@@ -186,8 +382,10 @@ function trackIndexAt(y, trackCount, scrollTop = 0) {
|
|
|
186
382
|
return idx;
|
|
187
383
|
}
|
|
188
384
|
function xToMs(x, pxPerSec, scrollLeft, showHeader) {
|
|
189
|
-
|
|
190
|
-
|
|
385
|
+
return Math.max(
|
|
386
|
+
0,
|
|
387
|
+
(x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
|
|
388
|
+
);
|
|
191
389
|
}
|
|
192
390
|
function niceTickSeconds(targetSec) {
|
|
193
391
|
if (targetSec <= 0) return 1;
|
|
@@ -215,6 +413,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
|
|
|
215
413
|
for (const c of t.clips) {
|
|
216
414
|
if (c.id === ignoreClipId) continue;
|
|
217
415
|
arr.push(c.start, c.start + (c.out - c.in));
|
|
416
|
+
if (c.keyframes) {
|
|
417
|
+
for (const kf of c.keyframes) arr.push(c.start + kf.time);
|
|
418
|
+
}
|
|
218
419
|
}
|
|
219
420
|
}
|
|
220
421
|
return arr;
|
|
@@ -265,13 +466,16 @@ function uncoveredIntervals(project) {
|
|
|
265
466
|
var HtmlVideoEngine = class {
|
|
266
467
|
host;
|
|
267
468
|
mount;
|
|
268
|
-
|
|
469
|
+
sources = /* @__PURE__ */ new Map();
|
|
269
470
|
project;
|
|
270
471
|
currentClipId = null;
|
|
271
472
|
playing = false;
|
|
272
473
|
timeMs = 0;
|
|
273
474
|
rafHandle = null;
|
|
274
475
|
lastFrameTs = 0;
|
|
476
|
+
/** Permanent rAF that positions the active wrapper at the output
|
|
477
|
+
* rect + pushes keyframe transform onto the inner video via CSS. */
|
|
478
|
+
transformRaf = null;
|
|
275
479
|
/** Public event hooks — set by Editor. */
|
|
276
480
|
onTimeUpdate;
|
|
277
481
|
onEnded;
|
|
@@ -283,8 +487,15 @@ var HtmlVideoEngine = class {
|
|
|
283
487
|
this.project = opts.project;
|
|
284
488
|
this.mount = document.createElement("div");
|
|
285
489
|
this.mount.className = "aicut-preview";
|
|
490
|
+
Object.assign(this.mount.style, {
|
|
491
|
+
position: "absolute",
|
|
492
|
+
inset: "0",
|
|
493
|
+
width: "100%",
|
|
494
|
+
height: "100%"
|
|
495
|
+
});
|
|
286
496
|
this.host.appendChild(this.mount);
|
|
287
497
|
this.syncSources();
|
|
498
|
+
this.startTransformLoop();
|
|
288
499
|
}
|
|
289
500
|
setProject(next) {
|
|
290
501
|
this.project = next;
|
|
@@ -307,9 +518,9 @@ var HtmlVideoEngine = class {
|
|
|
307
518
|
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
308
519
|
this.activate(clip);
|
|
309
520
|
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
310
|
-
const
|
|
311
|
-
if (!
|
|
312
|
-
void
|
|
521
|
+
const s = this.sources.get(clip.sourceId);
|
|
522
|
+
if (!s) return;
|
|
523
|
+
void s.video.play().catch((err) => this.onError?.(err));
|
|
313
524
|
this.playing = true;
|
|
314
525
|
this.startTickLoop();
|
|
315
526
|
}
|
|
@@ -320,8 +531,7 @@ var HtmlVideoEngine = class {
|
|
|
320
531
|
if (this.currentClipId) {
|
|
321
532
|
const clip = this.clipById(this.currentClipId);
|
|
322
533
|
if (clip) {
|
|
323
|
-
|
|
324
|
-
v?.pause();
|
|
534
|
+
this.sources.get(clip.sourceId)?.video.pause();
|
|
325
535
|
}
|
|
326
536
|
}
|
|
327
537
|
}
|
|
@@ -348,41 +558,137 @@ var HtmlVideoEngine = class {
|
|
|
348
558
|
}
|
|
349
559
|
this.onTimeUpdate?.(clamped);
|
|
350
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* The OUTPUT frame — the fixed stage the rendered video is clipped
|
|
563
|
+
* to. Independent of the keyframe transform. Used by the overlay to
|
|
564
|
+
* draw the dashed border at a stable position.
|
|
565
|
+
*/
|
|
566
|
+
getOutputFrameRect() {
|
|
567
|
+
return this.baseFrameRect();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The CONTENT frame — where the transformed video pixels actually
|
|
571
|
+
* land. Equal to the output frame when transform is identity; may
|
|
572
|
+
* extend outside (zoom in) or fit inside (zoom out) when not.
|
|
573
|
+
*/
|
|
574
|
+
getFrameRect() {
|
|
575
|
+
const base = this.baseFrameRect();
|
|
576
|
+
if (!base) return null;
|
|
577
|
+
const clip = this.clipById(this.currentClipId);
|
|
578
|
+
if (!clip) return base;
|
|
579
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
580
|
+
const cx = base.x + base.w / 2 + t.panX;
|
|
581
|
+
const cy = base.y + base.h / 2 + t.panY;
|
|
582
|
+
const w = base.w * t.scale;
|
|
583
|
+
const h = base.h * t.scale;
|
|
584
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
585
|
+
}
|
|
586
|
+
/** Untransformed contain-letterbox rect — the OUTPUT frame. */
|
|
587
|
+
baseFrameRect() {
|
|
588
|
+
if (!this.currentClipId) return null;
|
|
589
|
+
const clip = this.clipById(this.currentClipId);
|
|
590
|
+
if (!clip) return null;
|
|
591
|
+
const s = this.sources.get(clip.sourceId);
|
|
592
|
+
if (!s) return null;
|
|
593
|
+
const v = s.video;
|
|
594
|
+
if (v.videoWidth === 0 || v.videoHeight === 0) return null;
|
|
595
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
596
|
+
const cw = hostRect.width;
|
|
597
|
+
const ch = hostRect.height;
|
|
598
|
+
if (cw === 0 || ch === 0) return null;
|
|
599
|
+
const scale = Math.min(cw / v.videoWidth, ch / v.videoHeight);
|
|
600
|
+
const w = v.videoWidth * scale;
|
|
601
|
+
const h = v.videoHeight * scale;
|
|
602
|
+
return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Permanent rAF that (a) sizes + positions the active wrapper to
|
|
606
|
+
* the output frame, and (b) writes the keyframe transform onto the
|
|
607
|
+
* inner video. Negligible cost — three style writes per frame max.
|
|
608
|
+
*/
|
|
609
|
+
startTransformLoop() {
|
|
610
|
+
const tick = () => {
|
|
611
|
+
this.applyTransforms();
|
|
612
|
+
this.transformRaf = requestAnimationFrame(tick);
|
|
613
|
+
};
|
|
614
|
+
this.transformRaf = requestAnimationFrame(tick);
|
|
615
|
+
}
|
|
616
|
+
applyTransforms() {
|
|
617
|
+
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
618
|
+
const outRect = this.baseFrameRect();
|
|
619
|
+
if (clip && outRect) {
|
|
620
|
+
const s = this.sources.get(clip.sourceId);
|
|
621
|
+
if (s) {
|
|
622
|
+
Object.assign(s.wrapper.style, {
|
|
623
|
+
left: `${outRect.x}px`,
|
|
624
|
+
top: `${outRect.y}px`,
|
|
625
|
+
width: `${outRect.w}px`,
|
|
626
|
+
height: `${outRect.h}px`
|
|
627
|
+
});
|
|
628
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
629
|
+
s.video.style.transform = `translate(${t.panX.toFixed(2)}px, ${t.panY.toFixed(2)}px) scale(${t.scale.toFixed(4)})`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
for (const [id, s] of this.sources) {
|
|
633
|
+
if (clip && id === clip.sourceId) continue;
|
|
634
|
+
if (s.video.style.transform) s.video.style.transform = "";
|
|
635
|
+
}
|
|
636
|
+
}
|
|
351
637
|
destroy() {
|
|
352
638
|
this.stopTickLoop();
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
v.load();
|
|
357
|
-
v.remove();
|
|
639
|
+
if (this.transformRaf != null) {
|
|
640
|
+
cancelAnimationFrame(this.transformRaf);
|
|
641
|
+
this.transformRaf = null;
|
|
358
642
|
}
|
|
359
|
-
this.
|
|
643
|
+
for (const s of this.sources.values()) {
|
|
644
|
+
s.video.pause();
|
|
645
|
+
s.video.removeAttribute("src");
|
|
646
|
+
s.video.load();
|
|
647
|
+
s.wrapper.remove();
|
|
648
|
+
}
|
|
649
|
+
this.sources.clear();
|
|
360
650
|
this.mount.remove();
|
|
361
651
|
}
|
|
362
652
|
// --- internals -------------------------------------------------------
|
|
363
653
|
syncSources() {
|
|
364
654
|
const wanted = new Set(this.project.sources.map((s) => s.id));
|
|
365
|
-
for (const [id,
|
|
655
|
+
for (const [id, s] of this.sources) {
|
|
366
656
|
if (!wanted.has(id)) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
this.
|
|
657
|
+
s.video.pause();
|
|
658
|
+
s.wrapper.remove();
|
|
659
|
+
this.sources.delete(id);
|
|
370
660
|
}
|
|
371
661
|
}
|
|
372
662
|
for (const src of this.project.sources) {
|
|
373
663
|
if (src.kind !== "video") continue;
|
|
374
|
-
if (this.
|
|
664
|
+
if (this.sources.has(src.id)) continue;
|
|
665
|
+
const wrapper = document.createElement("div");
|
|
666
|
+
wrapper.className = "aicut-preview-clip";
|
|
667
|
+
Object.assign(wrapper.style, {
|
|
668
|
+
position: "absolute",
|
|
669
|
+
overflow: "hidden",
|
|
670
|
+
visibility: "hidden",
|
|
671
|
+
// Initial bounds — applyTransforms overrides each frame.
|
|
672
|
+
left: "0",
|
|
673
|
+
top: "0",
|
|
674
|
+
width: "0",
|
|
675
|
+
height: "0"
|
|
676
|
+
});
|
|
375
677
|
const v = document.createElement("video");
|
|
376
678
|
v.preload = "auto";
|
|
377
679
|
v.playsInline = true;
|
|
378
680
|
v.muted = false;
|
|
379
681
|
v.src = src.url;
|
|
380
|
-
v.style
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
682
|
+
Object.assign(v.style, {
|
|
683
|
+
position: "absolute",
|
|
684
|
+
inset: "0",
|
|
685
|
+
width: "100%",
|
|
686
|
+
height: "100%",
|
|
687
|
+
objectFit: "fill",
|
|
688
|
+
// Transform origin at center so scale() scales around the
|
|
689
|
+
// video's centroid, not its top-left corner.
|
|
690
|
+
transformOrigin: "50% 50%"
|
|
691
|
+
});
|
|
386
692
|
const sourceId = src.id;
|
|
387
693
|
v.addEventListener(
|
|
388
694
|
"error",
|
|
@@ -395,8 +701,9 @@ var HtmlVideoEngine = class {
|
|
|
395
701
|
this.onSourceMetadata?.(sourceId, durMs);
|
|
396
702
|
}
|
|
397
703
|
});
|
|
398
|
-
|
|
399
|
-
this.
|
|
704
|
+
wrapper.appendChild(v);
|
|
705
|
+
this.mount.appendChild(wrapper);
|
|
706
|
+
this.sources.set(src.id, { wrapper, video: v });
|
|
400
707
|
}
|
|
401
708
|
}
|
|
402
709
|
activate(clip) {
|
|
@@ -404,25 +711,25 @@ var HtmlVideoEngine = class {
|
|
|
404
711
|
if (this.currentClipId) {
|
|
405
712
|
const prev = this.clipById(this.currentClipId);
|
|
406
713
|
if (prev) {
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
714
|
+
const s = this.sources.get(prev.sourceId);
|
|
715
|
+
if (s) {
|
|
716
|
+
s.video.pause();
|
|
717
|
+
s.wrapper.style.visibility = "hidden";
|
|
411
718
|
}
|
|
412
719
|
}
|
|
413
720
|
}
|
|
414
721
|
this.currentClipId = clip ? clip.id : null;
|
|
415
722
|
if (clip) {
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
723
|
+
const s = this.sources.get(clip.sourceId);
|
|
724
|
+
if (s) s.wrapper.style.visibility = "visible";
|
|
418
725
|
}
|
|
419
726
|
}
|
|
420
727
|
seekVideoToClipOffset(clip, offsetMs) {
|
|
421
|
-
const
|
|
422
|
-
if (!
|
|
728
|
+
const s = this.sources.get(clip.sourceId);
|
|
729
|
+
if (!s) return;
|
|
423
730
|
const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
|
|
424
|
-
if (Math.abs(
|
|
425
|
-
|
|
731
|
+
if (Math.abs(s.video.currentTime - target) > 0.05) {
|
|
732
|
+
s.video.currentTime = target;
|
|
426
733
|
}
|
|
427
734
|
}
|
|
428
735
|
clipById(id) {
|
|
@@ -434,10 +741,7 @@ var HtmlVideoEngine = class {
|
|
|
434
741
|
/**
|
|
435
742
|
* Find the clip whose timeline range contains `timeMs`, searching
|
|
436
743
|
* across ALL video tracks. If multiple tracks have a clip at this
|
|
437
|
-
* moment, the lowest-index track wins
|
|
438
|
-
* background" convention used in the auto-split UX — overlapping
|
|
439
|
-
* placements would have created a new track on top, but here we
|
|
440
|
-
* fall back to the underlying clip).
|
|
744
|
+
* moment, the lowest-index track wins.
|
|
441
745
|
*/
|
|
442
746
|
clipAtTime(timeMs) {
|
|
443
747
|
for (const t of this.project.tracks) {
|
|
@@ -506,8 +810,8 @@ var HtmlVideoEngine = class {
|
|
|
506
810
|
this.timeMs = next.start;
|
|
507
811
|
this.activate(next);
|
|
508
812
|
this.seekVideoToClipOffset(next, 0);
|
|
509
|
-
const
|
|
510
|
-
if (
|
|
813
|
+
const s = this.sources.get(next.sourceId);
|
|
814
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
511
815
|
} else {
|
|
512
816
|
this.pause();
|
|
513
817
|
this.onEnded?.();
|
|
@@ -516,8 +820,8 @@ var HtmlVideoEngine = class {
|
|
|
516
820
|
} else if (clip.id !== this.currentClipId) {
|
|
517
821
|
this.activate(clip);
|
|
518
822
|
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
519
|
-
const
|
|
520
|
-
if (
|
|
823
|
+
const s = this.sources.get(clip.sourceId);
|
|
824
|
+
if (s) void s.video.play().catch((err) => this.onError?.(err));
|
|
521
825
|
}
|
|
522
826
|
this.onTimeUpdate?.(this.timeMs);
|
|
523
827
|
}
|
|
@@ -540,6 +844,10 @@ var CanvasCompositorEngine = class {
|
|
|
540
844
|
rafHandle = null;
|
|
541
845
|
lastFrameTs = 0;
|
|
542
846
|
paintedFrames = 0;
|
|
847
|
+
/** Output frame rect (no transform) — fixed bounds. */
|
|
848
|
+
lastOutputRect = null;
|
|
849
|
+
/** Post-transform content rect. */
|
|
850
|
+
lastFrameRect = null;
|
|
543
851
|
onTimeUpdate;
|
|
544
852
|
onEnded;
|
|
545
853
|
onError;
|
|
@@ -824,19 +1132,55 @@ var CanvasCompositorEngine = class {
|
|
|
824
1132
|
this.ctx.clearRect(0, 0, cw, ch);
|
|
825
1133
|
const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
|
|
826
1134
|
const v = clip ? this.videos.get(clip.sourceId) : null;
|
|
827
|
-
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
|
|
1135
|
+
if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
|
|
828
1136
|
const vw = v.videoWidth;
|
|
829
1137
|
const vh = v.videoHeight;
|
|
830
|
-
const
|
|
831
|
-
const dw = vw *
|
|
832
|
-
const dh = vh *
|
|
833
|
-
const
|
|
834
|
-
const
|
|
835
|
-
|
|
1138
|
+
const baseScale = Math.min(cw / vw, ch / vh);
|
|
1139
|
+
const dw = vw * baseScale;
|
|
1140
|
+
const dh = vh * baseScale;
|
|
1141
|
+
const cx = cw / 2;
|
|
1142
|
+
const cy = ch / 2;
|
|
1143
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1144
|
+
const t = getEffectiveTransform(clip, this.timeMs - clip.start);
|
|
1145
|
+
const outX = (cw - dw) / 2;
|
|
1146
|
+
const outY = (ch - dh) / 2;
|
|
1147
|
+
this.ctx.save();
|
|
1148
|
+
this.ctx.beginPath();
|
|
1149
|
+
this.ctx.rect(outX, outY, dw, dh);
|
|
1150
|
+
this.ctx.clip();
|
|
1151
|
+
this.ctx.translate(cx + t.panX * dpr, cy + t.panY * dpr);
|
|
1152
|
+
this.ctx.scale(t.scale, t.scale);
|
|
1153
|
+
this.ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
|
|
1154
|
+
this.ctx.restore();
|
|
836
1155
|
this.paintedFrames += 1;
|
|
1156
|
+
this.lastOutputRect = {
|
|
1157
|
+
x: outX / dpr,
|
|
1158
|
+
y: outY / dpr,
|
|
1159
|
+
w: dw / dpr,
|
|
1160
|
+
h: dh / dpr
|
|
1161
|
+
};
|
|
1162
|
+
const cssCx = cw / (2 * dpr) + t.panX;
|
|
1163
|
+
const cssCy = ch / (2 * dpr) + t.panY;
|
|
1164
|
+
const cssW = dw * t.scale / dpr;
|
|
1165
|
+
const cssH = dh * t.scale / dpr;
|
|
1166
|
+
this.lastFrameRect = {
|
|
1167
|
+
x: cssCx - cssW / 2,
|
|
1168
|
+
y: cssCy - cssH / 2,
|
|
1169
|
+
w: cssW,
|
|
1170
|
+
h: cssH
|
|
1171
|
+
};
|
|
1172
|
+
} else {
|
|
1173
|
+
this.lastFrameRect = null;
|
|
1174
|
+
this.lastOutputRect = null;
|
|
837
1175
|
}
|
|
838
1176
|
this.updateBadge();
|
|
839
1177
|
}
|
|
1178
|
+
getOutputFrameRect() {
|
|
1179
|
+
return this.lastOutputRect;
|
|
1180
|
+
}
|
|
1181
|
+
getFrameRect() {
|
|
1182
|
+
return this.lastFrameRect;
|
|
1183
|
+
}
|
|
840
1184
|
updateBadge() {
|
|
841
1185
|
if (!this.badge) return;
|
|
842
1186
|
const sec = (this.timeMs / 1e3).toFixed(2);
|
|
@@ -878,12 +1222,11 @@ function applyTheme(root, theme) {
|
|
|
878
1222
|
|
|
879
1223
|
// src/i18n.ts
|
|
880
1224
|
var localeEn = {
|
|
881
|
-
undo: "Undo",
|
|
882
|
-
redo: "Redo",
|
|
883
|
-
split: "Split",
|
|
884
|
-
trimLeft: "Trim left edge",
|
|
885
|
-
trimRight: "Trim right edge",
|
|
886
|
-
speedComingSoon: "Speed (coming soon)",
|
|
1225
|
+
undo: "Undo (\u2318Z)",
|
|
1226
|
+
redo: "Redo (\u21E7\u2318Z)",
|
|
1227
|
+
split: "Split (K)",
|
|
1228
|
+
trimLeft: "Trim left edge (Q)",
|
|
1229
|
+
trimRight: "Trim right edge (W)",
|
|
887
1230
|
playPause: "Play / Pause (Space)",
|
|
888
1231
|
fullscreen: "Fullscreen preview",
|
|
889
1232
|
snap: "Snap",
|
|
@@ -892,6 +1235,25 @@ var localeEn = {
|
|
|
892
1235
|
zoomOut: "Zoom out",
|
|
893
1236
|
zoomIn: "Zoom in",
|
|
894
1237
|
reset: "Reset edits (keep sources)",
|
|
1238
|
+
keyframeAdd: "Add keyframe at playhead",
|
|
1239
|
+
keyframeRemove: "Remove keyframe at playhead",
|
|
1240
|
+
seekClipStart: "Jump to clip start (I)",
|
|
1241
|
+
seekClipEnd: "Jump to clip end (O)",
|
|
1242
|
+
keyframePanelTitle: "Keyframe",
|
|
1243
|
+
keyframePanelLabelX: "X",
|
|
1244
|
+
keyframePanelLabelY: "Y",
|
|
1245
|
+
keyframePanelLabelScale: "Scale",
|
|
1246
|
+
keyframePanelLabelEasing: "Easing",
|
|
1247
|
+
keyframePanelReset: "Reset to 0 0 1",
|
|
1248
|
+
keyframePanelResetTitle: "Pin this keyframe to identity (panX=0, panY=0, scale=1)",
|
|
1249
|
+
keyframePanelBadgePinned: "Pinned at this moment",
|
|
1250
|
+
keyframePanelBadgeAnimated: "Animated \u2014 but not pinned at this exact moment",
|
|
1251
|
+
keyframePanelBadgeStatic: "Static value",
|
|
1252
|
+
keyframePanelTimeSuffix: "s",
|
|
1253
|
+
keyframeEasingLinear: "Linear",
|
|
1254
|
+
keyframeEasingEaseIn: "Ease in",
|
|
1255
|
+
keyframeEasingEaseOut: "Ease out",
|
|
1256
|
+
keyframeEasingEaseInOut: "Ease in-out",
|
|
895
1257
|
exitFullscreen: "Exit fullscreen",
|
|
896
1258
|
exitFullscreenTitle: "Exit fullscreen (Esc)",
|
|
897
1259
|
newTrack: "+ New track",
|
|
@@ -899,12 +1261,11 @@ var localeEn = {
|
|
|
899
1261
|
audioTrackLabel: "Audio {n}"
|
|
900
1262
|
};
|
|
901
1263
|
var localeZh = {
|
|
902
|
-
undo: "\u64A4\u9500",
|
|
903
|
-
redo: "\u91CD\u505A",
|
|
904
|
-
split: "\u5206\u5272",
|
|
905
|
-
trimLeft: "\u5411\u5DE6\u88C1\u526A",
|
|
906
|
-
trimRight: "\u5411\u53F3\u88C1\u526A",
|
|
907
|
-
speedComingSoon: "\u53D8\u901F\uFF08\u5373\u5C06\u5230\u6765\uFF09",
|
|
1264
|
+
undo: "\u64A4\u9500 (\u2318Z)",
|
|
1265
|
+
redo: "\u91CD\u505A (\u21E7\u2318Z)",
|
|
1266
|
+
split: "\u5206\u5272 (K)",
|
|
1267
|
+
trimLeft: "\u5411\u5DE6\u88C1\u526A (Q)",
|
|
1268
|
+
trimRight: "\u5411\u53F3\u88C1\u526A (W)",
|
|
908
1269
|
playPause: "\u64AD\u653E / \u6682\u505C (Space)",
|
|
909
1270
|
fullscreen: "\u5168\u5C4F\u9884\u89C8",
|
|
910
1271
|
snap: "\u5438\u9644",
|
|
@@ -913,6 +1274,25 @@ var localeZh = {
|
|
|
913
1274
|
zoomOut: "\u7F29\u5C0F",
|
|
914
1275
|
zoomIn: "\u653E\u5927",
|
|
915
1276
|
reset: "\u91CD\u7F6E\u7F16\u8F91\uFF08\u4FDD\u7559\u89C6\u9891\u6E90\uFF09",
|
|
1277
|
+
keyframeAdd: "\u6DFB\u52A0\u5173\u952E\u5E27",
|
|
1278
|
+
keyframeRemove: "\u5220\u9664\u5F53\u524D\u5173\u952E\u5E27",
|
|
1279
|
+
seekClipStart: "\u8DF3\u5230\u7247\u6BB5\u8D77\u70B9 (I)",
|
|
1280
|
+
seekClipEnd: "\u8DF3\u5230\u7247\u6BB5\u672B\u5C3E (O)",
|
|
1281
|
+
keyframePanelTitle: "\u5173\u952E\u5E27",
|
|
1282
|
+
keyframePanelLabelX: "X \u4F4D\u79FB",
|
|
1283
|
+
keyframePanelLabelY: "Y \u4F4D\u79FB",
|
|
1284
|
+
keyframePanelLabelScale: "\u7F29\u653E",
|
|
1285
|
+
keyframePanelLabelEasing: "\u7F13\u52A8",
|
|
1286
|
+
keyframePanelReset: "\u91CD\u7F6E\u4E3A 0 0 1",
|
|
1287
|
+
keyframePanelResetTitle: "\u5C06\u8BE5\u5173\u952E\u5E27\u91CD\u7F6E\u4E3A\u521D\u59CB\u59FF\u6001\uFF08panX=0, panY=0, scale=1\uFF09",
|
|
1288
|
+
keyframePanelBadgePinned: "\u5DF2\u5728\u8BE5\u65F6\u523B\u56FA\u5B9A",
|
|
1289
|
+
keyframePanelBadgeAnimated: "\u6574\u6BB5\u6709\u52A8\u753B\uFF0C\u4F46\u5F53\u524D\u65F6\u523B\u6CA1\u6709\u9501\u70B9",
|
|
1290
|
+
keyframePanelBadgeStatic: "\u672A\u52A8\u753B\uFF08\u6CBF\u7528\u9759\u6001\u503C\uFF09",
|
|
1291
|
+
keyframePanelTimeSuffix: "\u79D2",
|
|
1292
|
+
keyframeEasingLinear: "\u7EBF\u6027",
|
|
1293
|
+
keyframeEasingEaseIn: "\u7F13\u5165",
|
|
1294
|
+
keyframeEasingEaseOut: "\u7F13\u51FA",
|
|
1295
|
+
keyframeEasingEaseInOut: "\u7F13\u5165\u7F13\u51FA",
|
|
916
1296
|
exitFullscreen: "\u9000\u51FA\u5168\u5C4F",
|
|
917
1297
|
exitFullscreenTitle: "\u9000\u51FA\u5168\u5C4F (Esc)",
|
|
918
1298
|
newTrack: "+ \u65B0\u8F68\u9053",
|
|
@@ -1106,7 +1486,7 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
1106
1486
|
const { viewportWidth: W, viewportHeight: H } = state;
|
|
1107
1487
|
ctx.fillStyle = style.bg;
|
|
1108
1488
|
ctx.fillRect(0, 0, W, H);
|
|
1109
|
-
const baseX = state.showHeader
|
|
1489
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1110
1490
|
const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
|
|
1111
1491
|
const trackAreaH = H - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1112
1492
|
ctx.save();
|
|
@@ -1146,15 +1526,21 @@ function drawAll(ctx, state, style, thumbs) {
|
|
|
1146
1526
|
ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
|
|
1147
1527
|
ctx.clip();
|
|
1148
1528
|
drawSnapGuide(ctx, state, style);
|
|
1149
|
-
drawPlayhead(ctx, state, style);
|
|
1150
1529
|
ctx.restore();
|
|
1151
1530
|
drawScrollbarV(ctx, state, style);
|
|
1152
1531
|
drawScrollbarH(ctx, state, style);
|
|
1532
|
+
const playheadLeft = state.showHeader ? HEADER_WIDTH : 0;
|
|
1533
|
+
ctx.save();
|
|
1534
|
+
ctx.beginPath();
|
|
1535
|
+
ctx.rect(playheadLeft, 0, W - playheadLeft, H);
|
|
1536
|
+
ctx.clip();
|
|
1537
|
+
drawPlayhead(ctx, state, style);
|
|
1538
|
+
ctx.restore();
|
|
1153
1539
|
}
|
|
1154
1540
|
function drawCoverageGaps(ctx, state, style) {
|
|
1155
1541
|
const gaps = uncoveredIntervals(state.project);
|
|
1156
1542
|
if (gaps.length === 0) return;
|
|
1157
|
-
const baseX = state.showHeader
|
|
1543
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1158
1544
|
const trackStackH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1159
1545
|
for (const [s, e] of gaps) {
|
|
1160
1546
|
const x1 = Math.max(
|
|
@@ -1193,7 +1579,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
|
|
|
1193
1579
|
}
|
|
1194
1580
|
}
|
|
1195
1581
|
if (!real) return;
|
|
1196
|
-
const baseX = state.showHeader
|
|
1582
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1197
1583
|
const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
|
|
1198
1584
|
const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1199
1585
|
const overlap = ghost.wouldOverlap;
|
|
@@ -1263,7 +1649,7 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
|
|
|
1263
1649
|
}
|
|
1264
1650
|
function drawRuler(ctx, state, style) {
|
|
1265
1651
|
const { pxPerSec, scrollLeft, viewportWidth: W } = state;
|
|
1266
|
-
const baseX = state.showHeader
|
|
1652
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1267
1653
|
const rulerW = W - baseX;
|
|
1268
1654
|
ctx.fillStyle = style.bg;
|
|
1269
1655
|
ctx.fillRect(baseX, 0, rulerW, exports.RULER_HEIGHT);
|
|
@@ -1309,7 +1695,7 @@ function drawTracks(ctx, state, style, thumbs) {
|
|
|
1309
1695
|
}
|
|
1310
1696
|
function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
1311
1697
|
const { viewportWidth: W } = state;
|
|
1312
|
-
const baseX = state.showHeader
|
|
1698
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1313
1699
|
const y = trackY(trackIndex);
|
|
1314
1700
|
ctx.fillStyle = style.trackBg;
|
|
1315
1701
|
ctx.fillRect(baseX, y, W - baseX, exports.TRACK_HEIGHT);
|
|
@@ -1347,7 +1733,7 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
|
1347
1733
|
}
|
|
1348
1734
|
function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
|
|
1349
1735
|
const { pxPerSec, scrollLeft } = state;
|
|
1350
|
-
const baseX = state.showHeader
|
|
1736
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1351
1737
|
const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
|
|
1352
1738
|
const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
|
|
1353
1739
|
const y = trackY(trackIndex) + CLIP_INSET;
|
|
@@ -1396,6 +1782,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
|
|
|
1396
1782
|
ctx.fillRect(startX + 2, y + 12, 2, h - 24);
|
|
1397
1783
|
ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
|
|
1398
1784
|
}
|
|
1785
|
+
if (!dim && state.keyframesEnabled && clip.keyframes && clip.keyframes.length > 0) {
|
|
1786
|
+
const diamondY = y + h / 2;
|
|
1787
|
+
const halfSize = 5;
|
|
1788
|
+
const moments = groupKeyframesByTime(clip.keyframes, 16);
|
|
1789
|
+
const ghost = state.keyframeDragGhost;
|
|
1790
|
+
for (const moment of moments) {
|
|
1791
|
+
const draggedHere = ghost ? moment.kfs.find(
|
|
1792
|
+
(k) => ghost.clipId === clip.id && ghost.keyframeId === k.id
|
|
1793
|
+
) : void 0;
|
|
1794
|
+
const effectiveTime = draggedHere ? ghost.ghostTimeMs : moment.time;
|
|
1795
|
+
const kfX = startX + effectiveTime / 1e3 * pxPerSec;
|
|
1796
|
+
if (kfX < baseX - halfSize || kfX > state.viewportWidth + halfSize) continue;
|
|
1797
|
+
const isSelected = state.selectedKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.selectedKeyframe?.keyframeId);
|
|
1798
|
+
const isHovered = state.hoveredKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.hoveredKeyframe?.keyframeId);
|
|
1799
|
+
const drawSize = isHovered ? halfSize + 1.5 : halfSize;
|
|
1800
|
+
ctx.beginPath();
|
|
1801
|
+
ctx.moveTo(kfX, diamondY - drawSize);
|
|
1802
|
+
ctx.lineTo(kfX + drawSize, diamondY);
|
|
1803
|
+
ctx.lineTo(kfX, diamondY + drawSize);
|
|
1804
|
+
ctx.lineTo(kfX - drawSize, diamondY);
|
|
1805
|
+
ctx.closePath();
|
|
1806
|
+
ctx.fillStyle = isSelected ? style.selectedRing : isHovered ? "#ffffff" : withAlpha(style.text, 0.85);
|
|
1807
|
+
ctx.fill();
|
|
1808
|
+
ctx.strokeStyle = isHovered ? style.selectedRing : "rgba(0, 0, 0, 0.65)";
|
|
1809
|
+
ctx.lineWidth = isHovered ? 1.5 : 1;
|
|
1810
|
+
ctx.stroke();
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1399
1813
|
ctx.restore();
|
|
1400
1814
|
}
|
|
1401
1815
|
function drawHeaders(ctx, state, style) {
|
|
@@ -1446,7 +1860,7 @@ function drawHeaders(ctx, state, style) {
|
|
|
1446
1860
|
}
|
|
1447
1861
|
}
|
|
1448
1862
|
function drawPlayhead(ctx, state, style) {
|
|
1449
|
-
const baseX = state.showHeader
|
|
1863
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1450
1864
|
const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1451
1865
|
if (x < baseX - 2 || x > state.viewportWidth + 2) return;
|
|
1452
1866
|
ctx.strokeStyle = style.playhead;
|
|
@@ -1460,7 +1874,9 @@ function drawPlayhead(ctx, state, style) {
|
|
|
1460
1874
|
const padX = 6;
|
|
1461
1875
|
const w = ctx.measureText(label).width + padX * 2;
|
|
1462
1876
|
const h = 14;
|
|
1463
|
-
const
|
|
1877
|
+
const contentRight = state.viewportWidth - SCROLLBAR_THICKNESS;
|
|
1878
|
+
const rawBx = x - w / 2;
|
|
1879
|
+
const bx = Math.max(baseX, Math.min(contentRight - w, rawBx));
|
|
1464
1880
|
const by = 2;
|
|
1465
1881
|
ctx.fillStyle = style.playhead;
|
|
1466
1882
|
roundRect(ctx, bx, by, w, h, 4);
|
|
@@ -1516,7 +1932,7 @@ function drawScrollbarV(ctx, state, style) {
|
|
|
1516
1932
|
}
|
|
1517
1933
|
function drawScrollbarH(ctx, state, style) {
|
|
1518
1934
|
if (state.scrollbarOpacityX <= 0.01) return;
|
|
1519
|
-
const baseX = state.showHeader
|
|
1935
|
+
const baseX = contentLeftX(state.showHeader);
|
|
1520
1936
|
const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1521
1937
|
const contentW = contentWidth(state.project, state.pxPerSec);
|
|
1522
1938
|
if (contentW <= visibleW) return;
|
|
@@ -1599,11 +2015,25 @@ function parseColor(s) {
|
|
|
1599
2015
|
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1600
2016
|
return null;
|
|
1601
2017
|
}
|
|
2018
|
+
function groupKeyframesByTime(kfs, epsilonMs) {
|
|
2019
|
+
const sorted = [...kfs].sort((a, b) => a.time - b.time);
|
|
2020
|
+
const out = [];
|
|
2021
|
+
for (const k of sorted) {
|
|
2022
|
+
const last = out[out.length - 1];
|
|
2023
|
+
if (last && Math.abs(k.time - last.time) < epsilonMs) {
|
|
2024
|
+
last.kfs.push(k);
|
|
2025
|
+
} else {
|
|
2026
|
+
out.push({ time: k.time, kfs: [k] });
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return out;
|
|
2030
|
+
}
|
|
1602
2031
|
|
|
1603
2032
|
// src/timeline/hit.ts
|
|
2033
|
+
var KEYFRAME_HIT_RADIUS = 8;
|
|
1604
2034
|
function hitTest(x, y, ctx) {
|
|
1605
2035
|
if (y < 0 || x < 0) return { kind: "outside" };
|
|
1606
|
-
const baseX = ctx.showHeader
|
|
2036
|
+
const baseX = contentLeftX(ctx.showHeader);
|
|
1607
2037
|
const visibleH = ctx.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1608
2038
|
const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
|
|
1609
2039
|
if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= exports.RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
|
|
@@ -1655,6 +2085,23 @@ function hitTest(x, y, ctx) {
|
|
|
1655
2085
|
if (ti < 0) return { kind: "outside" };
|
|
1656
2086
|
const track = ctx.project.tracks[ti];
|
|
1657
2087
|
const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
|
|
2088
|
+
if (ctx.keyframesEnabled) {
|
|
2089
|
+
for (const clip of track.clips) {
|
|
2090
|
+
if (!clip.keyframes || clip.keyframes.length === 0) continue;
|
|
2091
|
+
const startX = msToXLocal(clip.start, ctx);
|
|
2092
|
+
for (const kf of clip.keyframes) {
|
|
2093
|
+
const kfX = startX + kf.time / 1e3 * ctx.pxPerSec;
|
|
2094
|
+
if (Math.abs(x - kfX) <= KEYFRAME_HIT_RADIUS) {
|
|
2095
|
+
return {
|
|
2096
|
+
kind: "keyframe",
|
|
2097
|
+
trackIndex: ti,
|
|
2098
|
+
clipId: clip.id,
|
|
2099
|
+
keyframeId: kf.id
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
1658
2105
|
for (const clip of track.clips) {
|
|
1659
2106
|
const start = clip.start;
|
|
1660
2107
|
const end = clip.start + (clip.out - clip.in);
|
|
@@ -1673,8 +2120,7 @@ function hitTest(x, y, ctx) {
|
|
|
1673
2120
|
return { kind: "track-empty", trackIndex: ti };
|
|
1674
2121
|
}
|
|
1675
2122
|
function msToXLocal(ms, ctx) {
|
|
1676
|
-
|
|
1677
|
-
return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
2123
|
+
return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1678
2124
|
}
|
|
1679
2125
|
|
|
1680
2126
|
// src/timeline/index.ts
|
|
@@ -1708,6 +2154,8 @@ var Timeline = class _Timeline {
|
|
|
1708
2154
|
readOnly;
|
|
1709
2155
|
autoFitEnabled;
|
|
1710
2156
|
locale;
|
|
2157
|
+
keyframesEnabled = false;
|
|
2158
|
+
selectedKeyframe = null;
|
|
1711
2159
|
scrollLeft = 0;
|
|
1712
2160
|
scrollTop = 0;
|
|
1713
2161
|
viewportWidth = 0;
|
|
@@ -1726,6 +2174,7 @@ var Timeline = class _Timeline {
|
|
|
1726
2174
|
scrollbarDrag = null;
|
|
1727
2175
|
hoveredClipId = null;
|
|
1728
2176
|
hoveredTrackIndex = null;
|
|
2177
|
+
hoveredKeyframe = null;
|
|
1729
2178
|
hoverCursor = "default";
|
|
1730
2179
|
dropTargetTrackIndex = null;
|
|
1731
2180
|
snapX = null;
|
|
@@ -1764,6 +2213,8 @@ var Timeline = class _Timeline {
|
|
|
1764
2213
|
this.readOnly = opts.readOnly === true;
|
|
1765
2214
|
this.autoFitEnabled = opts.autoFit !== false;
|
|
1766
2215
|
this.locale = mergeLocale(opts.locale);
|
|
2216
|
+
this.keyframesEnabled = opts.keyframesEnabled === true;
|
|
2217
|
+
this.selectedKeyframe = opts.selectedKeyframe ?? null;
|
|
1767
2218
|
this.root.classList.add("aicut-timeline-canvas");
|
|
1768
2219
|
this.root.innerHTML = "";
|
|
1769
2220
|
this.root.style.position = this.root.style.position || "relative";
|
|
@@ -1811,6 +2262,7 @@ var Timeline = class _Timeline {
|
|
|
1811
2262
|
this.thumbs.syncSources(this.project.sources);
|
|
1812
2263
|
this.attachPointer();
|
|
1813
2264
|
this.attachWheel();
|
|
2265
|
+
this.attachKeyboard();
|
|
1814
2266
|
this.attachResize();
|
|
1815
2267
|
this.resizeCanvas();
|
|
1816
2268
|
this.scheduleRender();
|
|
@@ -1891,7 +2343,7 @@ var Timeline = class _Timeline {
|
|
|
1891
2343
|
* Exposed publicly so React/Vue wrappers can forward it to a ref.
|
|
1892
2344
|
*/
|
|
1893
2345
|
getDebugInfo() {
|
|
1894
|
-
const baseX = this.showHeader
|
|
2346
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1895
2347
|
const clips = [];
|
|
1896
2348
|
for (let ti = 0; ti < this.project.tracks.length; ti++) {
|
|
1897
2349
|
const t = this.project.tracks[ti];
|
|
@@ -1944,17 +2396,17 @@ var Timeline = class _Timeline {
|
|
|
1944
2396
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1945
2397
|
}
|
|
1946
2398
|
computeFitScale() {
|
|
1947
|
-
const baseX = this.showHeader
|
|
2399
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1948
2400
|
const w = this.viewportWidth - baseX - 24;
|
|
1949
2401
|
const dur = projectDuration(this.project);
|
|
1950
2402
|
if (w <= 0 || dur <= 0) return null;
|
|
1951
2403
|
return clampScale(w / (dur / 1e3));
|
|
1952
2404
|
}
|
|
1953
2405
|
maxScrollLeft() {
|
|
1954
|
-
const baseX = this.showHeader
|
|
2406
|
+
const baseX = contentLeftX(this.showHeader);
|
|
1955
2407
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1956
2408
|
const cw = contentWidth(this.project, this.pxPerSec);
|
|
1957
|
-
return Math.max(0, cw - visibleW +
|
|
2409
|
+
return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
|
|
1958
2410
|
}
|
|
1959
2411
|
maxScrollTop() {
|
|
1960
2412
|
const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
@@ -2056,9 +2508,36 @@ var Timeline = class _Timeline {
|
|
|
2056
2508
|
scrollbarOpacityX: this.scrollbarOpacity("h"),
|
|
2057
2509
|
scrollbarActiveY: this.scrollbarDrag?.axis === "v",
|
|
2058
2510
|
scrollbarActiveX: this.scrollbarDrag?.axis === "h",
|
|
2059
|
-
locale: this.locale
|
|
2511
|
+
locale: this.locale,
|
|
2512
|
+
keyframesEnabled: this.keyframesEnabled,
|
|
2513
|
+
selectedKeyframe: this.selectedKeyframe,
|
|
2514
|
+
hoveredKeyframe: this.hoveredKeyframe,
|
|
2515
|
+
keyframeDragGhost: this.drag?.kind === "keyframe-drag" ? {
|
|
2516
|
+
clipId: this.drag.clipId,
|
|
2517
|
+
keyframeId: this.drag.keyframeId,
|
|
2518
|
+
ghostTimeMs: this.drag.ghostTimeMs
|
|
2519
|
+
} : null
|
|
2060
2520
|
};
|
|
2061
2521
|
}
|
|
2522
|
+
/** Host-pushed state — Editor calls this when its keyframe mode
|
|
2523
|
+
* changes or when a keyframe is selected/deselected externally. */
|
|
2524
|
+
setKeyframeState(state) {
|
|
2525
|
+
let dirty = false;
|
|
2526
|
+
if (state.enabled !== void 0 && state.enabled !== this.keyframesEnabled) {
|
|
2527
|
+
this.keyframesEnabled = state.enabled;
|
|
2528
|
+
dirty = true;
|
|
2529
|
+
}
|
|
2530
|
+
if (state.selected !== void 0) {
|
|
2531
|
+
const a = this.selectedKeyframe;
|
|
2532
|
+
const b = state.selected;
|
|
2533
|
+
const same = a?.clipId === b?.clipId && a?.keyframeId === b?.keyframeId;
|
|
2534
|
+
if (!same) {
|
|
2535
|
+
this.selectedKeyframe = b;
|
|
2536
|
+
dirty = true;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
if (dirty) this.scheduleRender();
|
|
2540
|
+
}
|
|
2062
2541
|
readStyle() {
|
|
2063
2542
|
const cs = getComputedStyle(this.root);
|
|
2064
2543
|
const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
|
|
@@ -2087,6 +2566,7 @@ var Timeline = class _Timeline {
|
|
|
2087
2566
|
this.canvas.addEventListener("pointerleave", () => {
|
|
2088
2567
|
if (!this.drag && !this.scrollbarDrag) {
|
|
2089
2568
|
this.hoveredClipId = null;
|
|
2569
|
+
this.hoveredKeyframe = null;
|
|
2090
2570
|
this.hoverCursor = "default";
|
|
2091
2571
|
this.hoverScrollbarY = false;
|
|
2092
2572
|
this.hoverScrollbarX = false;
|
|
@@ -2127,7 +2607,7 @@ var Timeline = class _Timeline {
|
|
|
2127
2607
|
return;
|
|
2128
2608
|
}
|
|
2129
2609
|
if (target.kind === "scrollbar-track-h") {
|
|
2130
|
-
const baseX = this.showHeader
|
|
2610
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2131
2611
|
const page = Math.max(
|
|
2132
2612
|
80,
|
|
2133
2613
|
this.viewportWidth - baseX - SCROLLBAR_THICKNESS
|
|
@@ -2167,6 +2647,33 @@ var Timeline = class _Timeline {
|
|
|
2167
2647
|
this.scheduleRender();
|
|
2168
2648
|
return;
|
|
2169
2649
|
}
|
|
2650
|
+
if (target.kind === "keyframe") {
|
|
2651
|
+
const found = findClip(this.project, target.clipId);
|
|
2652
|
+
const kf = found?.clip.keyframes?.find((k) => k.id === target.keyframeId);
|
|
2653
|
+
if (!found || !kf) return;
|
|
2654
|
+
this.selectedKeyframe = {
|
|
2655
|
+
clipId: target.clipId,
|
|
2656
|
+
keyframeId: target.keyframeId
|
|
2657
|
+
};
|
|
2658
|
+
this.opts.onSelectKeyframe?.({
|
|
2659
|
+
clipId: target.clipId,
|
|
2660
|
+
keyframeId: target.keyframeId
|
|
2661
|
+
});
|
|
2662
|
+
const absMs = found.clip.start + kf.time;
|
|
2663
|
+
this.timeMs = absMs;
|
|
2664
|
+
this.opts.onSeek?.(absMs);
|
|
2665
|
+
this.drag = {
|
|
2666
|
+
kind: "keyframe-drag",
|
|
2667
|
+
clipId: target.clipId,
|
|
2668
|
+
keyframeId: target.keyframeId,
|
|
2669
|
+
trackIndex: target.trackIndex,
|
|
2670
|
+
pointerStartX: x,
|
|
2671
|
+
originalTimeMs: kf.time,
|
|
2672
|
+
ghostTimeMs: kf.time
|
|
2673
|
+
};
|
|
2674
|
+
this.scheduleRender();
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2170
2677
|
if (target.kind === "clip") {
|
|
2171
2678
|
const found = findClip(this.project, target.clipId);
|
|
2172
2679
|
if (!found) return;
|
|
@@ -2233,7 +2740,7 @@ var Timeline = class _Timeline {
|
|
|
2233
2740
|
const ratio = maxScroll / free;
|
|
2234
2741
|
this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
|
|
2235
2742
|
} else {
|
|
2236
|
-
const baseX = this.showHeader
|
|
2743
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2237
2744
|
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
2238
2745
|
const contentW = contentWidth(this.project, this.pxPerSec);
|
|
2239
2746
|
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
@@ -2257,10 +2764,19 @@ var Timeline = class _Timeline {
|
|
|
2257
2764
|
let cursor = "default";
|
|
2258
2765
|
let onScrollbarV = false;
|
|
2259
2766
|
let onScrollbarH = false;
|
|
2767
|
+
let nextHoverKeyframe = null;
|
|
2260
2768
|
if (target.kind === "clip") {
|
|
2261
2769
|
nextHover = target.clipId;
|
|
2262
2770
|
nextHoverTrack = target.trackIndex;
|
|
2263
2771
|
cursor = this.readOnly ? "pointer" : "grab";
|
|
2772
|
+
} else if (target.kind === "keyframe") {
|
|
2773
|
+
nextHover = target.clipId;
|
|
2774
|
+
nextHoverTrack = target.trackIndex;
|
|
2775
|
+
nextHoverKeyframe = {
|
|
2776
|
+
clipId: target.clipId,
|
|
2777
|
+
keyframeId: target.keyframeId
|
|
2778
|
+
};
|
|
2779
|
+
cursor = "pointer";
|
|
2264
2780
|
} else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
|
|
2265
2781
|
nextHover = target.clipId;
|
|
2266
2782
|
nextHoverTrack = target.trackIndex;
|
|
@@ -2284,12 +2800,14 @@ var Timeline = class _Timeline {
|
|
|
2284
2800
|
cursor = "default";
|
|
2285
2801
|
}
|
|
2286
2802
|
const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
|
|
2287
|
-
|
|
2803
|
+
const kfHoverChanged = (nextHoverKeyframe?.clipId ?? null) !== (this.hoveredKeyframe?.clipId ?? null) || (nextHoverKeyframe?.keyframeId ?? null) !== (this.hoveredKeyframe?.keyframeId ?? null);
|
|
2804
|
+
if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged || kfHoverChanged) {
|
|
2288
2805
|
this.hoveredClipId = nextHover;
|
|
2289
2806
|
this.hoveredTrackIndex = nextHoverTrack;
|
|
2290
2807
|
this.hoverCursor = cursor;
|
|
2291
2808
|
this.hoverScrollbarY = onScrollbarV;
|
|
2292
2809
|
this.hoverScrollbarX = onScrollbarH;
|
|
2810
|
+
this.hoveredKeyframe = nextHoverKeyframe;
|
|
2293
2811
|
this.scheduleRender();
|
|
2294
2812
|
}
|
|
2295
2813
|
return;
|
|
@@ -2312,6 +2830,26 @@ var Timeline = class _Timeline {
|
|
|
2312
2830
|
this.maybeStartDragAutoScroll();
|
|
2313
2831
|
return;
|
|
2314
2832
|
}
|
|
2833
|
+
if (this.drag.kind === "keyframe-drag") {
|
|
2834
|
+
const found = findClip(this.project, this.drag.clipId);
|
|
2835
|
+
if (!found) return;
|
|
2836
|
+
const clip = found.clip;
|
|
2837
|
+
const duration = clip.out - clip.in;
|
|
2838
|
+
const dxPx = x - this.drag.pointerStartX;
|
|
2839
|
+
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
2840
|
+
const nextLocal = Math.max(
|
|
2841
|
+
0,
|
|
2842
|
+
Math.min(duration, this.drag.originalTimeMs + dxMs)
|
|
2843
|
+
);
|
|
2844
|
+
const snappedAbs = this.applySnap(clip.start + nextLocal, null);
|
|
2845
|
+
const snappedLocal = Math.max(
|
|
2846
|
+
0,
|
|
2847
|
+
Math.min(duration, snappedAbs - clip.start)
|
|
2848
|
+
);
|
|
2849
|
+
this.drag.ghostTimeMs = Math.round(snappedLocal);
|
|
2850
|
+
this.scheduleRender();
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2315
2853
|
if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
|
|
2316
2854
|
const dxPx = x - this.drag.pointerStartX;
|
|
2317
2855
|
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
@@ -2464,6 +3002,14 @@ var Timeline = class _Timeline {
|
|
|
2464
3002
|
});
|
|
2465
3003
|
this.opts.onChange?.(this.getProject());
|
|
2466
3004
|
}
|
|
3005
|
+
} else if (drag.kind === "keyframe-drag") {
|
|
3006
|
+
if (drag.ghostTimeMs !== drag.originalTimeMs) {
|
|
3007
|
+
this.opts.onMoveKeyframe?.(
|
|
3008
|
+
drag.clipId,
|
|
3009
|
+
drag.keyframeId,
|
|
3010
|
+
drag.ghostTimeMs
|
|
3011
|
+
);
|
|
3012
|
+
}
|
|
2467
3013
|
} else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
|
|
2468
3014
|
const found = findClip(this.project, drag.clipId);
|
|
2469
3015
|
if (found) {
|
|
@@ -2477,6 +3023,25 @@ var Timeline = class _Timeline {
|
|
|
2477
3023
|
}
|
|
2478
3024
|
this.scheduleRender();
|
|
2479
3025
|
}
|
|
3026
|
+
attachKeyboard() {
|
|
3027
|
+
this.canvas.tabIndex = -1;
|
|
3028
|
+
this.canvas.style.outline = "none";
|
|
3029
|
+
this.canvas.addEventListener("keydown", (e) => {
|
|
3030
|
+
if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
|
|
3031
|
+
e.preventDefault();
|
|
3032
|
+
const step = e.shiftKey ? bigFrameStepMs(this.project) : frameStepMs(this.project);
|
|
3033
|
+
const dir = e.code === "ArrowLeft" ? -1 : 1;
|
|
3034
|
+
const dur = projectDuration(this.project);
|
|
3035
|
+
const next = Math.max(0, Math.min(dur, this.timeMs + dir * step));
|
|
3036
|
+
if (next === this.timeMs) return;
|
|
3037
|
+
this.timeMs = next;
|
|
3038
|
+
this.opts.onSeek?.(next);
|
|
3039
|
+
this.scheduleRender();
|
|
3040
|
+
});
|
|
3041
|
+
this.canvas.addEventListener("pointerdown", () => {
|
|
3042
|
+
if (document.activeElement !== this.canvas) this.canvas.focus();
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
2480
3045
|
attachWheel() {
|
|
2481
3046
|
this.canvas.addEventListener(
|
|
2482
3047
|
"wheel",
|
|
@@ -2497,7 +3062,7 @@ var Timeline = class _Timeline {
|
|
|
2497
3062
|
if (Math.abs(next - this.pxPerSec) < 0.01) return;
|
|
2498
3063
|
this.pxPerSec = next;
|
|
2499
3064
|
this.hasAutoFitted = true;
|
|
2500
|
-
const baseX = this.showHeader
|
|
3065
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2501
3066
|
this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
|
|
2502
3067
|
this.clampScroll();
|
|
2503
3068
|
this.touchScrollbar("h");
|
|
@@ -2560,7 +3125,8 @@ var Timeline = class _Timeline {
|
|
|
2560
3125
|
showHeader: this.showHeader,
|
|
2561
3126
|
viewportWidth: this.viewportWidth,
|
|
2562
3127
|
viewportHeight: this.viewportHeight,
|
|
2563
|
-
isDragging: this.drag?.kind === "move"
|
|
3128
|
+
isDragging: this.drag?.kind === "move",
|
|
3129
|
+
keyframesEnabled: this.keyframesEnabled
|
|
2564
3130
|
});
|
|
2565
3131
|
}
|
|
2566
3132
|
trackIndexAtY(y) {
|
|
@@ -2583,7 +3149,7 @@ var Timeline = class _Timeline {
|
|
|
2583
3149
|
}
|
|
2584
3150
|
}
|
|
2585
3151
|
if (best !== ms) {
|
|
2586
|
-
const baseX = this.showHeader
|
|
3152
|
+
const baseX = contentLeftX(this.showHeader);
|
|
2587
3153
|
this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
2588
3154
|
} else {
|
|
2589
3155
|
this.snapX = null;
|
|
@@ -2592,6 +3158,709 @@ var Timeline = class _Timeline {
|
|
|
2592
3158
|
}
|
|
2593
3159
|
};
|
|
2594
3160
|
|
|
3161
|
+
// src/ui/keyframe-overlay.ts
|
|
3162
|
+
var KeyframeOverlay = class _KeyframeOverlay {
|
|
3163
|
+
editor;
|
|
3164
|
+
host;
|
|
3165
|
+
root;
|
|
3166
|
+
frameBody;
|
|
3167
|
+
handles;
|
|
3168
|
+
rafHandle = null;
|
|
3169
|
+
destroyed = false;
|
|
3170
|
+
drag = null;
|
|
3171
|
+
capturedPointerId = null;
|
|
3172
|
+
/** Timer handle for the wheel-burst → interaction commit. */
|
|
3173
|
+
wheelInteractionTimer = null;
|
|
3174
|
+
/** Snap-target threshold in CSS px — the same feel as the timeline. */
|
|
3175
|
+
static SNAP_PX = 8;
|
|
3176
|
+
constructor(host, editor) {
|
|
3177
|
+
this.host = host;
|
|
3178
|
+
this.editor = editor;
|
|
3179
|
+
this.root = document.createElement("div");
|
|
3180
|
+
this.root.className = "aicut-keyframe-overlay";
|
|
3181
|
+
this.root.setAttribute("data-testid", "aicut-keyframe-overlay");
|
|
3182
|
+
this.root.style.display = "none";
|
|
3183
|
+
this.frameBody = document.createElement("div");
|
|
3184
|
+
this.frameBody.className = "aicut-keyframe-overlay__frame";
|
|
3185
|
+
this.frameBody.setAttribute("data-testid", "aicut-keyframe-frame");
|
|
3186
|
+
this.frameBody.addEventListener("pointerdown", (e) => this.onTransStart(e));
|
|
3187
|
+
this.frameBody.addEventListener(
|
|
3188
|
+
"wheel",
|
|
3189
|
+
(e) => this.onPinchScale(e),
|
|
3190
|
+
{ passive: false }
|
|
3191
|
+
);
|
|
3192
|
+
this.root.appendChild(this.frameBody);
|
|
3193
|
+
this.handles = {
|
|
3194
|
+
tl: this.makeHandle("tl"),
|
|
3195
|
+
tr: this.makeHandle("tr"),
|
|
3196
|
+
bl: this.makeHandle("bl"),
|
|
3197
|
+
br: this.makeHandle("br")
|
|
3198
|
+
};
|
|
3199
|
+
host.appendChild(this.root);
|
|
3200
|
+
this.startTick();
|
|
3201
|
+
}
|
|
3202
|
+
destroy() {
|
|
3203
|
+
this.destroyed = true;
|
|
3204
|
+
if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
|
|
3205
|
+
if (this.wheelInteractionTimer != null) {
|
|
3206
|
+
clearTimeout(this.wheelInteractionTimer);
|
|
3207
|
+
this.wheelInteractionTimer = null;
|
|
3208
|
+
this.editor.endInteraction();
|
|
3209
|
+
}
|
|
3210
|
+
this.root.remove();
|
|
3211
|
+
}
|
|
3212
|
+
// ---- frame body drag (translate) -------------------------------------
|
|
3213
|
+
onTransStart(e) {
|
|
3214
|
+
if (e.button !== 0) return;
|
|
3215
|
+
const ctx = this.ensureSelectedClip();
|
|
3216
|
+
if (!ctx) return;
|
|
3217
|
+
e.preventDefault();
|
|
3218
|
+
e.stopPropagation();
|
|
3219
|
+
this.frameBody.setPointerCapture(e.pointerId);
|
|
3220
|
+
this.capturedPointerId = e.pointerId;
|
|
3221
|
+
this.drag = {
|
|
3222
|
+
kind: "translate",
|
|
3223
|
+
clipId: ctx.clip.id,
|
|
3224
|
+
pointerStartX: e.clientX,
|
|
3225
|
+
pointerStartY: e.clientY,
|
|
3226
|
+
startPanX: ctx.transform.panX,
|
|
3227
|
+
startPanY: ctx.transform.panY
|
|
3228
|
+
};
|
|
3229
|
+
this.editor.beginInteraction();
|
|
3230
|
+
this.frameBody.addEventListener("pointermove", this.onPointerMove);
|
|
3231
|
+
this.frameBody.addEventListener("pointerup", this.onPointerUp);
|
|
3232
|
+
this.frameBody.addEventListener("pointercancel", this.onPointerUp);
|
|
3233
|
+
}
|
|
3234
|
+
// ---- pinch-to-scale --------------------------------------------------
|
|
3235
|
+
onPinchScale(e) {
|
|
3236
|
+
if (!e.ctrlKey) return;
|
|
3237
|
+
const ctx = this.ensureSelectedClip();
|
|
3238
|
+
if (!ctx) return;
|
|
3239
|
+
e.preventDefault();
|
|
3240
|
+
e.stopPropagation();
|
|
3241
|
+
const step = Math.max(-50, Math.min(50, -e.deltaY));
|
|
3242
|
+
const factor = Math.exp(step * 0.01);
|
|
3243
|
+
const next = Math.max(
|
|
3244
|
+
0.05,
|
|
3245
|
+
Math.min(16, ctx.transform.scale * factor)
|
|
3246
|
+
);
|
|
3247
|
+
if (this.wheelInteractionTimer == null) {
|
|
3248
|
+
this.editor.beginInteraction();
|
|
3249
|
+
} else {
|
|
3250
|
+
clearTimeout(this.wheelInteractionTimer);
|
|
3251
|
+
}
|
|
3252
|
+
this.wheelInteractionTimer = window.setTimeout(() => {
|
|
3253
|
+
this.wheelInteractionTimer = null;
|
|
3254
|
+
this.editor.endInteraction();
|
|
3255
|
+
}, 200);
|
|
3256
|
+
this.editor.setValueAtPlayhead(
|
|
3257
|
+
ctx.clip.id,
|
|
3258
|
+
"scale",
|
|
3259
|
+
Math.round(next * 100) / 100
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
// ---- corner-handle drag (scale) --------------------------------------
|
|
3263
|
+
onScaleStart(corner, e) {
|
|
3264
|
+
if (e.button !== 0) return;
|
|
3265
|
+
const ctx = this.ensureSelectedClip();
|
|
3266
|
+
if (!ctx) return;
|
|
3267
|
+
e.preventDefault();
|
|
3268
|
+
e.stopPropagation();
|
|
3269
|
+
const rect = this.editor.getActiveOutputFrameRect() ?? this.editor.getActiveFrameRect();
|
|
3270
|
+
if (!rect) return;
|
|
3271
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
3272
|
+
const cx = hostRect.left + rect.x + rect.w / 2;
|
|
3273
|
+
const cy = hostRect.top + rect.y + rect.h / 2;
|
|
3274
|
+
const startDist = Math.hypot(e.clientX - cx, e.clientY - cy);
|
|
3275
|
+
if (startDist < 1) return;
|
|
3276
|
+
const target = this.handles[corner];
|
|
3277
|
+
target.setPointerCapture(e.pointerId);
|
|
3278
|
+
this.capturedPointerId = e.pointerId;
|
|
3279
|
+
this.drag = {
|
|
3280
|
+
kind: "scale",
|
|
3281
|
+
clipId: ctx.clip.id,
|
|
3282
|
+
centerX: cx,
|
|
3283
|
+
centerY: cy,
|
|
3284
|
+
startDistance: startDist,
|
|
3285
|
+
startScale: ctx.transform.scale
|
|
3286
|
+
};
|
|
3287
|
+
this.editor.beginInteraction();
|
|
3288
|
+
target.addEventListener("pointermove", this.onPointerMove);
|
|
3289
|
+
target.addEventListener("pointerup", this.onPointerUp);
|
|
3290
|
+
target.addEventListener("pointercancel", this.onPointerUp);
|
|
3291
|
+
}
|
|
3292
|
+
onPointerMove = (e) => {
|
|
3293
|
+
if (!this.drag) return;
|
|
3294
|
+
if (this.drag.kind === "translate") {
|
|
3295
|
+
const dx = e.clientX - this.drag.pointerStartX;
|
|
3296
|
+
const dy = e.clientY - this.drag.pointerStartY;
|
|
3297
|
+
const rawPanX = this.drag.startPanX + dx;
|
|
3298
|
+
const rawPanY = this.drag.startPanY + dy;
|
|
3299
|
+
const snapped = this.applySnap(this.drag.clipId, rawPanX, rawPanY);
|
|
3300
|
+
this.editor.setValueAtPlayhead(
|
|
3301
|
+
this.drag.clipId,
|
|
3302
|
+
"panX",
|
|
3303
|
+
Math.round(snapped.panX)
|
|
3304
|
+
);
|
|
3305
|
+
this.editor.setValueAtPlayhead(
|
|
3306
|
+
this.drag.clipId,
|
|
3307
|
+
"panY",
|
|
3308
|
+
Math.round(snapped.panY)
|
|
3309
|
+
);
|
|
3310
|
+
} else {
|
|
3311
|
+
const dist = Math.hypot(
|
|
3312
|
+
e.clientX - this.drag.centerX,
|
|
3313
|
+
e.clientY - this.drag.centerY
|
|
3314
|
+
);
|
|
3315
|
+
const ratio = dist / this.drag.startDistance;
|
|
3316
|
+
const next = Math.max(
|
|
3317
|
+
0.05,
|
|
3318
|
+
Math.min(16, this.drag.startScale * ratio)
|
|
3319
|
+
);
|
|
3320
|
+
this.editor.setValueAtPlayhead(
|
|
3321
|
+
this.drag.clipId,
|
|
3322
|
+
"scale",
|
|
3323
|
+
Math.round(next * 100) / 100
|
|
3324
|
+
);
|
|
3325
|
+
}
|
|
3326
|
+
};
|
|
3327
|
+
/**
|
|
3328
|
+
* Snap raw pan to: centered (panX/Y = 0) and the four edge-alignment
|
|
3329
|
+
* stops (content's L/R/T/B edge flush with the output's matching
|
|
3330
|
+
* edge). When content is smaller than output, the edge stops collapse
|
|
3331
|
+
* to the same point as 0 — harmless dup. Threshold = 8 CSS px.
|
|
3332
|
+
*/
|
|
3333
|
+
applySnap(clipId, rawPanX, rawPanY) {
|
|
3334
|
+
const out = this.editor.getActiveOutputFrameRect();
|
|
3335
|
+
if (!out) return { panX: rawPanX, panY: rawPanY };
|
|
3336
|
+
const clip = this.findClip(clipId);
|
|
3337
|
+
if (!clip) return { panX: rawPanX, panY: rawPanY };
|
|
3338
|
+
const t = (() => {
|
|
3339
|
+
try {
|
|
3340
|
+
const transformer = this.editor.getActiveFrameRect();
|
|
3341
|
+
if (!transformer) return null;
|
|
3342
|
+
return { w: transformer.w, h: transformer.h };
|
|
3343
|
+
} catch {
|
|
3344
|
+
return null;
|
|
3345
|
+
}
|
|
3346
|
+
})();
|
|
3347
|
+
const contentW = t?.w ?? out.w;
|
|
3348
|
+
const contentH = t?.h ?? out.h;
|
|
3349
|
+
const edgeX = (contentW - out.w) / 2;
|
|
3350
|
+
const edgeY = (contentH - out.h) / 2;
|
|
3351
|
+
const xTargets = [0, edgeX, -edgeX];
|
|
3352
|
+
const yTargets = [0, edgeY, -edgeY];
|
|
3353
|
+
const px = nearestSnap(rawPanX, xTargets, _KeyframeOverlay.SNAP_PX);
|
|
3354
|
+
const py = nearestSnap(rawPanY, yTargets, _KeyframeOverlay.SNAP_PX);
|
|
3355
|
+
return { panX: px, panY: py };
|
|
3356
|
+
}
|
|
3357
|
+
findClip(clipId) {
|
|
3358
|
+
const project = this.editor.getProject();
|
|
3359
|
+
for (const t of project.tracks) {
|
|
3360
|
+
const c = t.clips.find((cl) => cl.id === clipId);
|
|
3361
|
+
if (c) return c;
|
|
3362
|
+
}
|
|
3363
|
+
return null;
|
|
3364
|
+
}
|
|
3365
|
+
onPointerUp = (e) => {
|
|
3366
|
+
if (!this.drag) return;
|
|
3367
|
+
const targetEl = e.currentTarget;
|
|
3368
|
+
if (targetEl && this.capturedPointerId === e.pointerId) {
|
|
3369
|
+
try {
|
|
3370
|
+
targetEl.releasePointerCapture(e.pointerId);
|
|
3371
|
+
} catch {
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
targetEl?.removeEventListener("pointermove", this.onPointerMove);
|
|
3375
|
+
targetEl?.removeEventListener("pointerup", this.onPointerUp);
|
|
3376
|
+
targetEl?.removeEventListener("pointercancel", this.onPointerUp);
|
|
3377
|
+
this.drag = null;
|
|
3378
|
+
this.capturedPointerId = null;
|
|
3379
|
+
this.editor.endInteraction();
|
|
3380
|
+
};
|
|
3381
|
+
// ---- per-frame layout ------------------------------------------------
|
|
3382
|
+
startTick() {
|
|
3383
|
+
const tick = () => {
|
|
3384
|
+
if (this.destroyed) return;
|
|
3385
|
+
this.layout();
|
|
3386
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
3387
|
+
};
|
|
3388
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
3389
|
+
}
|
|
3390
|
+
layout() {
|
|
3391
|
+
const enabled = this.editor.isKeyframesEnabled();
|
|
3392
|
+
if (!enabled) {
|
|
3393
|
+
this.root.style.display = "none";
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
const outRect = this.editor.getActiveOutputFrameRect();
|
|
3397
|
+
const contentRect = this.editor.getActiveFrameRect() ?? outRect;
|
|
3398
|
+
if (!outRect) {
|
|
3399
|
+
this.root.style.display = "none";
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
this.root.style.display = "block";
|
|
3403
|
+
Object.assign(this.frameBody.style, {
|
|
3404
|
+
left: `${outRect.x}px`,
|
|
3405
|
+
top: `${outRect.y}px`,
|
|
3406
|
+
width: `${outRect.w}px`,
|
|
3407
|
+
height: `${outRect.h}px`
|
|
3408
|
+
});
|
|
3409
|
+
const fullyCovered = contentRect ? contentRect.x <= outRect.x + 0.5 && contentRect.x + contentRect.w >= outRect.x + outRect.w - 0.5 && contentRect.y <= outRect.y + 0.5 && contentRect.y + contentRect.h >= outRect.y + outRect.h - 0.5 : true;
|
|
3410
|
+
this.frameBody.classList.toggle(
|
|
3411
|
+
"aicut-keyframe-overlay__frame--warn",
|
|
3412
|
+
!fullyCovered
|
|
3413
|
+
);
|
|
3414
|
+
const halfHandle = 6;
|
|
3415
|
+
const r = contentRect ?? outRect;
|
|
3416
|
+
const fbLeft = r.x;
|
|
3417
|
+
const fbTop = r.y;
|
|
3418
|
+
const fbRight = r.x + r.w;
|
|
3419
|
+
const fbBottom = r.y + r.h;
|
|
3420
|
+
const place = (el, cx, cy) => {
|
|
3421
|
+
el.style.left = `${cx - halfHandle}px`;
|
|
3422
|
+
el.style.top = `${cy - halfHandle}px`;
|
|
3423
|
+
};
|
|
3424
|
+
place(this.handles.tl, fbLeft, fbTop);
|
|
3425
|
+
place(this.handles.tr, fbRight, fbTop);
|
|
3426
|
+
place(this.handles.bl, fbLeft, fbBottom);
|
|
3427
|
+
place(this.handles.br, fbRight, fbBottom);
|
|
3428
|
+
}
|
|
3429
|
+
// ---- helpers ---------------------------------------------------------
|
|
3430
|
+
makeHandle(name) {
|
|
3431
|
+
const el = document.createElement("div");
|
|
3432
|
+
el.className = `aicut-keyframe-overlay__handle aicut-keyframe-overlay__handle--${name}`;
|
|
3433
|
+
el.setAttribute("data-testid", `aicut-keyframe-handle-${name}`);
|
|
3434
|
+
el.addEventListener("pointerdown", (e) => this.onScaleStart(name, e));
|
|
3435
|
+
this.root.appendChild(el);
|
|
3436
|
+
return el;
|
|
3437
|
+
}
|
|
3438
|
+
/**
|
|
3439
|
+
* Resolve the currently selected clip + its current effective
|
|
3440
|
+
* transform (so drag baselines are correct). Returns null when no
|
|
3441
|
+
* clip is selected or the playhead isn't over it.
|
|
3442
|
+
*/
|
|
3443
|
+
ensureSelectedClip() {
|
|
3444
|
+
const selectedClipId = this.editor.getSelection();
|
|
3445
|
+
if (!selectedClipId) return null;
|
|
3446
|
+
const project = this.editor.getProject();
|
|
3447
|
+
let clip = null;
|
|
3448
|
+
for (const t of project.tracks) {
|
|
3449
|
+
const c = t.clips.find((cl) => cl.id === selectedClipId);
|
|
3450
|
+
if (c) {
|
|
3451
|
+
clip = c;
|
|
3452
|
+
break;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
if (!clip) return null;
|
|
3456
|
+
const playheadLocal = this.editor.getTime() - clip.start;
|
|
3457
|
+
if (playheadLocal < 0 || playheadLocal > clip.out - clip.in) {
|
|
3458
|
+
return null;
|
|
3459
|
+
}
|
|
3460
|
+
const transform = getEffectiveTransform(clip, playheadLocal);
|
|
3461
|
+
return { clip, transform };
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
3464
|
+
function nearestSnap(raw, targets, threshold) {
|
|
3465
|
+
let best = raw;
|
|
3466
|
+
let bestDist = threshold;
|
|
3467
|
+
for (const t of targets) {
|
|
3468
|
+
const d = Math.abs(raw - t);
|
|
3469
|
+
if (d < bestDist) {
|
|
3470
|
+
bestDist = d;
|
|
3471
|
+
best = t;
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
return best;
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
// src/ui/keyframe-panel.ts
|
|
3478
|
+
var EASING_VALUES = [
|
|
3479
|
+
"linear",
|
|
3480
|
+
"easeIn",
|
|
3481
|
+
"easeOut",
|
|
3482
|
+
"easeInOut"
|
|
3483
|
+
];
|
|
3484
|
+
function easingLabel(value, locale) {
|
|
3485
|
+
switch (value) {
|
|
3486
|
+
case "linear":
|
|
3487
|
+
return locale.keyframeEasingLinear;
|
|
3488
|
+
case "easeIn":
|
|
3489
|
+
return locale.keyframeEasingEaseIn;
|
|
3490
|
+
case "easeOut":
|
|
3491
|
+
return locale.keyframeEasingEaseOut;
|
|
3492
|
+
case "easeInOut":
|
|
3493
|
+
return locale.keyframeEasingEaseInOut;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
var TIME_EPS_MS = 16;
|
|
3497
|
+
var KeyframePanel = class {
|
|
3498
|
+
editor;
|
|
3499
|
+
locale;
|
|
3500
|
+
root;
|
|
3501
|
+
inputs;
|
|
3502
|
+
kfBadges;
|
|
3503
|
+
timeLabel;
|
|
3504
|
+
titleLabel;
|
|
3505
|
+
resetBtn;
|
|
3506
|
+
easingTrigger;
|
|
3507
|
+
easingTriggerLabel;
|
|
3508
|
+
easingMenu;
|
|
3509
|
+
easingItems;
|
|
3510
|
+
easingValue = "linear";
|
|
3511
|
+
easingDisabled = false;
|
|
3512
|
+
easingOpen = false;
|
|
3513
|
+
easingLabelEl;
|
|
3514
|
+
rowLabels;
|
|
3515
|
+
lastSyncKey = "";
|
|
3516
|
+
// Bound once so add/remove listener pairs reference the same fn.
|
|
3517
|
+
boundOutsideClick = null;
|
|
3518
|
+
boundDocKeydown = null;
|
|
3519
|
+
constructor(host, editor, locale) {
|
|
3520
|
+
this.editor = editor;
|
|
3521
|
+
this.locale = locale;
|
|
3522
|
+
this.root = document.createElement("div");
|
|
3523
|
+
this.root.className = "aicut-keyframe-panel";
|
|
3524
|
+
this.root.setAttribute("data-testid", "aicut-keyframe-panel");
|
|
3525
|
+
this.root.style.display = "none";
|
|
3526
|
+
this.root.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
3527
|
+
this.root.addEventListener("wheel", (e) => e.stopPropagation());
|
|
3528
|
+
const title = document.createElement("div");
|
|
3529
|
+
title.className = "aicut-keyframe-panel__title";
|
|
3530
|
+
this.titleLabel = document.createElement("span");
|
|
3531
|
+
this.timeLabel = document.createElement("span");
|
|
3532
|
+
this.timeLabel.className = "aicut-keyframe-panel__time";
|
|
3533
|
+
title.append(this.titleLabel, this.timeLabel);
|
|
3534
|
+
this.root.appendChild(title);
|
|
3535
|
+
const xRow = this.makeRow("kf-x", "panX", 1);
|
|
3536
|
+
const yRow = this.makeRow("kf-y", "panY", 1);
|
|
3537
|
+
const scaleRow = this.makeRow("kf-scale", "scale", 0.05);
|
|
3538
|
+
this.inputs = {
|
|
3539
|
+
panX: xRow.input,
|
|
3540
|
+
panY: yRow.input,
|
|
3541
|
+
scale: scaleRow.input
|
|
3542
|
+
};
|
|
3543
|
+
this.rowLabels = {
|
|
3544
|
+
panX: xRow.label,
|
|
3545
|
+
panY: yRow.label,
|
|
3546
|
+
scale: scaleRow.label
|
|
3547
|
+
};
|
|
3548
|
+
this.kfBadges = {
|
|
3549
|
+
panX: this.makeBadge(this.inputs.panX),
|
|
3550
|
+
panY: this.makeBadge(this.inputs.panY),
|
|
3551
|
+
scale: this.makeBadge(this.inputs.scale)
|
|
3552
|
+
};
|
|
3553
|
+
const easingRow = document.createElement("div");
|
|
3554
|
+
easingRow.className = "aicut-keyframe-panel__row aicut-keyframe-panel__row--easing";
|
|
3555
|
+
this.easingLabelEl = document.createElement("label");
|
|
3556
|
+
const dd = document.createElement("div");
|
|
3557
|
+
dd.className = "aicut-keyframe-panel__dropdown";
|
|
3558
|
+
dd.setAttribute("data-testid", "aicut-kf-easing");
|
|
3559
|
+
this.easingTrigger = document.createElement("button");
|
|
3560
|
+
this.easingTrigger.type = "button";
|
|
3561
|
+
this.easingTrigger.className = "aicut-keyframe-panel__dropdown-trigger";
|
|
3562
|
+
this.easingTrigger.setAttribute("aria-haspopup", "listbox");
|
|
3563
|
+
this.easingTrigger.setAttribute("aria-expanded", "false");
|
|
3564
|
+
this.easingTriggerLabel = document.createElement("span");
|
|
3565
|
+
this.easingTriggerLabel.className = "aicut-keyframe-panel__dropdown-trigger-label";
|
|
3566
|
+
const chevron = document.createElement("span");
|
|
3567
|
+
chevron.className = "aicut-keyframe-panel__dropdown-chevron";
|
|
3568
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
3569
|
+
this.easingTrigger.append(this.easingTriggerLabel, chevron);
|
|
3570
|
+
this.easingTrigger.addEventListener("click", (e) => {
|
|
3571
|
+
e.stopPropagation();
|
|
3572
|
+
if (this.easingDisabled) return;
|
|
3573
|
+
this.toggleEasingMenu();
|
|
3574
|
+
});
|
|
3575
|
+
this.easingTrigger.addEventListener("keydown", (e) => {
|
|
3576
|
+
if (this.easingDisabled) return;
|
|
3577
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
3578
|
+
e.preventDefault();
|
|
3579
|
+
if (!this.easingOpen) this.openEasingMenu();
|
|
3580
|
+
this.easingItems.get(this.easingValue)?.focus();
|
|
3581
|
+
}
|
|
3582
|
+
});
|
|
3583
|
+
this.easingMenu = document.createElement("ul");
|
|
3584
|
+
this.easingMenu.className = "aicut-keyframe-panel__dropdown-menu";
|
|
3585
|
+
this.easingMenu.setAttribute("role", "listbox");
|
|
3586
|
+
this.easingMenu.style.display = "none";
|
|
3587
|
+
this.easingItems = /* @__PURE__ */ new Map();
|
|
3588
|
+
for (const value of EASING_VALUES) {
|
|
3589
|
+
const li = document.createElement("li");
|
|
3590
|
+
li.className = "aicut-keyframe-panel__dropdown-item";
|
|
3591
|
+
li.setAttribute("role", "option");
|
|
3592
|
+
li.setAttribute("data-value", value);
|
|
3593
|
+
li.setAttribute("tabindex", "-1");
|
|
3594
|
+
li.addEventListener("click", (e) => {
|
|
3595
|
+
e.stopPropagation();
|
|
3596
|
+
this.selectEasing(value);
|
|
3597
|
+
});
|
|
3598
|
+
li.addEventListener("keydown", (e) => this.onMenuKeydown(e, value));
|
|
3599
|
+
this.easingItems.set(value, li);
|
|
3600
|
+
this.easingMenu.appendChild(li);
|
|
3601
|
+
}
|
|
3602
|
+
dd.append(this.easingTrigger, this.easingMenu);
|
|
3603
|
+
easingRow.append(this.easingLabelEl, dd);
|
|
3604
|
+
this.root.appendChild(easingRow);
|
|
3605
|
+
const actions = document.createElement("div");
|
|
3606
|
+
actions.className = "aicut-keyframe-panel__actions";
|
|
3607
|
+
this.resetBtn = document.createElement("button");
|
|
3608
|
+
this.resetBtn.type = "button";
|
|
3609
|
+
this.resetBtn.className = "aicut-keyframe-panel__reset";
|
|
3610
|
+
this.resetBtn.setAttribute("data-testid", "aicut-keyframe-reset");
|
|
3611
|
+
this.resetBtn.addEventListener("click", () => this.onReset());
|
|
3612
|
+
actions.appendChild(this.resetBtn);
|
|
3613
|
+
this.root.appendChild(actions);
|
|
3614
|
+
this.applyLocaleText();
|
|
3615
|
+
host.appendChild(this.root);
|
|
3616
|
+
}
|
|
3617
|
+
setLocale(locale) {
|
|
3618
|
+
this.locale = locale;
|
|
3619
|
+
this.applyLocaleText();
|
|
3620
|
+
this.lastSyncKey = "";
|
|
3621
|
+
this.render();
|
|
3622
|
+
}
|
|
3623
|
+
applyLocaleText() {
|
|
3624
|
+
this.titleLabel.textContent = this.locale.keyframePanelTitle;
|
|
3625
|
+
this.rowLabels.panX.textContent = this.locale.keyframePanelLabelX;
|
|
3626
|
+
this.rowLabels.panY.textContent = this.locale.keyframePanelLabelY;
|
|
3627
|
+
this.rowLabels.scale.textContent = this.locale.keyframePanelLabelScale;
|
|
3628
|
+
this.easingLabelEl.textContent = this.locale.keyframePanelLabelEasing;
|
|
3629
|
+
this.resetBtn.textContent = this.locale.keyframePanelReset;
|
|
3630
|
+
this.resetBtn.title = this.locale.keyframePanelResetTitle;
|
|
3631
|
+
for (const [value, li] of this.easingItems) {
|
|
3632
|
+
li.textContent = easingLabel(value, this.locale);
|
|
3633
|
+
}
|
|
3634
|
+
this.easingTriggerLabel.textContent = easingLabel(
|
|
3635
|
+
this.easingValue,
|
|
3636
|
+
this.locale
|
|
3637
|
+
);
|
|
3638
|
+
}
|
|
3639
|
+
destroy() {
|
|
3640
|
+
this.closeEasingMenu();
|
|
3641
|
+
this.root.remove();
|
|
3642
|
+
}
|
|
3643
|
+
render() {
|
|
3644
|
+
const enabled = this.editor.isKeyframesEnabled();
|
|
3645
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3646
|
+
if (!enabled || !sel) {
|
|
3647
|
+
this.root.style.display = "none";
|
|
3648
|
+
this.lastSyncKey = "";
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
const clip = this.findClip(sel.clipId);
|
|
3652
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3653
|
+
if (!clip || !anchorKf) {
|
|
3654
|
+
this.root.style.display = "none";
|
|
3655
|
+
this.lastSyncKey = "";
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
const time = anchorKf.time;
|
|
3659
|
+
const moment = (clip.keyframes ?? []).filter(
|
|
3660
|
+
(k) => Math.abs(k.time - time) < TIME_EPS_MS
|
|
3661
|
+
);
|
|
3662
|
+
const interp = getEffectiveTransform(clip, time);
|
|
3663
|
+
const valueOf = (prop) => {
|
|
3664
|
+
const m = moment.find((k) => k.prop === prop);
|
|
3665
|
+
if (m) return m.value;
|
|
3666
|
+
return interp[prop];
|
|
3667
|
+
};
|
|
3668
|
+
const v = {
|
|
3669
|
+
panX: valueOf("panX"),
|
|
3670
|
+
panY: valueOf("panY"),
|
|
3671
|
+
scale: valueOf("scale")
|
|
3672
|
+
};
|
|
3673
|
+
const sharedEasing = (() => {
|
|
3674
|
+
if (moment.length === 0) return "linear";
|
|
3675
|
+
const anchor = moment.find((k) => k.id === sel.keyframeId) ?? moment[0];
|
|
3676
|
+
return anchor.easing ?? "linear";
|
|
3677
|
+
})();
|
|
3678
|
+
const syncKey = `${clip.id}|${time}|${v.panX.toFixed(2)}|${v.panY.toFixed(2)}|${v.scale.toFixed(4)}|${moment.map((m) => m.prop).join(",")}|${sharedEasing}`;
|
|
3679
|
+
this.root.style.display = "flex";
|
|
3680
|
+
if (syncKey === this.lastSyncKey) return;
|
|
3681
|
+
this.lastSyncKey = syncKey;
|
|
3682
|
+
this.setIfBlur(this.inputs.panX, String(Math.round(v.panX)));
|
|
3683
|
+
this.setIfBlur(this.inputs.panY, String(Math.round(v.panY)));
|
|
3684
|
+
this.setIfBlur(this.inputs.scale, v.scale.toFixed(2));
|
|
3685
|
+
this.timeLabel.textContent = `${(time / 1e3).toFixed(2)}${this.locale.keyframePanelTimeSuffix}`;
|
|
3686
|
+
this.setEasingValue(sharedEasing);
|
|
3687
|
+
this.setEasingDisabled(moment.length === 0);
|
|
3688
|
+
for (const p of ["panX", "panY", "scale"]) {
|
|
3689
|
+
const animated = moment.some((k) => k.prop === p) || hasKeyframesForProp(clip, p);
|
|
3690
|
+
const pinned = moment.some((k) => k.prop === p);
|
|
3691
|
+
this.kfBadges[p].classList.toggle(
|
|
3692
|
+
"aicut-keyframe-panel__badge--on",
|
|
3693
|
+
pinned
|
|
3694
|
+
);
|
|
3695
|
+
this.kfBadges[p].title = pinned ? this.locale.keyframePanelBadgePinned : animated ? this.locale.keyframePanelBadgeAnimated : this.locale.keyframePanelBadgeStatic;
|
|
3696
|
+
}
|
|
3697
|
+
this.resetBtn.disabled = false;
|
|
3698
|
+
}
|
|
3699
|
+
// ---- internals ------------------------------------------------------
|
|
3700
|
+
makeRow(testId, prop, step) {
|
|
3701
|
+
const row = document.createElement("div");
|
|
3702
|
+
row.className = "aicut-keyframe-panel__row";
|
|
3703
|
+
const lab = document.createElement("label");
|
|
3704
|
+
const input = document.createElement("input");
|
|
3705
|
+
input.type = "number";
|
|
3706
|
+
input.step = String(step);
|
|
3707
|
+
input.setAttribute("data-testid", `aicut-${testId}`);
|
|
3708
|
+
input.addEventListener("blur", () => this.commit(prop, input.value));
|
|
3709
|
+
input.addEventListener("keydown", (e) => {
|
|
3710
|
+
if (e.key === "Enter") input.blur();
|
|
3711
|
+
});
|
|
3712
|
+
row.append(lab, input);
|
|
3713
|
+
this.root.appendChild(row);
|
|
3714
|
+
return { input, label: lab };
|
|
3715
|
+
}
|
|
3716
|
+
makeBadge(input) {
|
|
3717
|
+
const dot = document.createElement("span");
|
|
3718
|
+
dot.className = "aicut-keyframe-panel__badge";
|
|
3719
|
+
input.parentElement?.appendChild(dot);
|
|
3720
|
+
return dot;
|
|
3721
|
+
}
|
|
3722
|
+
commit(prop, raw) {
|
|
3723
|
+
const num = Number(raw);
|
|
3724
|
+
if (!Number.isFinite(num)) return;
|
|
3725
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3726
|
+
if (!sel) return;
|
|
3727
|
+
const clip = this.findClip(sel.clipId);
|
|
3728
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3729
|
+
if (!clip || !anchorKf) return;
|
|
3730
|
+
this.editor.addKeyframe(sel.clipId, prop, {
|
|
3731
|
+
time: anchorKf.time,
|
|
3732
|
+
value: num
|
|
3733
|
+
});
|
|
3734
|
+
if (anchorKf.prop !== prop) {
|
|
3735
|
+
const refreshedClip = this.findClip(sel.clipId);
|
|
3736
|
+
const created = (refreshedClip?.keyframes ?? []).find(
|
|
3737
|
+
(k) => k.prop === prop && Math.abs(k.time - anchorKf.time) < TIME_EPS_MS
|
|
3738
|
+
);
|
|
3739
|
+
if (created) {
|
|
3740
|
+
this.editor.setSelectedKeyframe({
|
|
3741
|
+
clipId: sel.clipId,
|
|
3742
|
+
keyframeId: created.id
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
// ---- custom dropdown -------------------------------------------------
|
|
3748
|
+
setEasingValue(value) {
|
|
3749
|
+
if (this.easingValue === value) return;
|
|
3750
|
+
this.easingValue = value;
|
|
3751
|
+
this.easingTriggerLabel.textContent = easingLabel(value, this.locale);
|
|
3752
|
+
for (const [v, li] of this.easingItems) {
|
|
3753
|
+
li.classList.toggle(
|
|
3754
|
+
"aicut-keyframe-panel__dropdown-item--selected",
|
|
3755
|
+
v === value
|
|
3756
|
+
);
|
|
3757
|
+
li.setAttribute("aria-selected", v === value ? "true" : "false");
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
setEasingDisabled(disabled) {
|
|
3761
|
+
if (this.easingDisabled === disabled) return;
|
|
3762
|
+
this.easingDisabled = disabled;
|
|
3763
|
+
this.easingTrigger.disabled = disabled;
|
|
3764
|
+
this.easingTrigger.classList.toggle(
|
|
3765
|
+
"aicut-keyframe-panel__dropdown-trigger--disabled",
|
|
3766
|
+
disabled
|
|
3767
|
+
);
|
|
3768
|
+
if (disabled && this.easingOpen) this.closeEasingMenu();
|
|
3769
|
+
}
|
|
3770
|
+
toggleEasingMenu() {
|
|
3771
|
+
if (this.easingOpen) this.closeEasingMenu();
|
|
3772
|
+
else this.openEasingMenu();
|
|
3773
|
+
}
|
|
3774
|
+
openEasingMenu() {
|
|
3775
|
+
if (this.easingOpen || this.easingDisabled) return;
|
|
3776
|
+
this.easingOpen = true;
|
|
3777
|
+
this.easingMenu.style.display = "";
|
|
3778
|
+
this.easingTrigger.setAttribute("aria-expanded", "true");
|
|
3779
|
+
this.easingTrigger.classList.add(
|
|
3780
|
+
"aicut-keyframe-panel__dropdown-trigger--open"
|
|
3781
|
+
);
|
|
3782
|
+
requestAnimationFrame(() => {
|
|
3783
|
+
if (!this.easingOpen) return;
|
|
3784
|
+
this.boundOutsideClick = (e) => {
|
|
3785
|
+
if (!this.easingMenu.contains(e.target) && !this.easingTrigger.contains(e.target)) {
|
|
3786
|
+
this.closeEasingMenu();
|
|
3787
|
+
}
|
|
3788
|
+
};
|
|
3789
|
+
this.boundDocKeydown = (e) => {
|
|
3790
|
+
if (e.key === "Escape") {
|
|
3791
|
+
e.stopPropagation();
|
|
3792
|
+
this.closeEasingMenu();
|
|
3793
|
+
this.easingTrigger.focus();
|
|
3794
|
+
} else if (e.key === "Tab") {
|
|
3795
|
+
this.closeEasingMenu();
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3798
|
+
document.addEventListener("click", this.boundOutsideClick, true);
|
|
3799
|
+
document.addEventListener("keydown", this.boundDocKeydown);
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
closeEasingMenu() {
|
|
3803
|
+
if (!this.easingOpen) return;
|
|
3804
|
+
this.easingOpen = false;
|
|
3805
|
+
this.easingMenu.style.display = "none";
|
|
3806
|
+
this.easingTrigger.setAttribute("aria-expanded", "false");
|
|
3807
|
+
this.easingTrigger.classList.remove(
|
|
3808
|
+
"aicut-keyframe-panel__dropdown-trigger--open"
|
|
3809
|
+
);
|
|
3810
|
+
if (this.boundOutsideClick) {
|
|
3811
|
+
document.removeEventListener("click", this.boundOutsideClick, true);
|
|
3812
|
+
this.boundOutsideClick = null;
|
|
3813
|
+
}
|
|
3814
|
+
if (this.boundDocKeydown) {
|
|
3815
|
+
document.removeEventListener("keydown", this.boundDocKeydown);
|
|
3816
|
+
this.boundDocKeydown = null;
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
selectEasing(value) {
|
|
3820
|
+
this.closeEasingMenu();
|
|
3821
|
+
this.easingTrigger.focus();
|
|
3822
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3823
|
+
if (!sel) return;
|
|
3824
|
+
const clip = this.findClip(sel.clipId);
|
|
3825
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3826
|
+
if (!clip || !anchorKf) return;
|
|
3827
|
+
this.editor.setKeyframesEasingAtTime(sel.clipId, anchorKf.time, value);
|
|
3828
|
+
}
|
|
3829
|
+
onMenuKeydown(e, value) {
|
|
3830
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
3831
|
+
e.preventDefault();
|
|
3832
|
+
this.selectEasing(value);
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
3836
|
+
e.preventDefault();
|
|
3837
|
+
const idx = EASING_VALUES.indexOf(value);
|
|
3838
|
+
const next = e.key === "ArrowDown" ? EASING_VALUES[(idx + 1) % EASING_VALUES.length] : EASING_VALUES[(idx - 1 + EASING_VALUES.length) % EASING_VALUES.length];
|
|
3839
|
+
this.easingItems.get(next)?.focus();
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
onReset() {
|
|
3843
|
+
const sel = this.editor.getSelectedKeyframe();
|
|
3844
|
+
if (!sel) return;
|
|
3845
|
+
const clip = this.findClip(sel.clipId);
|
|
3846
|
+
const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
|
|
3847
|
+
if (!clip || !anchorKf) return;
|
|
3848
|
+
this.editor.resetKeyframesAtTime(sel.clipId, anchorKf.time);
|
|
3849
|
+
}
|
|
3850
|
+
setIfBlur(input, value) {
|
|
3851
|
+
if (document.activeElement === input) return;
|
|
3852
|
+
if (input.value !== value) input.value = value;
|
|
3853
|
+
}
|
|
3854
|
+
findClip(clipId) {
|
|
3855
|
+
const project = this.editor.getProject();
|
|
3856
|
+
for (const t of project.tracks) {
|
|
3857
|
+
const c = t.clips.find((cl) => cl.id === clipId);
|
|
3858
|
+
if (c) return c;
|
|
3859
|
+
}
|
|
3860
|
+
return null;
|
|
3861
|
+
}
|
|
3862
|
+
};
|
|
3863
|
+
|
|
2595
3864
|
// src/ui/icons.ts
|
|
2596
3865
|
var wrap = (path) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">${path}</svg>`;
|
|
2597
3866
|
var ICONS = {
|
|
@@ -2635,6 +3904,24 @@ var ICONS = {
|
|
|
2635
3904
|
trash: wrap(
|
|
2636
3905
|
`<g transform="translate(1 1)" fill="currentColor"><path d="M5 1.25h4v.9H13v1.05H1V2.15h4v-.9zM2.4 4.1h9.2l-.65 8.9c-.04.55-.5.96-1.05.96H4.1c-.55 0-1.01-.41-1.05-.96L2.4 4.1zm2.3 1.7l.35 7.1h1l-.35-7.1h-1zm2.65 0v7.1h1V5.8h-1zm2.3 0l-.35 7.1h1l.35-7.1h-1z"/></g>`
|
|
2637
3906
|
),
|
|
3907
|
+
/** "Skip to start" — vertical bar + left-pointing triangle. Sits to
|
|
3908
|
+
* the left of the keyframe diamond so the clip-edge nav cluster
|
|
3909
|
+
* reads as [|◀ ◇ ▶|] = "go to clip start / add kf / go to clip end". */
|
|
3910
|
+
seekClipStart: wrap(
|
|
3911
|
+
`<g transform="translate(2 3)" fill="currentColor"><rect x="0" y="0" width="1.6" height="10" rx="0.4"/><path d="M11 0.6c0-0.5-0.55-0.78-0.95-0.48l-6.5 4.4c-0.34 0.23-0.34 0.73 0 0.96l6.5 4.4c0.4 0.3 0.95 0.02 0.95-0.48z"/></g>`
|
|
3912
|
+
),
|
|
3913
|
+
/** "Skip to end" — mirror of seekClipStart. */
|
|
3914
|
+
seekClipEnd: wrap(
|
|
3915
|
+
`<g transform="translate(1 3)" fill="currentColor"><path d="M0 0.6c0-0.5 0.55-0.78 0.95-0.48l6.5 4.4c0.34 0.23 0.34 0.73 0 0.96l-6.5 4.4c-0.4 0.3-0.95 0.02-0.95-0.48z"/><rect x="10.4" y="0" width="1.6" height="10" rx="0.4"/></g>`
|
|
3916
|
+
),
|
|
3917
|
+
/** Outlined diamond (rotated square) — "add keyframe" affordance. */
|
|
3918
|
+
keyframeOutline: wrap(
|
|
3919
|
+
`<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="0.5" y="0.5" width="7" height="7" rx="0.5"/></g>`
|
|
3920
|
+
),
|
|
3921
|
+
/** Filled diamond — shown when a keyframe already exists at playhead. */
|
|
3922
|
+
keyframeFilled: wrap(
|
|
3923
|
+
`<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="currentColor"><rect x="0" y="0" width="8" height="8" rx="0.8"/></g>`
|
|
3924
|
+
),
|
|
2638
3925
|
/** Counter-clockwise circular arrow — "reset to initial layout". */
|
|
2639
3926
|
reset: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`
|
|
2640
3927
|
};
|
|
@@ -2661,6 +3948,9 @@ var Toolbar = class {
|
|
|
2661
3948
|
splitBtn;
|
|
2662
3949
|
trimLeftBtn;
|
|
2663
3950
|
trimRightBtn;
|
|
3951
|
+
seekClipStartBtn;
|
|
3952
|
+
seekClipEndBtn;
|
|
3953
|
+
keyframeBtn;
|
|
2664
3954
|
playBtn;
|
|
2665
3955
|
playIcon;
|
|
2666
3956
|
timeLabel;
|
|
@@ -2686,9 +3976,37 @@ var Toolbar = class {
|
|
|
2686
3976
|
this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
|
|
2687
3977
|
this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
|
|
2688
3978
|
this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
3979
|
+
this.seekClipStartBtn = mkIconButton(
|
|
3980
|
+
"seekClipStart",
|
|
3981
|
+
locale.seekClipStart,
|
|
3982
|
+
() => cb.onSeekClipStart(),
|
|
3983
|
+
"aicut-seek-clip-start"
|
|
3984
|
+
);
|
|
3985
|
+
this.seekClipStartBtn.style.display = "none";
|
|
3986
|
+
this.keyframeBtn = mkIconButton(
|
|
3987
|
+
"keyframeOutline",
|
|
3988
|
+
locale.keyframeAdd,
|
|
3989
|
+
() => cb.onKeyframeToggle(),
|
|
3990
|
+
"aicut-keyframe"
|
|
3991
|
+
);
|
|
3992
|
+
this.keyframeBtn.style.display = "none";
|
|
3993
|
+
this.seekClipEndBtn = mkIconButton(
|
|
3994
|
+
"seekClipEnd",
|
|
3995
|
+
locale.seekClipEnd,
|
|
3996
|
+
() => cb.onSeekClipEnd(),
|
|
3997
|
+
"aicut-seek-clip-end"
|
|
3998
|
+
);
|
|
3999
|
+
this.seekClipEndBtn.style.display = "none";
|
|
4000
|
+
left.append(
|
|
4001
|
+
this.undoBtn,
|
|
4002
|
+
this.redoBtn,
|
|
4003
|
+
this.splitBtn,
|
|
4004
|
+
this.trimLeftBtn,
|
|
4005
|
+
this.trimRightBtn,
|
|
4006
|
+
this.seekClipStartBtn,
|
|
4007
|
+
this.keyframeBtn,
|
|
4008
|
+
this.seekClipEndBtn
|
|
4009
|
+
);
|
|
2692
4010
|
const center = mkGroup("aicut-toolbar-center");
|
|
2693
4011
|
this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
|
|
2694
4012
|
this.playBtn = document.createElement("button");
|
|
@@ -2765,11 +4083,38 @@ var Toolbar = class {
|
|
|
2765
4083
|
this.trimLeftBtn.disabled = !state.canTrim;
|
|
2766
4084
|
this.trimRightBtn.disabled = !state.canTrim;
|
|
2767
4085
|
}
|
|
4086
|
+
if (!this.lastState || this.lastState.clipEdgeNavEnabled !== state.clipEdgeNavEnabled) {
|
|
4087
|
+
const display = state.clipEdgeNavEnabled ? "" : "none";
|
|
4088
|
+
this.seekClipStartBtn.style.display = display;
|
|
4089
|
+
this.seekClipEndBtn.style.display = display;
|
|
4090
|
+
}
|
|
4091
|
+
if (!this.lastState || this.lastState.canSeekClipEdge !== state.canSeekClipEdge) {
|
|
4092
|
+
this.seekClipStartBtn.disabled = !state.canSeekClipEdge;
|
|
4093
|
+
this.seekClipEndBtn.disabled = !state.canSeekClipEdge;
|
|
4094
|
+
}
|
|
2768
4095
|
if (!this.lastState || this.lastState.snap !== state.snap) {
|
|
2769
4096
|
this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
|
|
2770
4097
|
this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
|
|
2771
4098
|
this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
|
|
2772
4099
|
}
|
|
4100
|
+
if (!this.lastState || this.lastState.keyframesEnabled !== state.keyframesEnabled) {
|
|
4101
|
+
this.keyframeBtn.style.display = state.keyframesEnabled ? "" : "none";
|
|
4102
|
+
}
|
|
4103
|
+
if (state.keyframesEnabled) {
|
|
4104
|
+
if (!this.lastState || this.lastState.hasKeyframeAtPlayhead !== state.hasKeyframeAtPlayhead) {
|
|
4105
|
+
this.keyframeBtn.innerHTML = state.hasKeyframeAtPlayhead ? ICONS.keyframeFilled : ICONS.keyframeOutline;
|
|
4106
|
+
const title = state.hasKeyframeAtPlayhead ? this.locale.keyframeRemove : this.locale.keyframeAdd;
|
|
4107
|
+
this.keyframeBtn.title = title;
|
|
4108
|
+
this.keyframeBtn.setAttribute("aria-label", title);
|
|
4109
|
+
this.keyframeBtn.setAttribute(
|
|
4110
|
+
"data-state",
|
|
4111
|
+
state.hasKeyframeAtPlayhead ? "on" : "off"
|
|
4112
|
+
);
|
|
4113
|
+
}
|
|
4114
|
+
if (!this.lastState || this.lastState.canKeyframe !== state.canKeyframe) {
|
|
4115
|
+
this.keyframeBtn.disabled = !state.canKeyframe;
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
2773
4118
|
if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
|
|
2774
4119
|
const ratio = scaleToSlider(state.pxPerSec);
|
|
2775
4120
|
const nextVal = String(Math.round(ratio * 100));
|
|
@@ -2802,12 +4147,20 @@ var Toolbar = class {
|
|
|
2802
4147
|
applyTitle(this.splitBtn, locale.split);
|
|
2803
4148
|
applyTitle(this.trimLeftBtn, locale.trimLeft);
|
|
2804
4149
|
applyTitle(this.trimRightBtn, locale.trimRight);
|
|
4150
|
+
applyTitle(this.seekClipStartBtn, locale.seekClipStart);
|
|
4151
|
+
applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
|
|
2805
4152
|
applyTitle(this.playBtn, locale.playPause);
|
|
2806
4153
|
applyTitle(this.fullscreenBtn, locale.fullscreen);
|
|
2807
4154
|
applyTitle(this.snapBtn, locale.snap);
|
|
2808
4155
|
applyTitle(this.zoomOutBtn, locale.zoomOut);
|
|
2809
4156
|
applyTitle(this.zoomInBtn, locale.zoomIn);
|
|
2810
4157
|
applyTitle(this.resetBtn, locale.reset);
|
|
4158
|
+
if (this.keyframeBtn) {
|
|
4159
|
+
const hasKf = this.lastState?.hasKeyframeAtPlayhead === true;
|
|
4160
|
+
const t = hasKf ? locale.keyframeRemove : locale.keyframeAdd;
|
|
4161
|
+
this.keyframeBtn.title = t;
|
|
4162
|
+
this.keyframeBtn.setAttribute("aria-label", t);
|
|
4163
|
+
}
|
|
2811
4164
|
if (this.lastState) {
|
|
2812
4165
|
this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
|
|
2813
4166
|
}
|
|
@@ -2867,6 +4220,8 @@ var EditorUI = class {
|
|
|
2867
4220
|
toolbar;
|
|
2868
4221
|
timelineHost;
|
|
2869
4222
|
timeline;
|
|
4223
|
+
keyframePanel;
|
|
4224
|
+
keyframeOverlay;
|
|
2870
4225
|
fullscreen = false;
|
|
2871
4226
|
onDocKeydown = null;
|
|
2872
4227
|
constructor(root, editor, cb) {
|
|
@@ -2913,10 +4268,14 @@ var EditorUI = class {
|
|
|
2913
4268
|
snap: editor.getSnap(),
|
|
2914
4269
|
autoFit: true,
|
|
2915
4270
|
locale,
|
|
4271
|
+
keyframesEnabled: editor.isKeyframesEnabled(),
|
|
4272
|
+
selectedKeyframe: editor.getSelectedKeyframe(),
|
|
2916
4273
|
onSeek: cb.onSeek,
|
|
2917
4274
|
onSelectClip: cb.onSelectClip,
|
|
2918
4275
|
onMoveClip: cb.onMoveClip,
|
|
2919
4276
|
onResizeClip: cb.onResizeClip,
|
|
4277
|
+
onSelectKeyframe: cb.onSelectKeyframe,
|
|
4278
|
+
onMoveKeyframe: cb.onMoveKeyframe,
|
|
2920
4279
|
onScaleChange: cb.onScaleChange,
|
|
2921
4280
|
onDeleteTrack: (trackId) => editor.removeTrack(trackId),
|
|
2922
4281
|
// Mirror the editor's smart routing into the drag preview so
|
|
@@ -2941,6 +4300,8 @@ var EditorUI = class {
|
|
|
2941
4300
|
};
|
|
2942
4301
|
}
|
|
2943
4302
|
});
|
|
4303
|
+
this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
|
|
4304
|
+
this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
|
|
2944
4305
|
this.attachKeyboard(cb);
|
|
2945
4306
|
}
|
|
2946
4307
|
// ---- fullscreen -----------------------------------------------------
|
|
@@ -2990,6 +4351,13 @@ var EditorUI = class {
|
|
|
2990
4351
|
const selectedClipId = this.editor.getSelection();
|
|
2991
4352
|
const pxPerSec = this.editor.getScale();
|
|
2992
4353
|
const snap = this.editor.getSnap();
|
|
4354
|
+
const kfEnabled = this.editor.isKeyframesEnabled();
|
|
4355
|
+
const kfState = this.computeKeyframeToolbarState(
|
|
4356
|
+
project,
|
|
4357
|
+
selectedClipId,
|
|
4358
|
+
time,
|
|
4359
|
+
kfEnabled
|
|
4360
|
+
);
|
|
2993
4361
|
this.toolbar.render({
|
|
2994
4362
|
playing: this.editor.isPlaying(),
|
|
2995
4363
|
time,
|
|
@@ -2998,18 +4366,34 @@ var EditorUI = class {
|
|
|
2998
4366
|
canRedo: this.editor.canRedo(),
|
|
2999
4367
|
canSplit: this.canSplitAt(time),
|
|
3000
4368
|
canTrim: this.canTrimAt(time, selectedClipId),
|
|
4369
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4370
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
3001
4371
|
snap,
|
|
3002
|
-
pxPerSec
|
|
4372
|
+
pxPerSec,
|
|
4373
|
+
...kfState
|
|
3003
4374
|
});
|
|
3004
4375
|
this.timeline.setProject(project);
|
|
3005
4376
|
this.timeline.setTime(time);
|
|
3006
4377
|
this.timeline.setScale(pxPerSec);
|
|
3007
4378
|
this.timeline.setSelection(selectedClipId);
|
|
3008
4379
|
this.timeline.setSnap(snap);
|
|
4380
|
+
this.timeline.setKeyframeState({
|
|
4381
|
+
enabled: this.editor.isKeyframesEnabled(),
|
|
4382
|
+
selected: this.editor.getSelectedKeyframe()
|
|
4383
|
+
});
|
|
4384
|
+
this.keyframePanel.render();
|
|
3009
4385
|
}
|
|
3010
4386
|
/** Playback-fast path: nudge playhead + toolbar time label only. */
|
|
3011
4387
|
onTimeTick(timeMs) {
|
|
3012
4388
|
this.timeline.setTime(timeMs);
|
|
4389
|
+
const selectedClipId = this.editor.getSelection();
|
|
4390
|
+
const kfEnabled = this.editor.isKeyframesEnabled();
|
|
4391
|
+
const kfState = this.computeKeyframeToolbarState(
|
|
4392
|
+
this.editor.getProject(),
|
|
4393
|
+
selectedClipId,
|
|
4394
|
+
timeMs,
|
|
4395
|
+
kfEnabled
|
|
4396
|
+
);
|
|
3013
4397
|
this.toolbar.render({
|
|
3014
4398
|
playing: this.editor.isPlaying(),
|
|
3015
4399
|
time: timeMs,
|
|
@@ -3017,9 +4401,12 @@ var EditorUI = class {
|
|
|
3017
4401
|
canUndo: this.editor.canUndo(),
|
|
3018
4402
|
canRedo: this.editor.canRedo(),
|
|
3019
4403
|
canSplit: this.canSplitAt(timeMs),
|
|
3020
|
-
canTrim: this.canTrimAt(timeMs,
|
|
4404
|
+
canTrim: this.canTrimAt(timeMs, selectedClipId),
|
|
4405
|
+
canSeekClipEdge: selectedClipId != null,
|
|
4406
|
+
clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
|
|
3021
4407
|
snap: this.editor.getSnap(),
|
|
3022
|
-
pxPerSec: this.editor.getScale()
|
|
4408
|
+
pxPerSec: this.editor.getScale(),
|
|
4409
|
+
...kfState
|
|
3023
4410
|
});
|
|
3024
4411
|
}
|
|
3025
4412
|
/** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
|
|
@@ -3031,6 +4418,7 @@ var EditorUI = class {
|
|
|
3031
4418
|
this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
|
|
3032
4419
|
this.fullscreenExitBtn.textContent = locale.exitFullscreen;
|
|
3033
4420
|
this.timeline.setLocale(locale);
|
|
4421
|
+
this.keyframePanel.setLocale(locale);
|
|
3034
4422
|
this.render();
|
|
3035
4423
|
}
|
|
3036
4424
|
destroy() {
|
|
@@ -3040,10 +4428,56 @@ var EditorUI = class {
|
|
|
3040
4428
|
}
|
|
3041
4429
|
this.toolbar.destroy();
|
|
3042
4430
|
this.timeline.destroy();
|
|
4431
|
+
this.keyframePanel.destroy();
|
|
4432
|
+
this.keyframeOverlay.destroy();
|
|
3043
4433
|
this.root.innerHTML = "";
|
|
3044
4434
|
this.root.classList.remove("aicut-root", "aicut-fullscreen");
|
|
3045
4435
|
}
|
|
3046
4436
|
// ---- helpers --------------------------------------------------------
|
|
4437
|
+
/** Walk the selected clip + playhead state to figure out (a) whether
|
|
4438
|
+
* the keyframe button should be enabled, and (b) whether a keyframe
|
|
4439
|
+
* already exists at the playhead's clip-local time (so the button
|
|
4440
|
+
* swaps to "remove" mode). */
|
|
4441
|
+
computeKeyframeToolbarState(project, selectedClipId, time, keyframesEnabled) {
|
|
4442
|
+
if (!keyframesEnabled || !selectedClipId) {
|
|
4443
|
+
return {
|
|
4444
|
+
canKeyframe: false,
|
|
4445
|
+
hasKeyframeAtPlayhead: false,
|
|
4446
|
+
keyframesEnabled
|
|
4447
|
+
};
|
|
4448
|
+
}
|
|
4449
|
+
let clip = null;
|
|
4450
|
+
for (const t of project.tracks) {
|
|
4451
|
+
const c = t.clips.find((cl) => cl.id === selectedClipId);
|
|
4452
|
+
if (c) {
|
|
4453
|
+
clip = c;
|
|
4454
|
+
break;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
if (!clip) {
|
|
4458
|
+
return {
|
|
4459
|
+
canKeyframe: false,
|
|
4460
|
+
hasKeyframeAtPlayhead: false,
|
|
4461
|
+
keyframesEnabled
|
|
4462
|
+
};
|
|
4463
|
+
}
|
|
4464
|
+
const localMs = time - clip.start;
|
|
4465
|
+
const duration = clipDuration(clip);
|
|
4466
|
+
if (localMs < 0 || localMs > duration) {
|
|
4467
|
+
return {
|
|
4468
|
+
canKeyframe: false,
|
|
4469
|
+
hasKeyframeAtPlayhead: false,
|
|
4470
|
+
keyframesEnabled
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
4473
|
+
const roundedLocal = Math.round(localMs);
|
|
4474
|
+
const hasKf = clip.keyframes?.some((k) => k.time === roundedLocal) ?? false;
|
|
4475
|
+
return {
|
|
4476
|
+
canKeyframe: true,
|
|
4477
|
+
hasKeyframeAtPlayhead: hasKf,
|
|
4478
|
+
keyframesEnabled
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
3047
4481
|
canSplitAt(timeMs) {
|
|
3048
4482
|
const project = this.editor.getProject();
|
|
3049
4483
|
for (const t of project.tracks) {
|
|
@@ -3085,6 +4519,22 @@ var EditorUI = class {
|
|
|
3085
4519
|
} else if (e.code === "KeyW") {
|
|
3086
4520
|
e.preventDefault();
|
|
3087
4521
|
cb.onTrimRight();
|
|
4522
|
+
} else if (e.code === "KeyI" && this.editor.isClipEdgeNavEnabled()) {
|
|
4523
|
+
e.preventDefault();
|
|
4524
|
+
cb.onSeekClipStart();
|
|
4525
|
+
} else if (e.code === "KeyO" && this.editor.isClipEdgeNavEnabled()) {
|
|
4526
|
+
e.preventDefault();
|
|
4527
|
+
cb.onSeekClipEnd();
|
|
4528
|
+
} else if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
|
|
4529
|
+
e.preventDefault();
|
|
4530
|
+
const project = this.editor.getProject();
|
|
4531
|
+
const step = e.shiftKey ? bigFrameStepMs(project) : frameStepMs(project);
|
|
4532
|
+
const dir = e.code === "ArrowLeft" ? -1 : 1;
|
|
4533
|
+
const next = Math.max(
|
|
4534
|
+
0,
|
|
4535
|
+
Math.min(this.editor.getDuration(), this.editor.getTime() + dir * step)
|
|
4536
|
+
);
|
|
4537
|
+
cb.onSeek(next);
|
|
3088
4538
|
} else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
|
|
3089
4539
|
e.preventDefault();
|
|
3090
4540
|
if (e.shiftKey) cb.onRedo();
|
|
@@ -3113,6 +4563,13 @@ var Editor = class _Editor {
|
|
|
3113
4563
|
bus = new EventBus();
|
|
3114
4564
|
history = new HistoryStack();
|
|
3115
4565
|
selectedClipId = null;
|
|
4566
|
+
selectedKeyframe = null;
|
|
4567
|
+
keyframesEnabled;
|
|
4568
|
+
clipEdgeNavEnabled;
|
|
4569
|
+
/** Drag-session bookkeeping for ripple-merge undo. See
|
|
4570
|
+
* beginInteraction / endInteraction docs on EditorApi. */
|
|
4571
|
+
interactionDepth = 0;
|
|
4572
|
+
interactionStartSnapshot = null;
|
|
3116
4573
|
pxPerSec;
|
|
3117
4574
|
snap;
|
|
3118
4575
|
locale;
|
|
@@ -3123,6 +4580,8 @@ var Editor = class _Editor {
|
|
|
3123
4580
|
this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
|
|
3124
4581
|
this.snap = opts.initialSnap !== false;
|
|
3125
4582
|
this.locale = mergeLocale(opts.locale);
|
|
4583
|
+
this.keyframesEnabled = opts.keyframes?.enabled === true;
|
|
4584
|
+
this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
|
|
3126
4585
|
if (opts.trackHeight != null || opts.rulerHeight != null) {
|
|
3127
4586
|
setTimelineMetrics({
|
|
3128
4587
|
...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
|
|
@@ -3151,7 +4610,12 @@ var Editor = class _Editor {
|
|
|
3151
4610
|
onSelectClip: (id) => this.setSelection(id),
|
|
3152
4611
|
onDeleteClip: (id) => this.removeClip(id),
|
|
3153
4612
|
onMoveClip: (id, opts2) => this.moveClip(id, opts2),
|
|
3154
|
-
onResizeClip: (id, edits) => this.resizeClip(id, edits)
|
|
4613
|
+
onResizeClip: (id, edits) => this.resizeClip(id, edits),
|
|
4614
|
+
onSelectKeyframe: (target) => this.setSelectedKeyframe(target),
|
|
4615
|
+
onMoveKeyframe: (clipId, keyframeId, timeMs) => this.moveKeyframe(clipId, keyframeId, timeMs),
|
|
4616
|
+
onKeyframeToggle: () => this.toggleKeyframeAtPlayhead(),
|
|
4617
|
+
onSeekClipStart: () => this.seekToSelectedClipEdge("start"),
|
|
4618
|
+
onSeekClipEnd: () => this.seekToSelectedClipEdge("end")
|
|
3155
4619
|
});
|
|
3156
4620
|
const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
|
|
3157
4621
|
this.engine = engineFactory({
|
|
@@ -3575,6 +5039,9 @@ var Editor = class _Editor {
|
|
|
3575
5039
|
for (const c of t.clips) {
|
|
3576
5040
|
if (c.id === ignoreClipId) continue;
|
|
3577
5041
|
targets.push(c.start, clipEnd(c));
|
|
5042
|
+
if (c.keyframes) {
|
|
5043
|
+
for (const kf of c.keyframes) targets.push(c.start + kf.time);
|
|
5044
|
+
}
|
|
3578
5045
|
}
|
|
3579
5046
|
}
|
|
3580
5047
|
let best = timeMs;
|
|
@@ -3596,6 +5063,304 @@ var Editor = class _Editor {
|
|
|
3596
5063
|
if (clipId === this.selectedClipId) return;
|
|
3597
5064
|
this.selectedClipId = clipId;
|
|
3598
5065
|
this.bus.emit("selectionChange", { clipId });
|
|
5066
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId !== clipId) {
|
|
5067
|
+
this.selectedKeyframe = null;
|
|
5068
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5069
|
+
}
|
|
5070
|
+
this.ui.render();
|
|
5071
|
+
}
|
|
5072
|
+
// ---- keyframes ------------------------------------------------------
|
|
5073
|
+
isKeyframesEnabled() {
|
|
5074
|
+
return this.keyframesEnabled;
|
|
5075
|
+
}
|
|
5076
|
+
/**
|
|
5077
|
+
* Screen-space CSS-pixel rect of the actively painted frame
|
|
5078
|
+
* (post-transform), relative to the editor's preview element.
|
|
5079
|
+
* Null when no clip is active, the engine doesn't expose
|
|
5080
|
+
* `getFrameRect`, or the rect isn't computed yet. Used by the
|
|
5081
|
+
* library's keyframe-editing overlay.
|
|
5082
|
+
*/
|
|
5083
|
+
getActiveFrameRect() {
|
|
5084
|
+
return this.engine.getFrameRect?.() ?? null;
|
|
5085
|
+
}
|
|
5086
|
+
/**
|
|
5087
|
+
* Screen-space CSS-pixel rect of the OUTPUT FRAME (the fixed
|
|
5088
|
+
* stage that clips the rendered video). Different from
|
|
5089
|
+
* `getActiveFrameRect` which includes the keyframe transform —
|
|
5090
|
+
* this one stays put as the user drags / scales the content.
|
|
5091
|
+
* Used by the overlay to anchor the dashed border + drag body.
|
|
5092
|
+
*/
|
|
5093
|
+
getActiveOutputFrameRect() {
|
|
5094
|
+
return this.engine.getOutputFrameRect?.() ?? null;
|
|
5095
|
+
}
|
|
5096
|
+
setKeyframesEnabled(enabled) {
|
|
5097
|
+
if (enabled === this.keyframesEnabled) return;
|
|
5098
|
+
this.keyframesEnabled = enabled;
|
|
5099
|
+
if (!enabled && this.selectedKeyframe) {
|
|
5100
|
+
this.selectedKeyframe = null;
|
|
5101
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5102
|
+
}
|
|
5103
|
+
this.bus.emit("keyframesEnabledChange", { enabled });
|
|
5104
|
+
this.ui.render();
|
|
5105
|
+
}
|
|
5106
|
+
isClipEdgeNavEnabled() {
|
|
5107
|
+
return this.clipEdgeNavEnabled;
|
|
5108
|
+
}
|
|
5109
|
+
setClipEdgeNavEnabled(enabled) {
|
|
5110
|
+
if (enabled === this.clipEdgeNavEnabled) return;
|
|
5111
|
+
this.clipEdgeNavEnabled = enabled;
|
|
5112
|
+
this.bus.emit("clipEdgeNavEnabledChange", { enabled });
|
|
5113
|
+
this.ui.render();
|
|
5114
|
+
}
|
|
5115
|
+
addKeyframe(clipId, prop, opts = {}) {
|
|
5116
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5117
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5118
|
+
if (!trk || !cl) return null;
|
|
5119
|
+
const duration = clipDuration(cl);
|
|
5120
|
+
const playheadLocal = this.engine.getTime() - cl.start;
|
|
5121
|
+
const rawTime = opts.time ?? playheadLocal;
|
|
5122
|
+
const time = Math.max(0, Math.min(duration, Math.round(rawTime)));
|
|
5123
|
+
const value = opts.value ?? interpolateProp(cl, prop, time);
|
|
5124
|
+
this.pushHistory();
|
|
5125
|
+
cl.keyframes = upsertKeyframe(
|
|
5126
|
+
cl.keyframes,
|
|
5127
|
+
prop,
|
|
5128
|
+
time,
|
|
5129
|
+
value,
|
|
5130
|
+
() => createId("kf")
|
|
5131
|
+
);
|
|
5132
|
+
cl.keyframes.sort((a, b) => {
|
|
5133
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5134
|
+
return a.time - b.time;
|
|
5135
|
+
});
|
|
5136
|
+
this.afterMutation();
|
|
5137
|
+
const created = cl.keyframes.find(
|
|
5138
|
+
(k) => k.prop === prop && Math.abs(k.time - time) < 16
|
|
5139
|
+
);
|
|
5140
|
+
return created?.id ?? null;
|
|
5141
|
+
}
|
|
5142
|
+
removeKeyframe(clipId, keyframeId) {
|
|
5143
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5144
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5145
|
+
if (!trk || !cl || !cl.keyframes) return false;
|
|
5146
|
+
const idx = cl.keyframes.findIndex((k) => k.id === keyframeId);
|
|
5147
|
+
if (idx < 0) return false;
|
|
5148
|
+
this.pushHistory();
|
|
5149
|
+
const next = cl.keyframes.slice();
|
|
5150
|
+
next.splice(idx, 1);
|
|
5151
|
+
cl.keyframes = next.length > 0 ? next : void 0;
|
|
5152
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId && this.selectedKeyframe.keyframeId === keyframeId) {
|
|
5153
|
+
this.selectedKeyframe = null;
|
|
5154
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5155
|
+
}
|
|
5156
|
+
this.afterMutation();
|
|
5157
|
+
return true;
|
|
5158
|
+
}
|
|
5159
|
+
moveKeyframe(clipId, keyframeId, timeMs) {
|
|
5160
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5161
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5162
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
5163
|
+
if (!trk || !cl || !kf || !cl.keyframes) return false;
|
|
5164
|
+
const duration = clipDuration(cl);
|
|
5165
|
+
const clamped = Math.max(0, Math.min(duration, Math.round(timeMs)));
|
|
5166
|
+
if (clamped === kf.time) return false;
|
|
5167
|
+
if (cl.keyframes.some(
|
|
5168
|
+
(k) => k.id !== keyframeId && k.prop === kf.prop && k.time === clamped
|
|
5169
|
+
)) {
|
|
5170
|
+
return false;
|
|
5171
|
+
}
|
|
5172
|
+
this.pushHistory();
|
|
5173
|
+
kf.time = clamped;
|
|
5174
|
+
cl.keyframes.sort((a, b) => {
|
|
5175
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5176
|
+
return a.time - b.time;
|
|
5177
|
+
});
|
|
5178
|
+
this.afterMutation();
|
|
5179
|
+
return true;
|
|
5180
|
+
}
|
|
5181
|
+
setKeyframeValue(clipId, keyframeId, value) {
|
|
5182
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5183
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5184
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
5185
|
+
if (!trk || !cl || !kf) return false;
|
|
5186
|
+
if (Math.abs(kf.value - value) < 1e-9) return false;
|
|
5187
|
+
this.pushHistory();
|
|
5188
|
+
kf.value = value;
|
|
5189
|
+
this.afterMutation();
|
|
5190
|
+
return true;
|
|
5191
|
+
}
|
|
5192
|
+
setKeyframeEasing(clipId, keyframeId, easing) {
|
|
5193
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5194
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5195
|
+
const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
|
|
5196
|
+
if (!trk || !cl || !kf) return false;
|
|
5197
|
+
const current = kf.easing ?? "linear";
|
|
5198
|
+
if (current === easing) return false;
|
|
5199
|
+
this.pushHistory();
|
|
5200
|
+
if (easing === "linear") {
|
|
5201
|
+
delete kf.easing;
|
|
5202
|
+
} else {
|
|
5203
|
+
kf.easing = easing;
|
|
5204
|
+
}
|
|
5205
|
+
this.afterMutation();
|
|
5206
|
+
return true;
|
|
5207
|
+
}
|
|
5208
|
+
setKeyframesEasingAtTime(clipId, timeMs, easing) {
|
|
5209
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5210
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5211
|
+
if (!trk || !cl || !cl.keyframes) return false;
|
|
5212
|
+
const t = Math.round(timeMs);
|
|
5213
|
+
const matches = cl.keyframes.filter((k) => Math.abs(k.time - t) < 16);
|
|
5214
|
+
if (matches.length === 0) return false;
|
|
5215
|
+
const anyChange = matches.some((k) => (k.easing ?? "linear") !== easing);
|
|
5216
|
+
if (!anyChange) return false;
|
|
5217
|
+
this.pushHistory();
|
|
5218
|
+
for (const kf of matches) {
|
|
5219
|
+
if (easing === "linear") delete kf.easing;
|
|
5220
|
+
else kf.easing = easing;
|
|
5221
|
+
}
|
|
5222
|
+
this.afterMutation();
|
|
5223
|
+
return true;
|
|
5224
|
+
}
|
|
5225
|
+
setValueAtPlayhead(clipId, prop, value) {
|
|
5226
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5227
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5228
|
+
if (!trk || !cl) return false;
|
|
5229
|
+
const duration = clipDuration(cl);
|
|
5230
|
+
const playheadLocal = this.engine.getTime() - cl.start;
|
|
5231
|
+
const time = Math.max(0, Math.min(duration, Math.round(playheadLocal)));
|
|
5232
|
+
const hasKf = cl.keyframes?.some((k) => k.prop === prop) ?? false;
|
|
5233
|
+
if (hasKf) {
|
|
5234
|
+
this.pushHistory();
|
|
5235
|
+
cl.keyframes = upsertKeyframe(
|
|
5236
|
+
cl.keyframes,
|
|
5237
|
+
prop,
|
|
5238
|
+
time,
|
|
5239
|
+
value,
|
|
5240
|
+
() => createId("kf")
|
|
5241
|
+
);
|
|
5242
|
+
cl.keyframes.sort((a, b) => {
|
|
5243
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5244
|
+
return a.time - b.time;
|
|
5245
|
+
});
|
|
5246
|
+
this.afterMutation();
|
|
5247
|
+
return true;
|
|
5248
|
+
}
|
|
5249
|
+
if ((cl[prop] ?? (prop === "scale" ? 1 : 0)) === value) return false;
|
|
5250
|
+
this.pushHistory();
|
|
5251
|
+
cl[prop] = value;
|
|
5252
|
+
this.afterMutation();
|
|
5253
|
+
return true;
|
|
5254
|
+
}
|
|
5255
|
+
getSelectedKeyframe() {
|
|
5256
|
+
return this.selectedKeyframe;
|
|
5257
|
+
}
|
|
5258
|
+
resetClipTransform(clipId) {
|
|
5259
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5260
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5261
|
+
if (!trk || !cl) return false;
|
|
5262
|
+
const dirty = cl.keyframes && cl.keyframes.length > 0 || cl.panX !== void 0 || cl.panY !== void 0 || cl.scale !== void 0;
|
|
5263
|
+
if (!dirty) return false;
|
|
5264
|
+
this.pushHistory();
|
|
5265
|
+
delete cl.panX;
|
|
5266
|
+
delete cl.panY;
|
|
5267
|
+
delete cl.scale;
|
|
5268
|
+
cl.keyframes = void 0;
|
|
5269
|
+
if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId) {
|
|
5270
|
+
this.selectedKeyframe = null;
|
|
5271
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5272
|
+
}
|
|
5273
|
+
this.afterMutation();
|
|
5274
|
+
return true;
|
|
5275
|
+
}
|
|
5276
|
+
resetKeyframesAtTime(clipId, timeMs) {
|
|
5277
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5278
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5279
|
+
if (!trk || !cl) return false;
|
|
5280
|
+
const duration = clipDuration(cl);
|
|
5281
|
+
const t = Math.max(0, Math.min(duration, Math.round(timeMs)));
|
|
5282
|
+
this.pushHistory();
|
|
5283
|
+
let kfs = cl.keyframes ?? [];
|
|
5284
|
+
kfs = upsertKeyframe(kfs, "panX", t, 0, () => createId("kf"));
|
|
5285
|
+
kfs = upsertKeyframe(kfs, "panY", t, 0, () => createId("kf"));
|
|
5286
|
+
kfs = upsertKeyframe(kfs, "scale", t, 1, () => createId("kf"));
|
|
5287
|
+
kfs.sort((a, b) => {
|
|
5288
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5289
|
+
return a.time - b.time;
|
|
5290
|
+
});
|
|
5291
|
+
cl.keyframes = kfs;
|
|
5292
|
+
this.afterMutation();
|
|
5293
|
+
return true;
|
|
5294
|
+
}
|
|
5295
|
+
seekToClipEdge(clipId, edge) {
|
|
5296
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5297
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5298
|
+
if (!trk || !cl) return false;
|
|
5299
|
+
const target = edge === "start" ? cl.start : Math.max(cl.start, clipEnd(cl) - 1);
|
|
5300
|
+
if (this.engine.getTime() === target) return false;
|
|
5301
|
+
this.seek(target);
|
|
5302
|
+
return true;
|
|
5303
|
+
}
|
|
5304
|
+
seekToSelectedClipEdge(edge) {
|
|
5305
|
+
if (!this.selectedClipId) return false;
|
|
5306
|
+
return this.seekToClipEdge(this.selectedClipId, edge);
|
|
5307
|
+
}
|
|
5308
|
+
toggleKeyframeAtPlayhead() {
|
|
5309
|
+
const clipId = this.selectedClipId;
|
|
5310
|
+
if (!clipId) return false;
|
|
5311
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
5312
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
5313
|
+
if (!trk || !cl) return false;
|
|
5314
|
+
const localMs = this.engine.getTime() - cl.start;
|
|
5315
|
+
const duration = clipDuration(cl);
|
|
5316
|
+
if (localMs < 0 || localMs > duration) return false;
|
|
5317
|
+
const t = Math.round(localMs);
|
|
5318
|
+
const existing = cl.keyframes?.filter((k) => Math.abs(k.time - t) < 16);
|
|
5319
|
+
if (existing && existing.length > 0) {
|
|
5320
|
+
this.pushHistory();
|
|
5321
|
+
const ids = new Set(existing.map((k) => k.id));
|
|
5322
|
+
const next = cl.keyframes.filter((k) => !ids.has(k.id));
|
|
5323
|
+
cl.keyframes = next.length > 0 ? next : void 0;
|
|
5324
|
+
if (this.selectedKeyframe && ids.has(this.selectedKeyframe.keyframeId)) {
|
|
5325
|
+
this.selectedKeyframe = null;
|
|
5326
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5327
|
+
}
|
|
5328
|
+
this.afterMutation();
|
|
5329
|
+
return true;
|
|
5330
|
+
}
|
|
5331
|
+
const current = getEffectiveTransform(cl, t);
|
|
5332
|
+
this.pushHistory();
|
|
5333
|
+
let kfs = cl.keyframes ?? [];
|
|
5334
|
+
kfs = upsertKeyframe(kfs, "panX", t, current.panX, () => createId("kf"));
|
|
5335
|
+
kfs = upsertKeyframe(kfs, "panY", t, current.panY, () => createId("kf"));
|
|
5336
|
+
kfs = upsertKeyframe(kfs, "scale", t, current.scale, () => createId("kf"));
|
|
5337
|
+
kfs.sort((a, b) => {
|
|
5338
|
+
if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
|
|
5339
|
+
return a.time - b.time;
|
|
5340
|
+
});
|
|
5341
|
+
cl.keyframes = kfs;
|
|
5342
|
+
const anchor = kfs.find(
|
|
5343
|
+
(k) => k.prop === "panX" && Math.abs(k.time - t) < 16
|
|
5344
|
+
);
|
|
5345
|
+
if (anchor) {
|
|
5346
|
+
this.selectedKeyframe = { clipId, keyframeId: anchor.id };
|
|
5347
|
+
this.bus.emit("keyframeSelectionChange", {
|
|
5348
|
+
target: this.selectedKeyframe
|
|
5349
|
+
});
|
|
5350
|
+
}
|
|
5351
|
+
this.afterMutation();
|
|
5352
|
+
return true;
|
|
5353
|
+
}
|
|
5354
|
+
setSelectedKeyframe(target) {
|
|
5355
|
+
if (target?.clipId === this.selectedKeyframe?.clipId && target?.keyframeId === this.selectedKeyframe?.keyframeId) {
|
|
5356
|
+
return;
|
|
5357
|
+
}
|
|
5358
|
+
this.selectedKeyframe = target;
|
|
5359
|
+
if (target && target.clipId !== this.selectedClipId) {
|
|
5360
|
+
this.selectedClipId = target.clipId;
|
|
5361
|
+
this.bus.emit("selectionChange", { clipId: target.clipId });
|
|
5362
|
+
}
|
|
5363
|
+
this.bus.emit("keyframeSelectionChange", { target });
|
|
3599
5364
|
this.ui.render();
|
|
3600
5365
|
}
|
|
3601
5366
|
// ---- history --------------------------------------------------------
|
|
@@ -3609,6 +5374,7 @@ var Editor = class _Editor {
|
|
|
3609
5374
|
const prev = this.history.undo(this.project);
|
|
3610
5375
|
if (!prev) return false;
|
|
3611
5376
|
this.project = prev;
|
|
5377
|
+
this.reconcileSelectionsWithProject();
|
|
3612
5378
|
this.engine.setProject(this.project);
|
|
3613
5379
|
this.bus.emit("change", { project: this.getProject() });
|
|
3614
5380
|
this.emitHistory();
|
|
@@ -3619,12 +5385,57 @@ var Editor = class _Editor {
|
|
|
3619
5385
|
const next = this.history.redo(this.project);
|
|
3620
5386
|
if (!next) return false;
|
|
3621
5387
|
this.project = next;
|
|
5388
|
+
this.reconcileSelectionsWithProject();
|
|
3622
5389
|
this.engine.setProject(this.project);
|
|
3623
5390
|
this.bus.emit("change", { project: this.getProject() });
|
|
3624
5391
|
this.emitHistory();
|
|
3625
5392
|
this.ui.render();
|
|
3626
5393
|
return true;
|
|
3627
5394
|
}
|
|
5395
|
+
beginInteraction() {
|
|
5396
|
+
this.interactionDepth += 1;
|
|
5397
|
+
}
|
|
5398
|
+
endInteraction() {
|
|
5399
|
+
if (this.interactionDepth === 0) return;
|
|
5400
|
+
this.interactionDepth -= 1;
|
|
5401
|
+
if (this.interactionDepth > 0) return;
|
|
5402
|
+
const snapshot = this.interactionStartSnapshot;
|
|
5403
|
+
this.interactionStartSnapshot = null;
|
|
5404
|
+
if (snapshot == null) return;
|
|
5405
|
+
const now = JSON.stringify(this.project);
|
|
5406
|
+
if (now === snapshot) return;
|
|
5407
|
+
this.history.push(JSON.parse(snapshot));
|
|
5408
|
+
this.emitHistory();
|
|
5409
|
+
}
|
|
5410
|
+
/**
|
|
5411
|
+
* Selections (clipId + selectedKeyframe) live OUTSIDE the project
|
|
5412
|
+
* snapshot, so undo / redo can leave them pointing at ids that no
|
|
5413
|
+
* longer exist. Defend against dangling refs by clearing anything
|
|
5414
|
+
* the restored project doesn't actually contain — and emit the
|
|
5415
|
+
* paired change events so panels / overlays hide cleanly instead
|
|
5416
|
+
* of holding zombie references.
|
|
5417
|
+
*/
|
|
5418
|
+
reconcileSelectionsWithProject() {
|
|
5419
|
+
if (this.selectedKeyframe) {
|
|
5420
|
+
const trk = findTrackOfClip(this.project, this.selectedKeyframe.clipId);
|
|
5421
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedKeyframe.clipId);
|
|
5422
|
+
const kf = cl?.keyframes?.find(
|
|
5423
|
+
(k) => k.id === this.selectedKeyframe.keyframeId
|
|
5424
|
+
);
|
|
5425
|
+
if (!kf) {
|
|
5426
|
+
this.selectedKeyframe = null;
|
|
5427
|
+
this.bus.emit("keyframeSelectionChange", { target: null });
|
|
5428
|
+
}
|
|
5429
|
+
}
|
|
5430
|
+
if (this.selectedClipId) {
|
|
5431
|
+
const trk = findTrackOfClip(this.project, this.selectedClipId);
|
|
5432
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
|
|
5433
|
+
if (!cl) {
|
|
5434
|
+
this.selectedClipId = null;
|
|
5435
|
+
this.bus.emit("selectionChange", { clipId: null });
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
5438
|
+
}
|
|
3628
5439
|
// ---- events ---------------------------------------------------------
|
|
3629
5440
|
on(event, handler) {
|
|
3630
5441
|
return this.bus.on(event, handler);
|
|
@@ -3661,6 +5472,12 @@ var Editor = class _Editor {
|
|
|
3661
5472
|
return null;
|
|
3662
5473
|
}
|
|
3663
5474
|
pushHistory() {
|
|
5475
|
+
if (this.interactionDepth > 0) {
|
|
5476
|
+
if (this.interactionStartSnapshot == null) {
|
|
5477
|
+
this.interactionStartSnapshot = JSON.stringify(this.project);
|
|
5478
|
+
}
|
|
5479
|
+
return;
|
|
5480
|
+
}
|
|
3664
5481
|
this.history.push(this.project);
|
|
3665
5482
|
this.emitHistory();
|
|
3666
5483
|
}
|
|
@@ -3706,12 +5523,16 @@ exports.CanvasCompositorEngine = CanvasCompositorEngine;
|
|
|
3706
5523
|
exports.Editor = Editor;
|
|
3707
5524
|
exports.HEADER_WIDTH = HEADER_WIDTH;
|
|
3708
5525
|
exports.HtmlVideoEngine = HtmlVideoEngine;
|
|
5526
|
+
exports.IDENTITY_TRANSFORM = IDENTITY_TRANSFORM;
|
|
3709
5527
|
exports.Timeline = Timeline;
|
|
3710
5528
|
exports.canvasCompositorEngineFactory = canvasCompositorEngineFactory;
|
|
3711
5529
|
exports.createEmptyProject = createEmptyProject;
|
|
3712
5530
|
exports.createId = createId;
|
|
3713
5531
|
exports.formatLabel = formatLabel;
|
|
5532
|
+
exports.getEffectiveTransform = getEffectiveTransform;
|
|
5533
|
+
exports.getTransformAtTimelineTime = getTransformAtTimelineTime;
|
|
3714
5534
|
exports.htmlVideoEngineFactory = htmlVideoEngineFactory;
|
|
5535
|
+
exports.isIdentityTransform = isIdentityTransform;
|
|
3715
5536
|
exports.localeEn = localeEn;
|
|
3716
5537
|
exports.localeZh = localeZh;
|
|
3717
5538
|
exports.mergeLocale = mergeLocale;
|