@aicut/core 0.1.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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/index.cjs +3344 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +640 -0
- package/dist/index.d.ts +640 -0
- package/dist/index.js +3331 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/styles/theme.css +414 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3344 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/events.ts
|
|
4
|
+
var EventBus = class {
|
|
5
|
+
listeners = /* @__PURE__ */ new Map();
|
|
6
|
+
on(event, handler) {
|
|
7
|
+
let set = this.listeners.get(event);
|
|
8
|
+
if (!set) {
|
|
9
|
+
set = /* @__PURE__ */ new Set();
|
|
10
|
+
this.listeners.set(event, set);
|
|
11
|
+
}
|
|
12
|
+
set.add(handler);
|
|
13
|
+
return () => this.off(event, handler);
|
|
14
|
+
}
|
|
15
|
+
off(event, handler) {
|
|
16
|
+
this.listeners.get(event)?.delete(handler);
|
|
17
|
+
}
|
|
18
|
+
emit(event, payload) {
|
|
19
|
+
const set = this.listeners.get(event);
|
|
20
|
+
if (!set) return;
|
|
21
|
+
for (const h of set) {
|
|
22
|
+
try {
|
|
23
|
+
h(payload);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error("[aicut] event handler threw", event, err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
clear() {
|
|
30
|
+
this.listeners.clear();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/history.ts
|
|
35
|
+
var HistoryStack = class {
|
|
36
|
+
undoStack = [];
|
|
37
|
+
redoStack = [];
|
|
38
|
+
limit;
|
|
39
|
+
constructor(limit = 50) {
|
|
40
|
+
this.limit = limit;
|
|
41
|
+
}
|
|
42
|
+
/** Call BEFORE applying a mutation. */
|
|
43
|
+
push(previous) {
|
|
44
|
+
this.undoStack.push(clone(previous));
|
|
45
|
+
if (this.undoStack.length > this.limit) this.undoStack.shift();
|
|
46
|
+
this.redoStack.length = 0;
|
|
47
|
+
}
|
|
48
|
+
canUndo() {
|
|
49
|
+
return this.undoStack.length > 0;
|
|
50
|
+
}
|
|
51
|
+
canRedo() {
|
|
52
|
+
return this.redoStack.length > 0;
|
|
53
|
+
}
|
|
54
|
+
/** Returns the project to restore, or null if nothing to undo. */
|
|
55
|
+
undo(current) {
|
|
56
|
+
const prev = this.undoStack.pop();
|
|
57
|
+
if (!prev) return null;
|
|
58
|
+
this.redoStack.push(clone(current));
|
|
59
|
+
return prev;
|
|
60
|
+
}
|
|
61
|
+
redo(current) {
|
|
62
|
+
const next = this.redoStack.pop();
|
|
63
|
+
if (!next) return null;
|
|
64
|
+
this.undoStack.push(clone(current));
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
clear() {
|
|
68
|
+
this.undoStack.length = 0;
|
|
69
|
+
this.redoStack.length = 0;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function clone(p) {
|
|
73
|
+
return JSON.parse(JSON.stringify(p));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/ids.ts
|
|
77
|
+
function createId(prefix = "id") {
|
|
78
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
79
|
+
const t = Date.now().toString(36).slice(-4);
|
|
80
|
+
return `${prefix}_${t}${rand}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/model.ts
|
|
84
|
+
function createEmptyProject() {
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
sources: [],
|
|
88
|
+
tracks: [{ id: createId("track"), kind: "video", clips: [] }]
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function clipDuration(c) {
|
|
92
|
+
return c.out - c.in;
|
|
93
|
+
}
|
|
94
|
+
function clipEnd(c) {
|
|
95
|
+
return c.start + clipDuration(c);
|
|
96
|
+
}
|
|
97
|
+
function trackEnd(track) {
|
|
98
|
+
let max = 0;
|
|
99
|
+
for (const c of track.clips) {
|
|
100
|
+
const end = clipEnd(c);
|
|
101
|
+
if (end > max) max = end;
|
|
102
|
+
}
|
|
103
|
+
return max;
|
|
104
|
+
}
|
|
105
|
+
function findClipContaining(track, timeMs) {
|
|
106
|
+
for (const c of track.clips) {
|
|
107
|
+
if (timeMs >= c.start && timeMs < clipEnd(c)) return c;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function findTrackOfClip(project, clipId) {
|
|
112
|
+
for (const t of project.tracks) {
|
|
113
|
+
if (t.clips.some((c) => c.id === clipId)) return t;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function normalizeProject(project) {
|
|
118
|
+
const sources = project.sources.map((s) => ({ ...s }));
|
|
119
|
+
const tracks = project.tracks.map((t) => {
|
|
120
|
+
const clips = t.clips.filter((c) => c.out > c.in).map((c) => ({ ...c, id: c.id || createId("clip") })).sort((a, b) => a.start - b.start);
|
|
121
|
+
return { ...t, id: t.id || createId("track"), clips };
|
|
122
|
+
});
|
|
123
|
+
return { version: 1, sources, tracks };
|
|
124
|
+
}
|
|
125
|
+
function splitClipAt(clip, localOffset) {
|
|
126
|
+
const sourceLen = clip.out - clip.in;
|
|
127
|
+
if (localOffset <= 0 || localOffset >= sourceLen) return null;
|
|
128
|
+
const left = { ...clip, out: clip.in + localOffset };
|
|
129
|
+
const right = {
|
|
130
|
+
...clip,
|
|
131
|
+
id: createId("clip"),
|
|
132
|
+
in: clip.in + localOffset,
|
|
133
|
+
start: clip.start + localOffset
|
|
134
|
+
};
|
|
135
|
+
return [left, right];
|
|
136
|
+
}
|
|
137
|
+
function projectDuration(project) {
|
|
138
|
+
let max = 0;
|
|
139
|
+
for (const t of project.tracks) {
|
|
140
|
+
const e = trackEnd(t);
|
|
141
|
+
if (e > max) max = e;
|
|
142
|
+
}
|
|
143
|
+
return max;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/timeline/layout.ts
|
|
147
|
+
var TRACK_HEIGHT = 56;
|
|
148
|
+
var RULER_HEIGHT = 24;
|
|
149
|
+
var HEADER_WIDTH = 96;
|
|
150
|
+
var HANDLE_PX = 8;
|
|
151
|
+
var CLIP_INSET = 6;
|
|
152
|
+
var SCALE_MIN = 10;
|
|
153
|
+
var SCALE_MAX = 400;
|
|
154
|
+
var SCROLLBAR_THICKNESS = 10;
|
|
155
|
+
var SCROLLBAR_MIN_THUMB = 24;
|
|
156
|
+
var SCROLLBAR_INSET = 2;
|
|
157
|
+
function contentHeight(tracks, isDragging) {
|
|
158
|
+
return tracks.length * TRACK_HEIGHT + (isDragging ? TRACK_HEIGHT : 0);
|
|
159
|
+
}
|
|
160
|
+
function contentWidth(project, pxPerSec) {
|
|
161
|
+
let max = 0;
|
|
162
|
+
for (const t of project.tracks) {
|
|
163
|
+
for (const c of t.clips) {
|
|
164
|
+
const end = c.start + (c.out - c.in);
|
|
165
|
+
if (end > max) max = end;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return max / 1e3 * pxPerSec;
|
|
169
|
+
}
|
|
170
|
+
function trackY(index) {
|
|
171
|
+
return RULER_HEIGHT + index * TRACK_HEIGHT;
|
|
172
|
+
}
|
|
173
|
+
function trackIndexAt(y, trackCount, scrollTop = 0) {
|
|
174
|
+
if (y < RULER_HEIGHT) return -1;
|
|
175
|
+
const contentY = y - RULER_HEIGHT + scrollTop;
|
|
176
|
+
const idx = Math.floor(contentY / TRACK_HEIGHT);
|
|
177
|
+
if (idx < 0 || idx >= trackCount) return -1;
|
|
178
|
+
return idx;
|
|
179
|
+
}
|
|
180
|
+
function xToMs(x, pxPerSec, scrollLeft, showHeader) {
|
|
181
|
+
const base = showHeader ? HEADER_WIDTH : 0;
|
|
182
|
+
return Math.max(0, (x - base + scrollLeft) / pxPerSec * 1e3);
|
|
183
|
+
}
|
|
184
|
+
function niceTickSeconds(targetSec) {
|
|
185
|
+
if (targetSec <= 0) return 1;
|
|
186
|
+
const exp = Math.floor(Math.log10(targetSec));
|
|
187
|
+
const base = targetSec / Math.pow(10, exp);
|
|
188
|
+
let nice;
|
|
189
|
+
if (base < 1.5) nice = 1;
|
|
190
|
+
else if (base < 3) nice = 2;
|
|
191
|
+
else if (base < 7) nice = 5;
|
|
192
|
+
else nice = 10;
|
|
193
|
+
return nice * Math.pow(10, exp);
|
|
194
|
+
}
|
|
195
|
+
function formatRulerLabel(sec) {
|
|
196
|
+
if (sec < 60) return `${Math.round(sec * 10) / 10}s`;
|
|
197
|
+
const m = Math.floor(sec / 60);
|
|
198
|
+
const s = Math.round(sec - m * 60);
|
|
199
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
200
|
+
}
|
|
201
|
+
function clampScale(s) {
|
|
202
|
+
return Math.max(SCALE_MIN, Math.min(SCALE_MAX, s));
|
|
203
|
+
}
|
|
204
|
+
function snapTargets(project, playheadMs, ignoreClipId) {
|
|
205
|
+
const arr = [0, playheadMs];
|
|
206
|
+
for (const t of project.tracks) {
|
|
207
|
+
for (const c of t.clips) {
|
|
208
|
+
if (c.id === ignoreClipId) continue;
|
|
209
|
+
arr.push(c.start, c.start + (c.out - c.in));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return arr;
|
|
213
|
+
}
|
|
214
|
+
function wouldOverlap(track, clipId, start, end) {
|
|
215
|
+
for (const c of track.clips) {
|
|
216
|
+
if (c.id === clipId) continue;
|
|
217
|
+
const cEnd = c.start + (c.out - c.in);
|
|
218
|
+
if (start < cEnd && end > c.start) return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
function findClip(project, clipId) {
|
|
223
|
+
for (let i = 0; i < project.tracks.length; i++) {
|
|
224
|
+
const t = project.tracks[i];
|
|
225
|
+
const c = t.clips.find((c2) => c2.id === clipId);
|
|
226
|
+
if (c) return { track: t, clip: c, trackIndex: i };
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function uncoveredIntervals(project) {
|
|
231
|
+
const intervals = [];
|
|
232
|
+
for (const t of project.tracks) {
|
|
233
|
+
if (t.kind !== "video") continue;
|
|
234
|
+
for (const c of t.clips) {
|
|
235
|
+
const end = c.start + (c.out - c.in);
|
|
236
|
+
if (end > c.start) intervals.push([c.start, end]);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (intervals.length === 0) return [];
|
|
240
|
+
intervals.sort((a, b) => a[0] - b[0]);
|
|
241
|
+
const merged = [];
|
|
242
|
+
for (const [s, e] of intervals) {
|
|
243
|
+
const last = merged[merged.length - 1];
|
|
244
|
+
if (last && s <= last[1]) last[1] = Math.max(last[1], e);
|
|
245
|
+
else merged.push([s, e]);
|
|
246
|
+
}
|
|
247
|
+
const gaps = [];
|
|
248
|
+
let cursor = 0;
|
|
249
|
+
for (const [s, e] of merged) {
|
|
250
|
+
if (s > cursor) gaps.push([cursor, s]);
|
|
251
|
+
cursor = e;
|
|
252
|
+
}
|
|
253
|
+
return gaps;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/playback.ts
|
|
257
|
+
var PlaybackEngine = class {
|
|
258
|
+
host;
|
|
259
|
+
mount;
|
|
260
|
+
videos = /* @__PURE__ */ new Map();
|
|
261
|
+
project;
|
|
262
|
+
currentClipId = null;
|
|
263
|
+
playing = false;
|
|
264
|
+
timeMs = 0;
|
|
265
|
+
rafHandle = null;
|
|
266
|
+
lastFrameTs = 0;
|
|
267
|
+
/** Public event hooks — set by Editor. */
|
|
268
|
+
onTimeUpdate;
|
|
269
|
+
onEnded;
|
|
270
|
+
onError;
|
|
271
|
+
onReady;
|
|
272
|
+
onSourceMetadata;
|
|
273
|
+
constructor(host, project) {
|
|
274
|
+
this.host = host;
|
|
275
|
+
this.project = project;
|
|
276
|
+
this.mount = document.createElement("div");
|
|
277
|
+
this.mount.className = "aicut-preview";
|
|
278
|
+
this.host.appendChild(this.mount);
|
|
279
|
+
this.syncSources();
|
|
280
|
+
}
|
|
281
|
+
setProject(next) {
|
|
282
|
+
this.project = next;
|
|
283
|
+
this.syncSources();
|
|
284
|
+
const clip = this.clipAtTime(this.timeMs);
|
|
285
|
+
if (!clip) {
|
|
286
|
+
this.timeMs = 0;
|
|
287
|
+
this.activate(null);
|
|
288
|
+
this.onTimeUpdate?.(0);
|
|
289
|
+
} else {
|
|
290
|
+
this.activate(clip);
|
|
291
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
play() {
|
|
295
|
+
if (this.playing) return;
|
|
296
|
+
if (this.totalDuration() <= 0) return;
|
|
297
|
+
let clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
|
|
298
|
+
if (!clip) return;
|
|
299
|
+
if (this.timeMs < clip.start) this.timeMs = clip.start;
|
|
300
|
+
this.activate(clip);
|
|
301
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
302
|
+
const v = this.videos.get(clip.sourceId);
|
|
303
|
+
if (!v) return;
|
|
304
|
+
void v.play().catch((err) => this.onError?.(err));
|
|
305
|
+
this.playing = true;
|
|
306
|
+
this.startTickLoop();
|
|
307
|
+
}
|
|
308
|
+
pause() {
|
|
309
|
+
if (!this.playing) return;
|
|
310
|
+
this.playing = false;
|
|
311
|
+
this.stopTickLoop();
|
|
312
|
+
if (this.currentClipId) {
|
|
313
|
+
const clip = this.clipById(this.currentClipId);
|
|
314
|
+
if (clip) {
|
|
315
|
+
const v = this.videos.get(clip.sourceId);
|
|
316
|
+
v?.pause();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
isPlaying() {
|
|
321
|
+
return this.playing;
|
|
322
|
+
}
|
|
323
|
+
getTime() {
|
|
324
|
+
return this.timeMs;
|
|
325
|
+
}
|
|
326
|
+
seek(timeMs) {
|
|
327
|
+
const total = this.totalDuration();
|
|
328
|
+
if (total <= 0) {
|
|
329
|
+
this.timeMs = 0;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const clamped = Math.max(0, Math.min(timeMs, total));
|
|
333
|
+
this.timeMs = clamped;
|
|
334
|
+
const clip = this.clipAtTime(clamped);
|
|
335
|
+
if (clip) {
|
|
336
|
+
this.activate(clip);
|
|
337
|
+
this.seekVideoToClipOffset(clip, clamped - clip.start);
|
|
338
|
+
} else {
|
|
339
|
+
this.activate(null);
|
|
340
|
+
}
|
|
341
|
+
this.onTimeUpdate?.(clamped);
|
|
342
|
+
}
|
|
343
|
+
destroy() {
|
|
344
|
+
this.stopTickLoop();
|
|
345
|
+
for (const v of this.videos.values()) {
|
|
346
|
+
v.pause();
|
|
347
|
+
v.removeAttribute("src");
|
|
348
|
+
v.load();
|
|
349
|
+
v.remove();
|
|
350
|
+
}
|
|
351
|
+
this.videos.clear();
|
|
352
|
+
this.mount.remove();
|
|
353
|
+
}
|
|
354
|
+
// --- internals -------------------------------------------------------
|
|
355
|
+
syncSources() {
|
|
356
|
+
const wanted = new Set(this.project.sources.map((s) => s.id));
|
|
357
|
+
for (const [id, v] of this.videos) {
|
|
358
|
+
if (!wanted.has(id)) {
|
|
359
|
+
v.pause();
|
|
360
|
+
v.remove();
|
|
361
|
+
this.videos.delete(id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const src of this.project.sources) {
|
|
365
|
+
if (src.kind !== "video") continue;
|
|
366
|
+
if (this.videos.has(src.id)) continue;
|
|
367
|
+
const v = document.createElement("video");
|
|
368
|
+
v.preload = "auto";
|
|
369
|
+
v.playsInline = true;
|
|
370
|
+
v.muted = false;
|
|
371
|
+
v.src = src.url;
|
|
372
|
+
v.style.position = "absolute";
|
|
373
|
+
v.style.inset = "0";
|
|
374
|
+
v.style.width = "100%";
|
|
375
|
+
v.style.height = "100%";
|
|
376
|
+
v.style.objectFit = "contain";
|
|
377
|
+
v.style.visibility = "hidden";
|
|
378
|
+
const sourceId = src.id;
|
|
379
|
+
v.addEventListener(
|
|
380
|
+
"error",
|
|
381
|
+
() => this.onError?.(new Error(`Failed to load ${src.url}`))
|
|
382
|
+
);
|
|
383
|
+
v.addEventListener("loadedmetadata", () => {
|
|
384
|
+
this.onReady?.();
|
|
385
|
+
const durMs = Math.round(v.duration * 1e3);
|
|
386
|
+
if (Number.isFinite(durMs) && durMs > 0) {
|
|
387
|
+
this.onSourceMetadata?.(sourceId, durMs);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
this.mount.appendChild(v);
|
|
391
|
+
this.videos.set(src.id, v);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
activate(clip) {
|
|
395
|
+
if (clip?.id === this.currentClipId) return;
|
|
396
|
+
if (this.currentClipId) {
|
|
397
|
+
const prev = this.clipById(this.currentClipId);
|
|
398
|
+
if (prev) {
|
|
399
|
+
const v = this.videos.get(prev.sourceId);
|
|
400
|
+
if (v) {
|
|
401
|
+
v.pause();
|
|
402
|
+
v.style.visibility = "hidden";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
this.currentClipId = clip ? clip.id : null;
|
|
407
|
+
if (clip) {
|
|
408
|
+
const v = this.videos.get(clip.sourceId);
|
|
409
|
+
if (v) v.style.visibility = "visible";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
seekVideoToClipOffset(clip, offsetMs) {
|
|
413
|
+
const v = this.videos.get(clip.sourceId);
|
|
414
|
+
if (!v) return;
|
|
415
|
+
const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
|
|
416
|
+
if (Math.abs(v.currentTime - target) > 0.05) {
|
|
417
|
+
v.currentTime = target;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
clipById(id) {
|
|
421
|
+
for (const t of this.project.tracks) {
|
|
422
|
+
for (const c of t.clips) if (c.id === id) return c;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Find the clip whose timeline range contains `timeMs`, searching
|
|
428
|
+
* across ALL video tracks. If multiple tracks have a clip at this
|
|
429
|
+
* moment, the lowest-index track wins (matches the "Track 1 is
|
|
430
|
+
* background" convention used in the auto-split UX — overlapping
|
|
431
|
+
* placements would have created a new track on top, but here we
|
|
432
|
+
* fall back to the underlying clip).
|
|
433
|
+
*/
|
|
434
|
+
clipAtTime(timeMs) {
|
|
435
|
+
for (const t of this.project.tracks) {
|
|
436
|
+
if (t.kind !== "video") continue;
|
|
437
|
+
for (const c of t.clips) {
|
|
438
|
+
if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
/** Earliest clip starting at-or-after `timeMs` across all video tracks. */
|
|
444
|
+
nextClipAfterTime(timeMs) {
|
|
445
|
+
let best = null;
|
|
446
|
+
for (const t of this.project.tracks) {
|
|
447
|
+
if (t.kind !== "video") continue;
|
|
448
|
+
for (const c of t.clips) {
|
|
449
|
+
if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return best;
|
|
453
|
+
}
|
|
454
|
+
/** Max clip end across all video tracks. */
|
|
455
|
+
totalDuration() {
|
|
456
|
+
let max = 0;
|
|
457
|
+
for (const t of this.project.tracks) {
|
|
458
|
+
if (t.kind !== "video") continue;
|
|
459
|
+
for (const c of t.clips) {
|
|
460
|
+
const e = c.start + (c.out - c.in);
|
|
461
|
+
if (e > max) max = e;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return max;
|
|
465
|
+
}
|
|
466
|
+
startTickLoop() {
|
|
467
|
+
this.lastFrameTs = performance.now();
|
|
468
|
+
const tick = (now) => {
|
|
469
|
+
if (!this.playing) return;
|
|
470
|
+
const dtMs = now - this.lastFrameTs;
|
|
471
|
+
this.lastFrameTs = now;
|
|
472
|
+
this.advance(dtMs);
|
|
473
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
474
|
+
};
|
|
475
|
+
this.rafHandle = requestAnimationFrame(tick);
|
|
476
|
+
}
|
|
477
|
+
stopTickLoop() {
|
|
478
|
+
if (this.rafHandle != null) {
|
|
479
|
+
cancelAnimationFrame(this.rafHandle);
|
|
480
|
+
this.rafHandle = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
advance(dtMs) {
|
|
484
|
+
if (this.project.tracks.length === 0) return;
|
|
485
|
+
this.timeMs += dtMs;
|
|
486
|
+
const totalDur = this.totalDuration();
|
|
487
|
+
if (this.timeMs >= totalDur) {
|
|
488
|
+
this.timeMs = totalDur;
|
|
489
|
+
this.onTimeUpdate?.(this.timeMs);
|
|
490
|
+
this.pause();
|
|
491
|
+
this.onEnded?.();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const clip = this.clipAtTime(this.timeMs);
|
|
495
|
+
if (!clip) {
|
|
496
|
+
const next = this.nextClipAfterTime(this.timeMs);
|
|
497
|
+
if (next) {
|
|
498
|
+
this.timeMs = next.start;
|
|
499
|
+
this.activate(next);
|
|
500
|
+
this.seekVideoToClipOffset(next, 0);
|
|
501
|
+
const v = this.videos.get(next.sourceId);
|
|
502
|
+
if (v) void v.play().catch((err) => this.onError?.(err));
|
|
503
|
+
} else {
|
|
504
|
+
this.pause();
|
|
505
|
+
this.onEnded?.();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
} else if (clip.id !== this.currentClipId) {
|
|
509
|
+
this.activate(clip);
|
|
510
|
+
this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
|
|
511
|
+
const v = this.videos.get(clip.sourceId);
|
|
512
|
+
if (v) void v.play().catch((err) => this.onError?.(err));
|
|
513
|
+
}
|
|
514
|
+
this.onTimeUpdate?.(this.timeMs);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// src/theme.ts
|
|
519
|
+
var THEME_VARS = {
|
|
520
|
+
brand: "--color-brand",
|
|
521
|
+
secondary: "--color-secondary",
|
|
522
|
+
surface: "--color-surface",
|
|
523
|
+
dark: "--color-dark",
|
|
524
|
+
muted: "--color-muted",
|
|
525
|
+
card: "--color-card",
|
|
526
|
+
success: "--color-success",
|
|
527
|
+
warning: "--color-warning",
|
|
528
|
+
info: "--color-info",
|
|
529
|
+
error: "--color-error",
|
|
530
|
+
controlsBg: "--aicut-controls-bg",
|
|
531
|
+
controlsBorder: "--aicut-controls-border",
|
|
532
|
+
controlsText: "--aicut-controls-text",
|
|
533
|
+
controlsHover: "--aicut-controls-hover",
|
|
534
|
+
controlsActive: "--aicut-controls-active",
|
|
535
|
+
previewBg: "--aicut-preview-bg",
|
|
536
|
+
radiusSm: "--aicut-radius-sm",
|
|
537
|
+
radiusMd: "--aicut-radius-md",
|
|
538
|
+
radiusLg: "--aicut-radius-lg"
|
|
539
|
+
};
|
|
540
|
+
function applyTheme(root, theme) {
|
|
541
|
+
if (!theme) return;
|
|
542
|
+
for (const key of Object.keys(theme)) {
|
|
543
|
+
const cssVar = THEME_VARS[key];
|
|
544
|
+
const value = theme[key];
|
|
545
|
+
if (cssVar && value) root.style.setProperty(cssVar, value);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/i18n.ts
|
|
550
|
+
var localeEn = {
|
|
551
|
+
undo: "Undo",
|
|
552
|
+
redo: "Redo",
|
|
553
|
+
split: "Split",
|
|
554
|
+
trimLeft: "Trim left edge",
|
|
555
|
+
trimRight: "Trim right edge",
|
|
556
|
+
speedComingSoon: "Speed (coming soon)",
|
|
557
|
+
playPause: "Play / Pause (Space)",
|
|
558
|
+
fullscreen: "Fullscreen preview",
|
|
559
|
+
snap: "Snap",
|
|
560
|
+
snapOnTitle: "Turn off snap",
|
|
561
|
+
snapOffTitle: "Turn on snap",
|
|
562
|
+
zoomOut: "Zoom out",
|
|
563
|
+
zoomIn: "Zoom in",
|
|
564
|
+
reset: "Reset edits (keep sources)",
|
|
565
|
+
exitFullscreen: "Exit fullscreen",
|
|
566
|
+
exitFullscreenTitle: "Exit fullscreen (Esc)",
|
|
567
|
+
newTrack: "+ New track",
|
|
568
|
+
videoTrackLabel: "Video {n}",
|
|
569
|
+
audioTrackLabel: "Audio {n}"
|
|
570
|
+
};
|
|
571
|
+
var localeZh = {
|
|
572
|
+
undo: "\u64A4\u9500",
|
|
573
|
+
redo: "\u91CD\u505A",
|
|
574
|
+
split: "\u5206\u5272",
|
|
575
|
+
trimLeft: "\u5411\u5DE6\u88C1\u526A",
|
|
576
|
+
trimRight: "\u5411\u53F3\u88C1\u526A",
|
|
577
|
+
speedComingSoon: "\u53D8\u901F\uFF08\u5373\u5C06\u5230\u6765\uFF09",
|
|
578
|
+
playPause: "\u64AD\u653E / \u6682\u505C (Space)",
|
|
579
|
+
fullscreen: "\u5168\u5C4F\u9884\u89C8",
|
|
580
|
+
snap: "\u5438\u9644",
|
|
581
|
+
snapOnTitle: "\u5173\u95ED\u5438\u9644",
|
|
582
|
+
snapOffTitle: "\u5F00\u542F\u5438\u9644",
|
|
583
|
+
zoomOut: "\u7F29\u5C0F",
|
|
584
|
+
zoomIn: "\u653E\u5927",
|
|
585
|
+
reset: "\u91CD\u7F6E\u7F16\u8F91\uFF08\u4FDD\u7559\u89C6\u9891\u6E90\uFF09",
|
|
586
|
+
exitFullscreen: "\u9000\u51FA\u5168\u5C4F",
|
|
587
|
+
exitFullscreenTitle: "\u9000\u51FA\u5168\u5C4F (Esc)",
|
|
588
|
+
newTrack: "+ \u65B0\u8F68\u9053",
|
|
589
|
+
videoTrackLabel: "\u89C6\u9891 {n}",
|
|
590
|
+
audioTrackLabel: "\u97F3\u9891 {n}"
|
|
591
|
+
};
|
|
592
|
+
function mergeLocale(partial) {
|
|
593
|
+
return partial ? { ...localeEn, ...partial } : localeEn;
|
|
594
|
+
}
|
|
595
|
+
function formatLabel(template, vars) {
|
|
596
|
+
return template.replace(
|
|
597
|
+
/\{(\w+)\}/g,
|
|
598
|
+
(_, k) => k in vars ? String(vars[k]) : `{${k}}`
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/ui/thumbnails.ts
|
|
603
|
+
var THUMB_HEIGHT = 44;
|
|
604
|
+
var THUMB_WIDTH = Math.round(THUMB_HEIGHT * (16 / 9));
|
|
605
|
+
var BUCKET_MS = 250;
|
|
606
|
+
var ThumbnailRibbon = class {
|
|
607
|
+
host;
|
|
608
|
+
sources = /* @__PURE__ */ new Map();
|
|
609
|
+
onUpdate;
|
|
610
|
+
static get THUMB_HEIGHT() {
|
|
611
|
+
return THUMB_HEIGHT;
|
|
612
|
+
}
|
|
613
|
+
static get THUMB_WIDTH() {
|
|
614
|
+
return THUMB_WIDTH;
|
|
615
|
+
}
|
|
616
|
+
constructor(host, onUpdate) {
|
|
617
|
+
this.host = host;
|
|
618
|
+
this.onUpdate = onUpdate;
|
|
619
|
+
}
|
|
620
|
+
syncSources(sources) {
|
|
621
|
+
const wanted = new Set(sources.map((s) => s.id));
|
|
622
|
+
for (const [id, st] of this.sources) {
|
|
623
|
+
if (!wanted.has(id)) {
|
|
624
|
+
st.video.remove();
|
|
625
|
+
for (const bmp of st.cache.values()) bmp.close();
|
|
626
|
+
this.sources.delete(id);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const src of sources) {
|
|
630
|
+
if (src.kind !== "video") continue;
|
|
631
|
+
if (this.sources.has(src.id)) continue;
|
|
632
|
+
const v = document.createElement("video");
|
|
633
|
+
v.src = src.url;
|
|
634
|
+
v.preload = "auto";
|
|
635
|
+
v.muted = true;
|
|
636
|
+
v.playsInline = true;
|
|
637
|
+
v.style.position = "absolute";
|
|
638
|
+
v.style.left = "-9999px";
|
|
639
|
+
v.style.top = "-9999px";
|
|
640
|
+
v.style.width = "160px";
|
|
641
|
+
v.style.height = "90px";
|
|
642
|
+
this.host.appendChild(v);
|
|
643
|
+
const ready = new Promise((resolve) => {
|
|
644
|
+
if (v.readyState >= 1) resolve();
|
|
645
|
+
else v.addEventListener("loadedmetadata", () => resolve(), { once: true });
|
|
646
|
+
});
|
|
647
|
+
this.sources.set(src.id, {
|
|
648
|
+
video: v,
|
|
649
|
+
cache: /* @__PURE__ */ new Map(),
|
|
650
|
+
inflight: /* @__PURE__ */ new Set(),
|
|
651
|
+
ready,
|
|
652
|
+
queue: [],
|
|
653
|
+
busy: false
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Paint thumbnails for the clip's visible window onto `ctx`. The
|
|
659
|
+
* canvas is the per-clip strip — width = clip's px width, height =
|
|
660
|
+
* THUMB_HEIGHT. Source-time range derives from the clip's `in/out`
|
|
661
|
+
* and the px range we're drawing into.
|
|
662
|
+
*/
|
|
663
|
+
paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
|
|
664
|
+
ctx.clearRect(0, 0, pxWidth, THUMB_HEIGHT);
|
|
665
|
+
const st = this.sources.get(sourceId);
|
|
666
|
+
if (!st) return;
|
|
667
|
+
if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
|
|
668
|
+
const count = Math.max(1, Math.ceil(pxWidth / THUMB_WIDTH));
|
|
669
|
+
const spanMs = sourceOutMs - sourceInMs;
|
|
670
|
+
for (let i = 0; i < count; i++) {
|
|
671
|
+
const tMs = sourceInMs + spanMs * i / count;
|
|
672
|
+
const bucket = Math.round(tMs / BUCKET_MS) * BUCKET_MS;
|
|
673
|
+
const bmp = st.cache.get(bucket);
|
|
674
|
+
const x = Math.round(i * pxWidth / count);
|
|
675
|
+
const w = Math.round((i + 1) * pxWidth / count) - x;
|
|
676
|
+
if (bmp) {
|
|
677
|
+
ctx.drawImage(bmp, x, 0, w, THUMB_HEIGHT);
|
|
678
|
+
} else {
|
|
679
|
+
ctx.fillStyle = "rgba(255,255,255,0.04)";
|
|
680
|
+
ctx.fillRect(x, 0, w, THUMB_HEIGHT);
|
|
681
|
+
this.enqueue(st, bucket);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
destroy() {
|
|
686
|
+
for (const st of this.sources.values()) {
|
|
687
|
+
st.video.remove();
|
|
688
|
+
for (const bmp of st.cache.values()) bmp.close();
|
|
689
|
+
}
|
|
690
|
+
this.sources.clear();
|
|
691
|
+
}
|
|
692
|
+
// ---- internals ------------------------------------------------------
|
|
693
|
+
enqueue(st, bucketMs) {
|
|
694
|
+
if (st.cache.has(bucketMs) || st.inflight.has(bucketMs)) return;
|
|
695
|
+
st.inflight.add(bucketMs);
|
|
696
|
+
st.queue.push(bucketMs);
|
|
697
|
+
void this.drain(st);
|
|
698
|
+
}
|
|
699
|
+
async drain(st) {
|
|
700
|
+
if (st.busy) return;
|
|
701
|
+
st.busy = true;
|
|
702
|
+
try {
|
|
703
|
+
await st.ready;
|
|
704
|
+
while (st.queue.length > 0) {
|
|
705
|
+
const t = st.queue.shift();
|
|
706
|
+
try {
|
|
707
|
+
const bmp = await this.extractFrame(st.video, t);
|
|
708
|
+
st.cache.set(t, bmp);
|
|
709
|
+
} catch {
|
|
710
|
+
} finally {
|
|
711
|
+
st.inflight.delete(t);
|
|
712
|
+
}
|
|
713
|
+
this.onUpdate();
|
|
714
|
+
}
|
|
715
|
+
} finally {
|
|
716
|
+
st.busy = false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
extractFrame(video, timeMs) {
|
|
720
|
+
const targetSec = Math.max(
|
|
721
|
+
0,
|
|
722
|
+
Math.min((video.duration || Infinity) - 0.05, timeMs / 1e3)
|
|
723
|
+
);
|
|
724
|
+
return new Promise((resolve, reject) => {
|
|
725
|
+
const onSeeked = () => {
|
|
726
|
+
try {
|
|
727
|
+
const cnv = document.createElement("canvas");
|
|
728
|
+
cnv.width = THUMB_WIDTH;
|
|
729
|
+
cnv.height = THUMB_HEIGHT;
|
|
730
|
+
const cx = cnv.getContext("2d");
|
|
731
|
+
if (!cx) return reject(new Error("no 2d ctx"));
|
|
732
|
+
cx.drawImage(video, 0, 0, THUMB_WIDTH, THUMB_HEIGHT);
|
|
733
|
+
createImageBitmap(cnv).then(resolve, reject);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
reject(e);
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
video.addEventListener("seeked", onSeeked, { once: true });
|
|
739
|
+
try {
|
|
740
|
+
video.currentTime = targetSec;
|
|
741
|
+
} catch (e) {
|
|
742
|
+
video.removeEventListener("seeked", onSeeked);
|
|
743
|
+
reject(e);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// src/ui/format.ts
|
|
750
|
+
function fmtClock(ms) {
|
|
751
|
+
const total = Math.max(0, Math.round(ms / 1e3));
|
|
752
|
+
const m = Math.floor(total / 60);
|
|
753
|
+
const s = total % 60;
|
|
754
|
+
return `${pad2(m)}:${pad2(s)}`;
|
|
755
|
+
}
|
|
756
|
+
function fmtClockMs(ms) {
|
|
757
|
+
const total = Math.max(0, Math.round(ms));
|
|
758
|
+
const m = Math.floor(total / 6e4);
|
|
759
|
+
const s = Math.floor(total % 6e4 / 1e3);
|
|
760
|
+
const r = total % 1e3;
|
|
761
|
+
return `${pad2(m)}:${pad2(s)}.${pad3(r)}`;
|
|
762
|
+
}
|
|
763
|
+
var pad2 = (n) => n < 10 ? `0${n}` : `${n}`;
|
|
764
|
+
var pad3 = (n) => n.toString().padStart(3, "0");
|
|
765
|
+
|
|
766
|
+
// src/timeline/draw.ts
|
|
767
|
+
function drawAll(ctx, state, style, thumbs) {
|
|
768
|
+
const { viewportWidth: W, viewportHeight: H } = state;
|
|
769
|
+
ctx.fillStyle = style.bg;
|
|
770
|
+
ctx.fillRect(0, 0, W, H);
|
|
771
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
772
|
+
const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
|
|
773
|
+
const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
774
|
+
ctx.save();
|
|
775
|
+
ctx.beginPath();
|
|
776
|
+
ctx.rect(baseX, RULER_HEIGHT, trackAreaW, trackAreaH);
|
|
777
|
+
ctx.clip();
|
|
778
|
+
ctx.translate(0, -state.scrollTop);
|
|
779
|
+
drawTracks(ctx, state, style, thumbs);
|
|
780
|
+
if (state.isDragging) {
|
|
781
|
+
drawPhantomRow(ctx, state.project.tracks.length, baseX, state, style);
|
|
782
|
+
}
|
|
783
|
+
if (state.dragGhost) drawDragGhost(ctx, state, style, thumbs);
|
|
784
|
+
ctx.restore();
|
|
785
|
+
if (state.showHeader) {
|
|
786
|
+
ctx.save();
|
|
787
|
+
ctx.beginPath();
|
|
788
|
+
ctx.rect(0, RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
|
|
789
|
+
ctx.clip();
|
|
790
|
+
ctx.translate(0, -state.scrollTop);
|
|
791
|
+
drawHeaders(ctx, state, style);
|
|
792
|
+
ctx.restore();
|
|
793
|
+
}
|
|
794
|
+
ctx.save();
|
|
795
|
+
ctx.beginPath();
|
|
796
|
+
ctx.rect(baseX, 0, trackAreaW, RULER_HEIGHT);
|
|
797
|
+
ctx.clip();
|
|
798
|
+
drawRuler(ctx, state, style);
|
|
799
|
+
ctx.restore();
|
|
800
|
+
ctx.save();
|
|
801
|
+
ctx.beginPath();
|
|
802
|
+
ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
|
|
803
|
+
ctx.clip();
|
|
804
|
+
drawCoverageGaps(ctx, state);
|
|
805
|
+
ctx.restore();
|
|
806
|
+
ctx.save();
|
|
807
|
+
ctx.beginPath();
|
|
808
|
+
ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
|
|
809
|
+
ctx.clip();
|
|
810
|
+
drawSnapGuide(ctx, state, style);
|
|
811
|
+
drawPlayhead(ctx, state, style);
|
|
812
|
+
ctx.restore();
|
|
813
|
+
drawScrollbarV(ctx, state, style);
|
|
814
|
+
drawScrollbarH(ctx, state, style);
|
|
815
|
+
}
|
|
816
|
+
function drawCoverageGaps(ctx, state, style) {
|
|
817
|
+
const gaps = uncoveredIntervals(state.project);
|
|
818
|
+
if (gaps.length === 0) return;
|
|
819
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
820
|
+
const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
821
|
+
for (const [s, e] of gaps) {
|
|
822
|
+
const x1 = Math.max(
|
|
823
|
+
baseX,
|
|
824
|
+
baseX + s / 1e3 * state.pxPerSec - state.scrollLeft
|
|
825
|
+
);
|
|
826
|
+
const x2 = Math.min(
|
|
827
|
+
state.viewportWidth,
|
|
828
|
+
baseX + e / 1e3 * state.pxPerSec - state.scrollLeft
|
|
829
|
+
);
|
|
830
|
+
if (x2 <= x1) continue;
|
|
831
|
+
ctx.fillStyle = "rgba(250, 167, 0, 0.35)";
|
|
832
|
+
ctx.fillRect(x1, 0, x2 - x1, RULER_HEIGHT);
|
|
833
|
+
ctx.fillStyle = "rgba(250, 167, 0, 0.12)";
|
|
834
|
+
ctx.fillRect(x1, RULER_HEIGHT, x2 - x1, trackStackH);
|
|
835
|
+
ctx.save();
|
|
836
|
+
ctx.strokeStyle = "rgba(250, 167, 0, 0.6)";
|
|
837
|
+
ctx.lineWidth = 1;
|
|
838
|
+
ctx.beginPath();
|
|
839
|
+
for (let hx = Math.floor(x1); hx < x2; hx += 6) {
|
|
840
|
+
ctx.moveTo(hx, RULER_HEIGHT - 1);
|
|
841
|
+
ctx.lineTo(hx + 6, RULER_HEIGHT - 7);
|
|
842
|
+
}
|
|
843
|
+
ctx.stroke();
|
|
844
|
+
ctx.restore();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function drawDragGhost(ctx, state, style, thumbs) {
|
|
848
|
+
const ghost = state.dragGhost;
|
|
849
|
+
let real = null;
|
|
850
|
+
for (const t of state.project.tracks) {
|
|
851
|
+
const c = t.clips.find((c2) => c2.id === ghost.clipId);
|
|
852
|
+
if (c) {
|
|
853
|
+
real = c;
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (!real) return;
|
|
858
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
859
|
+
const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
|
|
860
|
+
const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
861
|
+
const overlap = ghost.wouldOverlap;
|
|
862
|
+
drawDropOutline(
|
|
863
|
+
ctx,
|
|
864
|
+
startX,
|
|
865
|
+
overlap ? state.project.tracks.length : ghost.ghostTrackIndex,
|
|
866
|
+
widthPx,
|
|
867
|
+
style.info,
|
|
868
|
+
overlap
|
|
869
|
+
);
|
|
870
|
+
ctx.save();
|
|
871
|
+
ctx.globalAlpha = 0.85;
|
|
872
|
+
drawClipAt(
|
|
873
|
+
ctx,
|
|
874
|
+
real,
|
|
875
|
+
overlap ? state.project.tracks.length : ghost.ghostTrackIndex,
|
|
876
|
+
ghost.ghostStart,
|
|
877
|
+
state.project.sources,
|
|
878
|
+
state,
|
|
879
|
+
style,
|
|
880
|
+
thumbs,
|
|
881
|
+
/* dim = */
|
|
882
|
+
false,
|
|
883
|
+
/* warn = */
|
|
884
|
+
overlap
|
|
885
|
+
);
|
|
886
|
+
ctx.restore();
|
|
887
|
+
}
|
|
888
|
+
function drawDropOutline(ctx, startX, trackIndex, widthPx, color, emphasized) {
|
|
889
|
+
const y = trackY(trackIndex) + CLIP_INSET - 1;
|
|
890
|
+
const h = TRACK_HEIGHT - CLIP_INSET * 2 + 2;
|
|
891
|
+
ctx.save();
|
|
892
|
+
if (emphasized) {
|
|
893
|
+
ctx.shadowColor = withAlpha(color, 0.45);
|
|
894
|
+
ctx.shadowBlur = 6;
|
|
895
|
+
}
|
|
896
|
+
ctx.strokeStyle = withAlpha(color, emphasized ? 0.9 : 0.7);
|
|
897
|
+
ctx.lineWidth = 1;
|
|
898
|
+
roundRect(ctx, startX - 0.5, y, widthPx + 1, h, 6);
|
|
899
|
+
ctx.stroke();
|
|
900
|
+
ctx.restore();
|
|
901
|
+
}
|
|
902
|
+
function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
|
|
903
|
+
const y = trackY(trackIndex);
|
|
904
|
+
const w = state.viewportWidth - baseX;
|
|
905
|
+
ctx.save();
|
|
906
|
+
ctx.fillStyle = withAlpha(style.info, 0.04);
|
|
907
|
+
ctx.fillRect(baseX, y, w, TRACK_HEIGHT);
|
|
908
|
+
ctx.strokeStyle = withAlpha(style.info, 0.35);
|
|
909
|
+
ctx.lineWidth = 1;
|
|
910
|
+
ctx.setLineDash([3, 4]);
|
|
911
|
+
ctx.beginPath();
|
|
912
|
+
ctx.moveTo(baseX, y + 0.5);
|
|
913
|
+
ctx.lineTo(baseX + w, y + 0.5);
|
|
914
|
+
ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
|
|
915
|
+
ctx.lineTo(baseX + w, y + TRACK_HEIGHT - 0.5);
|
|
916
|
+
ctx.stroke();
|
|
917
|
+
ctx.setLineDash([]);
|
|
918
|
+
if (state.showHeader) {
|
|
919
|
+
ctx.fillStyle = withAlpha(style.info, 0.7);
|
|
920
|
+
ctx.font = "10px system-ui, -apple-system, sans-serif";
|
|
921
|
+
ctx.textBaseline = "middle";
|
|
922
|
+
ctx.fillText(state.locale.newTrack, 12, y + TRACK_HEIGHT / 2);
|
|
923
|
+
}
|
|
924
|
+
ctx.restore();
|
|
925
|
+
}
|
|
926
|
+
function drawRuler(ctx, state, style) {
|
|
927
|
+
const { pxPerSec, scrollLeft, viewportWidth: W } = state;
|
|
928
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
929
|
+
const rulerW = W - baseX;
|
|
930
|
+
ctx.fillStyle = style.bg;
|
|
931
|
+
ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
|
|
932
|
+
ctx.strokeStyle = style.border;
|
|
933
|
+
ctx.lineWidth = 1;
|
|
934
|
+
ctx.beginPath();
|
|
935
|
+
ctx.moveTo(baseX, RULER_HEIGHT - 0.5);
|
|
936
|
+
ctx.lineTo(W, RULER_HEIGHT - 0.5);
|
|
937
|
+
ctx.stroke();
|
|
938
|
+
const minPx = 80;
|
|
939
|
+
const tickSec = niceTickSeconds(minPx / pxPerSec);
|
|
940
|
+
const subSec = tickSec / 5;
|
|
941
|
+
const firstVisibleSec = Math.max(0, scrollLeft / pxPerSec - subSec);
|
|
942
|
+
const lastVisibleSec = (scrollLeft + rulerW) / pxPerSec + subSec;
|
|
943
|
+
ctx.textBaseline = "bottom";
|
|
944
|
+
ctx.font = "10px system-ui, -apple-system, sans-serif";
|
|
945
|
+
const startStep = Math.floor(firstVisibleSec / subSec);
|
|
946
|
+
const endStep = Math.ceil(lastVisibleSec / subSec);
|
|
947
|
+
for (let i = startStep; i <= endStep; i++) {
|
|
948
|
+
const s = i * subSec;
|
|
949
|
+
if (s < 0) continue;
|
|
950
|
+
const x = baseX + s * pxPerSec - scrollLeft;
|
|
951
|
+
if (x < baseX || x > W) continue;
|
|
952
|
+
const isMajor = Math.abs(s / tickSec % 1) < 1e-3;
|
|
953
|
+
ctx.strokeStyle = isMajor ? withAlpha(style.text, 0.5) : withAlpha(style.text, 0.25);
|
|
954
|
+
ctx.lineWidth = 1;
|
|
955
|
+
const h = isMajor ? 10 : 6;
|
|
956
|
+
ctx.beginPath();
|
|
957
|
+
ctx.moveTo(x + 0.5, RULER_HEIGHT - h);
|
|
958
|
+
ctx.lineTo(x + 0.5, RULER_HEIGHT - 1);
|
|
959
|
+
ctx.stroke();
|
|
960
|
+
if (isMajor) {
|
|
961
|
+
ctx.fillStyle = withAlpha(style.textMuted, 0.85);
|
|
962
|
+
ctx.fillText(formatRulerLabel(s), x + 3, RULER_HEIGHT - 12);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function drawTracks(ctx, state, style, thumbs) {
|
|
967
|
+
const { project } = state;
|
|
968
|
+
for (let ti = 0; ti < project.tracks.length; ti++) {
|
|
969
|
+
drawTrackRow(ctx, ti, project.tracks[ti], project.sources, state, style, thumbs);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
|
|
973
|
+
const { viewportWidth: W } = state;
|
|
974
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
975
|
+
const y = trackY(trackIndex);
|
|
976
|
+
ctx.fillStyle = style.trackBg;
|
|
977
|
+
ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
|
|
978
|
+
ctx.strokeStyle = style.border;
|
|
979
|
+
ctx.lineWidth = 1;
|
|
980
|
+
ctx.beginPath();
|
|
981
|
+
ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
|
|
982
|
+
ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
|
|
983
|
+
ctx.stroke();
|
|
984
|
+
if (state.dropTargetTrackIndex === trackIndex) {
|
|
985
|
+
ctx.strokeStyle = withAlpha(style.info, 0.45);
|
|
986
|
+
ctx.lineWidth = 1;
|
|
987
|
+
ctx.beginPath();
|
|
988
|
+
ctx.moveTo(baseX, y + 0.5);
|
|
989
|
+
ctx.lineTo(W, y + 0.5);
|
|
990
|
+
ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
|
|
991
|
+
ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
|
|
992
|
+
ctx.stroke();
|
|
993
|
+
}
|
|
994
|
+
for (const clip of track.clips) {
|
|
995
|
+
const dim = state.dragGhost?.clipId === clip.id;
|
|
996
|
+
drawClipAt(
|
|
997
|
+
ctx,
|
|
998
|
+
clip,
|
|
999
|
+
trackIndex,
|
|
1000
|
+
clip.start,
|
|
1001
|
+
sources,
|
|
1002
|
+
state,
|
|
1003
|
+
style,
|
|
1004
|
+
thumbs,
|
|
1005
|
+
dim,
|
|
1006
|
+
false
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
|
|
1011
|
+
const { pxPerSec, scrollLeft } = state;
|
|
1012
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
1013
|
+
const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
|
|
1014
|
+
const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
|
|
1015
|
+
const y = trackY(trackIndex) + CLIP_INSET;
|
|
1016
|
+
const h = TRACK_HEIGHT - CLIP_INSET * 2;
|
|
1017
|
+
if (startX + widthPx < baseX || startX > state.viewportWidth) return;
|
|
1018
|
+
ctx.save();
|
|
1019
|
+
if (dim) ctx.globalAlpha = 0.3;
|
|
1020
|
+
const grad = ctx.createLinearGradient(0, y, 0, y + h);
|
|
1021
|
+
if (warn) {
|
|
1022
|
+
grad.addColorStop(0, "rgba(250, 175, 70, 0.7)");
|
|
1023
|
+
grad.addColorStop(1, "rgba(240, 145, 50, 0.62)");
|
|
1024
|
+
} else {
|
|
1025
|
+
grad.addColorStop(0, withAlpha(style.brand, 0.8));
|
|
1026
|
+
grad.addColorStop(1, withAlpha(style.brandTo, 0.7));
|
|
1027
|
+
}
|
|
1028
|
+
ctx.fillStyle = grad;
|
|
1029
|
+
roundRect(ctx, startX, y, widthPx, h, 6);
|
|
1030
|
+
ctx.fill();
|
|
1031
|
+
ctx.save();
|
|
1032
|
+
roundRect(ctx, startX, y, widthPx, h, 6);
|
|
1033
|
+
ctx.clip();
|
|
1034
|
+
ctx.translate(startX, y);
|
|
1035
|
+
thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
|
|
1036
|
+
ctx.restore();
|
|
1037
|
+
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
|
1038
|
+
ctx.lineWidth = 1;
|
|
1039
|
+
roundRect(ctx, startX + 0.5, y + 0.5, widthPx - 1, h - 1, 6);
|
|
1040
|
+
ctx.stroke();
|
|
1041
|
+
const src = sources.find((s) => s.id === clip.sourceId);
|
|
1042
|
+
const label = src?.name ?? src?.url.split("/").pop() ?? clip.id;
|
|
1043
|
+
ctx.font = "11px system-ui, -apple-system, sans-serif";
|
|
1044
|
+
ctx.textBaseline = "top";
|
|
1045
|
+
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
|
1046
|
+
ctx.fillText(label, startX + 9, y + 5);
|
|
1047
|
+
ctx.fillStyle = "#fff";
|
|
1048
|
+
ctx.fillText(label, startX + 8, y + 4);
|
|
1049
|
+
if (!dim && state.selectedClipId === clip.id) {
|
|
1050
|
+
ctx.strokeStyle = style.selectedRing;
|
|
1051
|
+
ctx.lineWidth = 2;
|
|
1052
|
+
roundRect(ctx, startX - 1, y - 1, widthPx + 2, h + 2, 7);
|
|
1053
|
+
ctx.stroke();
|
|
1054
|
+
}
|
|
1055
|
+
const showHandles = !dim && (state.selectedClipId === clip.id || state.hoveredClipId === clip.id);
|
|
1056
|
+
if (showHandles) {
|
|
1057
|
+
ctx.fillStyle = "rgba(255,255,255,0.75)";
|
|
1058
|
+
ctx.fillRect(startX + 2, y + 12, 2, h - 24);
|
|
1059
|
+
ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
|
|
1060
|
+
}
|
|
1061
|
+
ctx.restore();
|
|
1062
|
+
}
|
|
1063
|
+
function drawHeaders(ctx, state, style) {
|
|
1064
|
+
ctx.fillStyle = style.bg;
|
|
1065
|
+
ctx.fillRect(0, 0, HEADER_WIDTH, state.viewportHeight);
|
|
1066
|
+
ctx.strokeStyle = style.border;
|
|
1067
|
+
ctx.lineWidth = 1;
|
|
1068
|
+
ctx.beginPath();
|
|
1069
|
+
ctx.moveTo(HEADER_WIDTH - 0.5, 0);
|
|
1070
|
+
ctx.lineTo(HEADER_WIDTH - 0.5, state.viewportHeight);
|
|
1071
|
+
ctx.stroke();
|
|
1072
|
+
ctx.textBaseline = "middle";
|
|
1073
|
+
ctx.font = "11px system-ui, -apple-system, sans-serif";
|
|
1074
|
+
for (let i = 0; i < state.project.tracks.length; i++) {
|
|
1075
|
+
const t = state.project.tracks[i];
|
|
1076
|
+
const y = trackY(i);
|
|
1077
|
+
ctx.strokeStyle = style.border;
|
|
1078
|
+
ctx.beginPath();
|
|
1079
|
+
ctx.moveTo(0, y + TRACK_HEIGHT - 0.5);
|
|
1080
|
+
ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT - 0.5);
|
|
1081
|
+
ctx.stroke();
|
|
1082
|
+
ctx.fillStyle = withAlpha(style.text, 0.7);
|
|
1083
|
+
const template = t.kind === "video" ? state.locale.videoTrackLabel : state.locale.audioTrackLabel;
|
|
1084
|
+
const label = formatLabel(template, { n: i + 1 });
|
|
1085
|
+
ctx.fillText(label, 12, y + TRACK_HEIGHT / 2);
|
|
1086
|
+
if (t.clips.length === 0) {
|
|
1087
|
+
const hovered = state.hoveredTrackIndex === i;
|
|
1088
|
+
const btnSize = 18;
|
|
1089
|
+
const btnLeft = HEADER_WIDTH - btnSize - 6;
|
|
1090
|
+
const btnTop = y + (TRACK_HEIGHT - btnSize) / 2;
|
|
1091
|
+
ctx.save();
|
|
1092
|
+
if (hovered) {
|
|
1093
|
+
ctx.fillStyle = withAlpha(style.text, 0.1);
|
|
1094
|
+
roundRect(ctx, btnLeft, btnTop, btnSize, btnSize, 5);
|
|
1095
|
+
ctx.fill();
|
|
1096
|
+
}
|
|
1097
|
+
ctx.strokeStyle = withAlpha(style.text, hovered ? 0.85 : 0.4);
|
|
1098
|
+
ctx.lineWidth = 1.4;
|
|
1099
|
+
const pad = 5;
|
|
1100
|
+
ctx.beginPath();
|
|
1101
|
+
ctx.moveTo(btnLeft + pad, btnTop + pad);
|
|
1102
|
+
ctx.lineTo(btnLeft + btnSize - pad, btnTop + btnSize - pad);
|
|
1103
|
+
ctx.moveTo(btnLeft + btnSize - pad, btnTop + pad);
|
|
1104
|
+
ctx.lineTo(btnLeft + pad, btnTop + btnSize - pad);
|
|
1105
|
+
ctx.stroke();
|
|
1106
|
+
ctx.restore();
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
function drawPlayhead(ctx, state, style) {
|
|
1111
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
1112
|
+
const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
|
|
1113
|
+
if (x < baseX - 2 || x > state.viewportWidth + 2) return;
|
|
1114
|
+
ctx.strokeStyle = style.playhead;
|
|
1115
|
+
ctx.lineWidth = 2;
|
|
1116
|
+
ctx.beginPath();
|
|
1117
|
+
ctx.moveTo(x, 0);
|
|
1118
|
+
ctx.lineTo(x, state.viewportHeight);
|
|
1119
|
+
ctx.stroke();
|
|
1120
|
+
const label = fmtClockMs(state.timeMs);
|
|
1121
|
+
ctx.font = "10px system-ui, -apple-system, sans-serif";
|
|
1122
|
+
const padX = 6;
|
|
1123
|
+
const w = ctx.measureText(label).width + padX * 2;
|
|
1124
|
+
const h = 14;
|
|
1125
|
+
const bx = x - w / 2;
|
|
1126
|
+
const by = 2;
|
|
1127
|
+
ctx.fillStyle = style.playhead;
|
|
1128
|
+
roundRect(ctx, bx, by, w, h, 4);
|
|
1129
|
+
ctx.fill();
|
|
1130
|
+
ctx.beginPath();
|
|
1131
|
+
ctx.moveTo(x - 4, by + h);
|
|
1132
|
+
ctx.lineTo(x + 4, by + h);
|
|
1133
|
+
ctx.lineTo(x, by + h + 4);
|
|
1134
|
+
ctx.closePath();
|
|
1135
|
+
ctx.fill();
|
|
1136
|
+
ctx.fillStyle = "#fff";
|
|
1137
|
+
ctx.textBaseline = "middle";
|
|
1138
|
+
ctx.fillText(label, bx + padX, by + h / 2);
|
|
1139
|
+
}
|
|
1140
|
+
function drawSnapGuide(ctx, state, style) {
|
|
1141
|
+
if (state.snapX == null) return;
|
|
1142
|
+
ctx.strokeStyle = style.info;
|
|
1143
|
+
ctx.lineWidth = 1;
|
|
1144
|
+
ctx.setLineDash([3, 3]);
|
|
1145
|
+
ctx.beginPath();
|
|
1146
|
+
ctx.moveTo(state.snapX + 0.5, 0);
|
|
1147
|
+
ctx.lineTo(state.snapX + 0.5, state.viewportHeight);
|
|
1148
|
+
ctx.stroke();
|
|
1149
|
+
ctx.setLineDash([]);
|
|
1150
|
+
}
|
|
1151
|
+
function drawScrollbarV(ctx, state, style) {
|
|
1152
|
+
if (state.scrollbarOpacityY <= 0.01) return;
|
|
1153
|
+
const visibleH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1154
|
+
const contentH = contentHeight(state.project.tracks, state.isDragging);
|
|
1155
|
+
if (contentH <= visibleH) return;
|
|
1156
|
+
const trackX = state.viewportWidth - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
|
|
1157
|
+
const trackY0 = RULER_HEIGHT + SCROLLBAR_INSET;
|
|
1158
|
+
const trackLen = visibleH - SCROLLBAR_INSET * 2;
|
|
1159
|
+
const thumbLen = Math.max(
|
|
1160
|
+
SCROLLBAR_MIN_THUMB,
|
|
1161
|
+
trackLen * (visibleH / contentH)
|
|
1162
|
+
);
|
|
1163
|
+
const maxScroll = contentH - visibleH;
|
|
1164
|
+
const thumbY = trackY0 + (maxScroll > 0 ? state.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
|
|
1165
|
+
paintScrollbar(
|
|
1166
|
+
ctx,
|
|
1167
|
+
style,
|
|
1168
|
+
trackX,
|
|
1169
|
+
trackY0,
|
|
1170
|
+
SCROLLBAR_THICKNESS - SCROLLBAR_INSET * 2,
|
|
1171
|
+
trackLen,
|
|
1172
|
+
thumbY - trackY0,
|
|
1173
|
+
thumbLen,
|
|
1174
|
+
state.scrollbarOpacityY,
|
|
1175
|
+
state.scrollbarActiveY,
|
|
1176
|
+
"v"
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
function drawScrollbarH(ctx, state, style) {
|
|
1180
|
+
if (state.scrollbarOpacityX <= 0.01) return;
|
|
1181
|
+
const baseX = state.showHeader ? HEADER_WIDTH : 0;
|
|
1182
|
+
const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1183
|
+
const contentW = contentWidth(state.project, state.pxPerSec);
|
|
1184
|
+
if (contentW <= visibleW) return;
|
|
1185
|
+
const trackY0 = state.viewportHeight - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
|
|
1186
|
+
const trackX0 = baseX + SCROLLBAR_INSET;
|
|
1187
|
+
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
1188
|
+
const thumbLen = Math.max(
|
|
1189
|
+
SCROLLBAR_MIN_THUMB,
|
|
1190
|
+
trackLen * (visibleW / contentW)
|
|
1191
|
+
);
|
|
1192
|
+
const maxScroll = contentW - visibleW;
|
|
1193
|
+
const thumbX = trackX0 + (maxScroll > 0 ? state.scrollLeft / maxScroll * (trackLen - thumbLen) : 0);
|
|
1194
|
+
paintScrollbar(
|
|
1195
|
+
ctx,
|
|
1196
|
+
style,
|
|
1197
|
+
trackX0,
|
|
1198
|
+
trackY0,
|
|
1199
|
+
trackLen,
|
|
1200
|
+
SCROLLBAR_THICKNESS - SCROLLBAR_INSET * 2,
|
|
1201
|
+
thumbX - trackX0,
|
|
1202
|
+
thumbLen,
|
|
1203
|
+
state.scrollbarOpacityX,
|
|
1204
|
+
state.scrollbarActiveX,
|
|
1205
|
+
"h"
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
function paintScrollbar(ctx, style, trackX, trackY0, trackW, trackH, thumbOffset, thumbLen, opacity, active, axis) {
|
|
1209
|
+
ctx.save();
|
|
1210
|
+
ctx.globalAlpha = opacity;
|
|
1211
|
+
ctx.fillStyle = withAlpha(style.text, 0.06);
|
|
1212
|
+
roundRect(ctx, trackX, trackY0, trackW, trackH, Math.min(trackW, trackH) / 2);
|
|
1213
|
+
ctx.fill();
|
|
1214
|
+
ctx.fillStyle = active ? withAlpha(style.info, 0.85) : withAlpha(style.text, 0.4);
|
|
1215
|
+
if (axis === "v") {
|
|
1216
|
+
roundRect(ctx, trackX, trackY0 + thumbOffset, trackW, thumbLen, trackW / 2);
|
|
1217
|
+
} else {
|
|
1218
|
+
roundRect(ctx, trackX + thumbOffset, trackY0, thumbLen, trackH, trackH / 2);
|
|
1219
|
+
}
|
|
1220
|
+
ctx.fill();
|
|
1221
|
+
ctx.restore();
|
|
1222
|
+
}
|
|
1223
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
1224
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
1225
|
+
ctx.beginPath();
|
|
1226
|
+
ctx.moveTo(x + rr, y);
|
|
1227
|
+
ctx.lineTo(x + w - rr, y);
|
|
1228
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
|
|
1229
|
+
ctx.lineTo(x + w, y + h - rr);
|
|
1230
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
|
|
1231
|
+
ctx.lineTo(x + rr, y + h);
|
|
1232
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
|
|
1233
|
+
ctx.lineTo(x, y + rr);
|
|
1234
|
+
ctx.quadraticCurveTo(x, y, x + rr, y);
|
|
1235
|
+
ctx.closePath();
|
|
1236
|
+
}
|
|
1237
|
+
function withAlpha(color, alpha) {
|
|
1238
|
+
const c = parseColor(color);
|
|
1239
|
+
if (!c) return color;
|
|
1240
|
+
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
|
|
1241
|
+
}
|
|
1242
|
+
function parseColor(s) {
|
|
1243
|
+
if (s.startsWith("#")) {
|
|
1244
|
+
const hex = s.slice(1);
|
|
1245
|
+
if (hex.length === 3) {
|
|
1246
|
+
return [
|
|
1247
|
+
parseInt(hex[0] + hex[0], 16),
|
|
1248
|
+
parseInt(hex[1] + hex[1], 16),
|
|
1249
|
+
parseInt(hex[2] + hex[2], 16)
|
|
1250
|
+
];
|
|
1251
|
+
}
|
|
1252
|
+
if (hex.length === 6) {
|
|
1253
|
+
return [
|
|
1254
|
+
parseInt(hex.slice(0, 2), 16),
|
|
1255
|
+
parseInt(hex.slice(2, 4), 16),
|
|
1256
|
+
parseInt(hex.slice(4, 6), 16)
|
|
1257
|
+
];
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
1261
|
+
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/timeline/hit.ts
|
|
1266
|
+
function hitTest(x, y, ctx) {
|
|
1267
|
+
if (y < 0 || x < 0) return { kind: "outside" };
|
|
1268
|
+
const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
|
|
1269
|
+
const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1270
|
+
const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
|
|
1271
|
+
if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
|
|
1272
|
+
const trackLen = visibleH - SCROLLBAR_INSET * 2;
|
|
1273
|
+
const thumbLen = Math.max(
|
|
1274
|
+
SCROLLBAR_MIN_THUMB,
|
|
1275
|
+
trackLen * (visibleH / contentH)
|
|
1276
|
+
);
|
|
1277
|
+
const maxScroll = contentH - visibleH;
|
|
1278
|
+
const thumbY = RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
|
|
1279
|
+
if (y >= thumbY && y <= thumbY + thumbLen) {
|
|
1280
|
+
return { kind: "scrollbar-thumb-v", thumbY, thumbLen };
|
|
1281
|
+
}
|
|
1282
|
+
return { kind: "scrollbar-track-v", before: y < thumbY };
|
|
1283
|
+
}
|
|
1284
|
+
const visibleW = ctx.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1285
|
+
const contentW = contentWidth(ctx.project, ctx.pxPerSec);
|
|
1286
|
+
if (contentW > visibleW && y >= ctx.viewportHeight - SCROLLBAR_THICKNESS && y < ctx.viewportHeight && x >= baseX && x < ctx.viewportWidth - SCROLLBAR_THICKNESS) {
|
|
1287
|
+
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
1288
|
+
const thumbLen = Math.max(
|
|
1289
|
+
SCROLLBAR_MIN_THUMB,
|
|
1290
|
+
trackLen * (visibleW / contentW)
|
|
1291
|
+
);
|
|
1292
|
+
const maxScroll = contentW - visibleW;
|
|
1293
|
+
const thumbX = baseX + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollLeft / maxScroll * (trackLen - thumbLen) : 0);
|
|
1294
|
+
if (x >= thumbX && x <= thumbX + thumbLen) {
|
|
1295
|
+
return { kind: "scrollbar-thumb-h", thumbX, thumbLen };
|
|
1296
|
+
}
|
|
1297
|
+
return { kind: "scrollbar-track-h", before: x < thumbX };
|
|
1298
|
+
}
|
|
1299
|
+
if (ctx.showHeader && x < HEADER_WIDTH && y >= RULER_HEIGHT) {
|
|
1300
|
+
const ti2 = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
|
|
1301
|
+
if (ti2 >= 0) {
|
|
1302
|
+
const track2 = ctx.project.tracks[ti2];
|
|
1303
|
+
if (track2.clips.length === 0) {
|
|
1304
|
+
const btnSize = 18;
|
|
1305
|
+
const btnLeft = HEADER_WIDTH - btnSize - 6;
|
|
1306
|
+
const btnTop = RULER_HEIGHT + ti2 * 56 + (56 - btnSize) / 2 - ctx.scrollTop;
|
|
1307
|
+
if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
|
|
1308
|
+
return { kind: "header-delete", trackIndex: ti2 };
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return { kind: "header", trackIndex: ti2 };
|
|
1312
|
+
}
|
|
1313
|
+
return { kind: "outside" };
|
|
1314
|
+
}
|
|
1315
|
+
if (y < RULER_HEIGHT) return { kind: "ruler" };
|
|
1316
|
+
const ti = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
|
|
1317
|
+
if (ti < 0) return { kind: "outside" };
|
|
1318
|
+
const track = ctx.project.tracks[ti];
|
|
1319
|
+
const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
|
|
1320
|
+
for (const clip of track.clips) {
|
|
1321
|
+
const start = clip.start;
|
|
1322
|
+
const end = clip.start + (clip.out - clip.in);
|
|
1323
|
+
const startX = msToXLocal(start, ctx);
|
|
1324
|
+
const endX = msToXLocal(end, ctx);
|
|
1325
|
+
if (x >= startX - HANDLE_PX && x <= startX + HANDLE_PX) {
|
|
1326
|
+
return { kind: "clip-handle-left", trackIndex: ti, clipId: clip.id };
|
|
1327
|
+
}
|
|
1328
|
+
if (x >= endX - HANDLE_PX && x <= endX + HANDLE_PX) {
|
|
1329
|
+
return { kind: "clip-handle-right", trackIndex: ti, clipId: clip.id };
|
|
1330
|
+
}
|
|
1331
|
+
if (ms >= start && ms < end) {
|
|
1332
|
+
return { kind: "clip", trackIndex: ti, clipId: clip.id };
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return { kind: "track-empty", trackIndex: ti };
|
|
1336
|
+
}
|
|
1337
|
+
function msToXLocal(ms, ctx) {
|
|
1338
|
+
const base = ctx.showHeader ? HEADER_WIDTH : 0;
|
|
1339
|
+
return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// src/timeline/index.ts
|
|
1343
|
+
var SNAP_PX = 8;
|
|
1344
|
+
var DEFAULT_SCALE = 80;
|
|
1345
|
+
var WHEEL_ZOOM_RATE = 0.012;
|
|
1346
|
+
var SCROLLBAR_FADE_HOLD_MS = 800;
|
|
1347
|
+
var SCROLLBAR_FADE_OUT_MS = 400;
|
|
1348
|
+
var Timeline = class _Timeline {
|
|
1349
|
+
root;
|
|
1350
|
+
opts;
|
|
1351
|
+
canvas;
|
|
1352
|
+
ctx;
|
|
1353
|
+
thumbs;
|
|
1354
|
+
hiddenHost;
|
|
1355
|
+
toolbarEl = null;
|
|
1356
|
+
/**
|
|
1357
|
+
* Public flex slot at the left of the top toolbar. `null` when
|
|
1358
|
+
* `toolbar` is disabled. Hosts append their own elements (e.g. a
|
|
1359
|
+
* size/aspect dropdown). React/Vue wrappers portal children here.
|
|
1360
|
+
*/
|
|
1361
|
+
toolbarLeft = null;
|
|
1362
|
+
/** Right-side counterpart — conventionally used for export/save. */
|
|
1363
|
+
toolbarRight = null;
|
|
1364
|
+
project;
|
|
1365
|
+
pxPerSec;
|
|
1366
|
+
timeMs;
|
|
1367
|
+
selectedClipId;
|
|
1368
|
+
snapEnabled;
|
|
1369
|
+
showHeader;
|
|
1370
|
+
readOnly;
|
|
1371
|
+
autoFitEnabled;
|
|
1372
|
+
locale;
|
|
1373
|
+
scrollLeft = 0;
|
|
1374
|
+
scrollTop = 0;
|
|
1375
|
+
viewportWidth = 0;
|
|
1376
|
+
viewportHeight = 0;
|
|
1377
|
+
/**
|
|
1378
|
+
* `Date.now()` of the last interaction with each scrollbar (scroll
|
|
1379
|
+
* change OR hover OR drag). Drives the macOS-style fade — bars are
|
|
1380
|
+
* fully opaque for SCROLLBAR_FADE_HOLD_MS after activity, then ease
|
|
1381
|
+
* out over the next SCROLLBAR_FADE_OUT_MS.
|
|
1382
|
+
*/
|
|
1383
|
+
lastScrollInteractY = 0;
|
|
1384
|
+
lastScrollInteractX = 0;
|
|
1385
|
+
hoverScrollbarY = false;
|
|
1386
|
+
hoverScrollbarX = false;
|
|
1387
|
+
/** When set, pointer is dragging a scrollbar thumb. */
|
|
1388
|
+
scrollbarDrag = null;
|
|
1389
|
+
hoveredClipId = null;
|
|
1390
|
+
hoveredTrackIndex = null;
|
|
1391
|
+
hoverCursor = "default";
|
|
1392
|
+
dropTargetTrackIndex = null;
|
|
1393
|
+
snapX = null;
|
|
1394
|
+
drag = null;
|
|
1395
|
+
/**
|
|
1396
|
+
* In-flight ghost of the clip being dragged. Decoupled from the
|
|
1397
|
+
* project data so the data stays clean and undo-able only commits
|
|
1398
|
+
* on release. Has both the proposed `start` (X) and `trackIndex`
|
|
1399
|
+
* (Y), so the rendered ghost follows the cursor across tracks.
|
|
1400
|
+
*/
|
|
1401
|
+
dragGhost = null;
|
|
1402
|
+
/**
|
|
1403
|
+
* Most recent local pointer coords during a move drag — used by the
|
|
1404
|
+
* edge-autoscroll loop to re-run drop-target resolution between
|
|
1405
|
+
* pointermove events while scrollTop ticks under a stationary cursor.
|
|
1406
|
+
*/
|
|
1407
|
+
lastDragPointerX = 0;
|
|
1408
|
+
lastDragPointerY = 0;
|
|
1409
|
+
dragScrollRafPending = false;
|
|
1410
|
+
rafPending = false;
|
|
1411
|
+
hasAutoFitted = false;
|
|
1412
|
+
resizeObs = null;
|
|
1413
|
+
destroyed = false;
|
|
1414
|
+
static create(opts) {
|
|
1415
|
+
return new _Timeline(opts);
|
|
1416
|
+
}
|
|
1417
|
+
constructor(opts) {
|
|
1418
|
+
this.opts = opts;
|
|
1419
|
+
this.root = opts.container;
|
|
1420
|
+
this.project = normalizeProject(opts.project);
|
|
1421
|
+
this.pxPerSec = clampScale(opts.pxPerSec ?? DEFAULT_SCALE);
|
|
1422
|
+
this.timeMs = opts.time ?? 0;
|
|
1423
|
+
this.selectedClipId = opts.selectedClipId ?? null;
|
|
1424
|
+
this.snapEnabled = opts.snap !== false;
|
|
1425
|
+
this.showHeader = opts.showHeader !== false;
|
|
1426
|
+
this.readOnly = opts.readOnly === true;
|
|
1427
|
+
this.autoFitEnabled = opts.autoFit !== false;
|
|
1428
|
+
this.locale = mergeLocale(opts.locale);
|
|
1429
|
+
this.root.classList.add("aicut-timeline-canvas");
|
|
1430
|
+
this.root.innerHTML = "";
|
|
1431
|
+
this.root.style.position = this.root.style.position || "relative";
|
|
1432
|
+
if (opts.toolbar) {
|
|
1433
|
+
this.root.style.display = "flex";
|
|
1434
|
+
this.root.style.flexDirection = "column";
|
|
1435
|
+
const bar = document.createElement("div");
|
|
1436
|
+
bar.className = "aicut-timeline-toolbar";
|
|
1437
|
+
const left = document.createElement("div");
|
|
1438
|
+
left.className = "aicut-timeline-toolbar-left";
|
|
1439
|
+
const right = document.createElement("div");
|
|
1440
|
+
right.className = "aicut-timeline-toolbar-right";
|
|
1441
|
+
bar.appendChild(left);
|
|
1442
|
+
bar.appendChild(right);
|
|
1443
|
+
this.root.appendChild(bar);
|
|
1444
|
+
this.toolbarEl = bar;
|
|
1445
|
+
this.toolbarLeft = left;
|
|
1446
|
+
this.toolbarRight = right;
|
|
1447
|
+
}
|
|
1448
|
+
this.canvas = document.createElement("canvas");
|
|
1449
|
+
this.canvas.style.display = "block";
|
|
1450
|
+
this.canvas.style.width = "100%";
|
|
1451
|
+
if (opts.toolbar) {
|
|
1452
|
+
this.canvas.style.flex = "1 1 0";
|
|
1453
|
+
this.canvas.style.minHeight = "0";
|
|
1454
|
+
} else {
|
|
1455
|
+
this.canvas.style.height = "100%";
|
|
1456
|
+
}
|
|
1457
|
+
this.canvas.style.touchAction = "none";
|
|
1458
|
+
this.root.appendChild(this.canvas);
|
|
1459
|
+
const ctx = this.canvas.getContext("2d");
|
|
1460
|
+
if (!ctx) throw new Error("2d context unavailable");
|
|
1461
|
+
this.ctx = ctx;
|
|
1462
|
+
this.hiddenHost = document.createElement("div");
|
|
1463
|
+
this.hiddenHost.style.position = "absolute";
|
|
1464
|
+
this.hiddenHost.style.overflow = "hidden";
|
|
1465
|
+
this.hiddenHost.style.width = "0";
|
|
1466
|
+
this.hiddenHost.style.height = "0";
|
|
1467
|
+
this.hiddenHost.style.pointerEvents = "none";
|
|
1468
|
+
this.root.appendChild(this.hiddenHost);
|
|
1469
|
+
this.thumbs = new ThumbnailRibbon(
|
|
1470
|
+
this.hiddenHost,
|
|
1471
|
+
() => this.scheduleRender()
|
|
1472
|
+
);
|
|
1473
|
+
this.thumbs.syncSources(this.project.sources);
|
|
1474
|
+
this.attachPointer();
|
|
1475
|
+
this.attachWheel();
|
|
1476
|
+
this.attachResize();
|
|
1477
|
+
this.resizeCanvas();
|
|
1478
|
+
this.scheduleRender();
|
|
1479
|
+
}
|
|
1480
|
+
// ---- public API -----------------------------------------------------
|
|
1481
|
+
/**
|
|
1482
|
+
* Sync the project data. Does NOT reset the auto-fit latch — that's
|
|
1483
|
+
* what caused the editor-side zoom feedback loop: every Editor
|
|
1484
|
+
* mutation called `ui.render() → timeline.setProject()` which used
|
|
1485
|
+
* to reset auto-fit, refit on the next frame, emit a new scale,
|
|
1486
|
+
* which re-rendered… and round we went. Callers that genuinely
|
|
1487
|
+
* want a re-fit (e.g. when the host swaps to a brand-new project)
|
|
1488
|
+
* should call `refit()` explicitly.
|
|
1489
|
+
*/
|
|
1490
|
+
setProject(p) {
|
|
1491
|
+
this.project = normalizeProject(p);
|
|
1492
|
+
this.thumbs.syncSources(this.project.sources);
|
|
1493
|
+
this.scheduleRender();
|
|
1494
|
+
}
|
|
1495
|
+
/** Force a re-fit on the next render. */
|
|
1496
|
+
refit() {
|
|
1497
|
+
if (!this.autoFitEnabled) return;
|
|
1498
|
+
this.hasAutoFitted = false;
|
|
1499
|
+
this.scheduleRender();
|
|
1500
|
+
}
|
|
1501
|
+
getProject() {
|
|
1502
|
+
return JSON.parse(JSON.stringify(this.project));
|
|
1503
|
+
}
|
|
1504
|
+
setTime(timeMs) {
|
|
1505
|
+
this.timeMs = Math.max(0, timeMs);
|
|
1506
|
+
this.scheduleRender();
|
|
1507
|
+
}
|
|
1508
|
+
getTime() {
|
|
1509
|
+
return this.timeMs;
|
|
1510
|
+
}
|
|
1511
|
+
setScale(pxPerSec) {
|
|
1512
|
+
const next = clampScale(pxPerSec);
|
|
1513
|
+
if (next === this.pxPerSec) return;
|
|
1514
|
+
this.pxPerSec = next;
|
|
1515
|
+
this.hasAutoFitted = true;
|
|
1516
|
+
this.scheduleRender();
|
|
1517
|
+
}
|
|
1518
|
+
getScale() {
|
|
1519
|
+
return this.pxPerSec;
|
|
1520
|
+
}
|
|
1521
|
+
setSelection(id) {
|
|
1522
|
+
if (id === this.selectedClipId) return;
|
|
1523
|
+
this.selectedClipId = id;
|
|
1524
|
+
this.scheduleRender();
|
|
1525
|
+
}
|
|
1526
|
+
getSelection() {
|
|
1527
|
+
return this.selectedClipId;
|
|
1528
|
+
}
|
|
1529
|
+
setSnap(snap) {
|
|
1530
|
+
this.snapEnabled = snap;
|
|
1531
|
+
}
|
|
1532
|
+
getSnap() {
|
|
1533
|
+
return this.snapEnabled;
|
|
1534
|
+
}
|
|
1535
|
+
setLocale(locale) {
|
|
1536
|
+
this.locale = mergeLocale(locale);
|
|
1537
|
+
this.scheduleRender();
|
|
1538
|
+
}
|
|
1539
|
+
/** Fit the project's full duration into the current viewport width. */
|
|
1540
|
+
fitToWindow() {
|
|
1541
|
+
const fit = this.computeFitScale();
|
|
1542
|
+
if (fit == null) return;
|
|
1543
|
+
this.pxPerSec = fit;
|
|
1544
|
+
this.hasAutoFitted = true;
|
|
1545
|
+
this.scrollLeft = 0;
|
|
1546
|
+
this.opts.onScaleChange?.(this.pxPerSec);
|
|
1547
|
+
this.scheduleRender();
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Test/debug introspection — pixel coordinates of every visible clip,
|
|
1551
|
+
* the playhead, and the headers. Because clips are canvas-painted
|
|
1552
|
+
* there are no DOM nodes to query in e2e; tests use this instead.
|
|
1553
|
+
* Exposed publicly so React/Vue wrappers can forward it to a ref.
|
|
1554
|
+
*/
|
|
1555
|
+
getDebugInfo() {
|
|
1556
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
1557
|
+
const clips = [];
|
|
1558
|
+
for (let ti = 0; ti < this.project.tracks.length; ti++) {
|
|
1559
|
+
const t = this.project.tracks[ti];
|
|
1560
|
+
for (const c of t.clips) {
|
|
1561
|
+
const x = baseX + c.start / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
1562
|
+
const width = (c.out - c.in) / 1e3 * this.pxPerSec;
|
|
1563
|
+
const y = RULER_HEIGHT + ti * TRACK_HEIGHT + 6;
|
|
1564
|
+
clips.push({
|
|
1565
|
+
id: c.id,
|
|
1566
|
+
trackIndex: ti,
|
|
1567
|
+
x,
|
|
1568
|
+
width,
|
|
1569
|
+
y,
|
|
1570
|
+
height: TRACK_HEIGHT - 12
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return {
|
|
1575
|
+
pxPerSec: this.pxPerSec,
|
|
1576
|
+
scrollLeft: this.scrollLeft,
|
|
1577
|
+
viewportWidth: this.viewportWidth,
|
|
1578
|
+
viewportHeight: this.viewportHeight,
|
|
1579
|
+
playheadX: baseX + this.timeMs / 1e3 * this.pxPerSec - this.scrollLeft,
|
|
1580
|
+
clips
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
destroy() {
|
|
1584
|
+
if (this.destroyed) return;
|
|
1585
|
+
this.destroyed = true;
|
|
1586
|
+
this.resizeObs?.disconnect();
|
|
1587
|
+
this.thumbs.destroy();
|
|
1588
|
+
this.root.innerHTML = "";
|
|
1589
|
+
this.root.classList.remove("aicut-timeline-canvas");
|
|
1590
|
+
if (this.opts.toolbar) {
|
|
1591
|
+
this.root.style.display = "";
|
|
1592
|
+
this.root.style.flexDirection = "";
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
// ---- size / layout --------------------------------------------------
|
|
1596
|
+
resizeCanvas() {
|
|
1597
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1598
|
+
this.viewportWidth = Math.max(1, Math.floor(rect.width));
|
|
1599
|
+
this.viewportHeight = Math.max(
|
|
1600
|
+
Math.floor(rect.height) || RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS,
|
|
1601
|
+
RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS
|
|
1602
|
+
);
|
|
1603
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1604
|
+
this.canvas.width = Math.floor(this.viewportWidth * dpr);
|
|
1605
|
+
this.canvas.height = Math.floor(this.viewportHeight * dpr);
|
|
1606
|
+
this.canvas.style.height = `${this.viewportHeight}px`;
|
|
1607
|
+
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1608
|
+
}
|
|
1609
|
+
computeFitScale() {
|
|
1610
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
1611
|
+
const w = this.viewportWidth - baseX - 24;
|
|
1612
|
+
const dur = projectDuration(this.project);
|
|
1613
|
+
if (w <= 0 || dur <= 0) return null;
|
|
1614
|
+
return clampScale(w / (dur / 1e3));
|
|
1615
|
+
}
|
|
1616
|
+
maxScrollLeft() {
|
|
1617
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
1618
|
+
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1619
|
+
const cw = contentWidth(this.project, this.pxPerSec);
|
|
1620
|
+
return Math.max(0, cw - visibleW + 24);
|
|
1621
|
+
}
|
|
1622
|
+
maxScrollTop() {
|
|
1623
|
+
const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1624
|
+
const ch = contentHeight(this.project.tracks, this.drag?.kind === "move");
|
|
1625
|
+
return Math.max(0, ch - visibleH);
|
|
1626
|
+
}
|
|
1627
|
+
clampScroll() {
|
|
1628
|
+
this.scrollLeft = Math.max(0, Math.min(this.scrollLeft, this.maxScrollLeft()));
|
|
1629
|
+
this.scrollTop = Math.max(0, Math.min(this.scrollTop, this.maxScrollTop()));
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Scrollbar opacity = full for SCROLLBAR_FADE_HOLD_MS after last
|
|
1633
|
+
* interaction, then linearly fades to 0 over SCROLLBAR_FADE_OUT_MS.
|
|
1634
|
+
* Hovering or actively dragging the bar pins opacity at 1. Returns
|
|
1635
|
+
* 0 if the bar isn't needed (content fits).
|
|
1636
|
+
*/
|
|
1637
|
+
scrollbarOpacity(axis) {
|
|
1638
|
+
if (axis === "v" && this.maxScrollTop() <= 0) return 0;
|
|
1639
|
+
if (axis === "h" && this.maxScrollLeft() <= 0) return 0;
|
|
1640
|
+
if (axis === "v" && (this.hoverScrollbarY || this.scrollbarDrag?.axis === "v") || axis === "h" && (this.hoverScrollbarX || this.scrollbarDrag?.axis === "h")) {
|
|
1641
|
+
return 1;
|
|
1642
|
+
}
|
|
1643
|
+
const last = axis === "v" ? this.lastScrollInteractY : this.lastScrollInteractX;
|
|
1644
|
+
const elapsed = Date.now() - last;
|
|
1645
|
+
if (elapsed < SCROLLBAR_FADE_HOLD_MS) return 1;
|
|
1646
|
+
const fade = elapsed - SCROLLBAR_FADE_HOLD_MS;
|
|
1647
|
+
if (fade >= SCROLLBAR_FADE_OUT_MS) return 0;
|
|
1648
|
+
return 1 - fade / SCROLLBAR_FADE_OUT_MS;
|
|
1649
|
+
}
|
|
1650
|
+
/** Mark a scrollbar axis as just-touched so its fade timer restarts. */
|
|
1651
|
+
touchScrollbar(axis) {
|
|
1652
|
+
if (axis === "v") this.lastScrollInteractY = Date.now();
|
|
1653
|
+
else this.lastScrollInteractX = Date.now();
|
|
1654
|
+
this.scheduleRender();
|
|
1655
|
+
}
|
|
1656
|
+
// ---- rendering ------------------------------------------------------
|
|
1657
|
+
scheduleRender() {
|
|
1658
|
+
if (this.rafPending) return;
|
|
1659
|
+
this.rafPending = true;
|
|
1660
|
+
requestAnimationFrame(() => {
|
|
1661
|
+
this.rafPending = false;
|
|
1662
|
+
if (this.destroyed) return;
|
|
1663
|
+
this.maybeAutoFit();
|
|
1664
|
+
this.resizeCanvas();
|
|
1665
|
+
this.clampScroll();
|
|
1666
|
+
drawAll(
|
|
1667
|
+
this.ctx,
|
|
1668
|
+
this.buildDrawState(),
|
|
1669
|
+
this.readStyle(),
|
|
1670
|
+
this.thumbs
|
|
1671
|
+
);
|
|
1672
|
+
this.canvas.style.cursor = this.hoverCursor;
|
|
1673
|
+
this.maybeContinueFade();
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Keep the raf loop alive while a scrollbar is still in its HOLD or
|
|
1678
|
+
* fade-out window. Without this, opacity is sampled once and the
|
|
1679
|
+
* bar would freeze at whatever value it had at the last input event
|
|
1680
|
+
* instead of smoothly fading out. Skipped when a bar is pinned
|
|
1681
|
+
* (hover or active drag) since opacity is constant there.
|
|
1682
|
+
*/
|
|
1683
|
+
maybeContinueFade() {
|
|
1684
|
+
const total = SCROLLBAR_FADE_HOLD_MS + SCROLLBAR_FADE_OUT_MS;
|
|
1685
|
+
const now = Date.now();
|
|
1686
|
+
const needV = this.maxScrollTop() > 0 && !this.hoverScrollbarY && this.scrollbarDrag?.axis !== "v" && now - this.lastScrollInteractY < total;
|
|
1687
|
+
const needH = this.maxScrollLeft() > 0 && !this.hoverScrollbarX && this.scrollbarDrag?.axis !== "h" && now - this.lastScrollInteractX < total;
|
|
1688
|
+
if (needV || needH) this.scheduleRender();
|
|
1689
|
+
}
|
|
1690
|
+
maybeAutoFit() {
|
|
1691
|
+
if (!this.autoFitEnabled || this.hasAutoFitted) return;
|
|
1692
|
+
if (projectDuration(this.project) <= 0) return;
|
|
1693
|
+
const fit = this.computeFitScale();
|
|
1694
|
+
if (fit == null) return;
|
|
1695
|
+
this.hasAutoFitted = true;
|
|
1696
|
+
if (Math.abs(fit - this.pxPerSec) > 0.5) {
|
|
1697
|
+
this.pxPerSec = fit;
|
|
1698
|
+
this.opts.onScaleChange?.(fit);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
buildDrawState() {
|
|
1702
|
+
return {
|
|
1703
|
+
project: this.project,
|
|
1704
|
+
pxPerSec: this.pxPerSec,
|
|
1705
|
+
scrollLeft: this.scrollLeft,
|
|
1706
|
+
scrollTop: this.scrollTop,
|
|
1707
|
+
timeMs: this.timeMs,
|
|
1708
|
+
selectedClipId: this.selectedClipId,
|
|
1709
|
+
hoveredClipId: this.hoveredClipId,
|
|
1710
|
+
hoveredTrackIndex: this.hoveredTrackIndex,
|
|
1711
|
+
dropTargetTrackIndex: this.dropTargetTrackIndex,
|
|
1712
|
+
isDragging: this.drag?.kind === "move",
|
|
1713
|
+
snapX: this.snapX,
|
|
1714
|
+
showHeader: this.showHeader,
|
|
1715
|
+
viewportWidth: this.viewportWidth,
|
|
1716
|
+
viewportHeight: this.viewportHeight,
|
|
1717
|
+
dragGhost: this.dragGhost,
|
|
1718
|
+
scrollbarOpacityY: this.scrollbarOpacity("v"),
|
|
1719
|
+
scrollbarOpacityX: this.scrollbarOpacity("h"),
|
|
1720
|
+
scrollbarActiveY: this.scrollbarDrag?.axis === "v",
|
|
1721
|
+
scrollbarActiveX: this.scrollbarDrag?.axis === "h",
|
|
1722
|
+
locale: this.locale
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
readStyle() {
|
|
1726
|
+
const cs = getComputedStyle(this.root);
|
|
1727
|
+
const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
|
|
1728
|
+
return {
|
|
1729
|
+
bg: v("--aicut-controls-bg", "#1f1f22"),
|
|
1730
|
+
border: v("--aicut-controls-border", "rgba(255,255,255,0.08)"),
|
|
1731
|
+
// Pass the resolved text color straight through — draw.ts'
|
|
1732
|
+
// withAlpha can adjust it for tick / muted variants.
|
|
1733
|
+
text: v("--aicut-controls-text", "rgba(255,255,255,0.85)"),
|
|
1734
|
+
textMuted: v("--color-muted", "#999999"),
|
|
1735
|
+
trackBg: "rgba(255,255,255,0.06)",
|
|
1736
|
+
brand: v("--color-brand", "#ff3386"),
|
|
1737
|
+
brandTo: v("--color-secondary", "#9a31f4"),
|
|
1738
|
+
info: v("--color-info", "#1077ff"),
|
|
1739
|
+
clipText: "#fff",
|
|
1740
|
+
selectedRing: v("--color-info", "#1077ff"),
|
|
1741
|
+
playhead: v("--color-brand", "#ff3386")
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
// ---- pointer / wheel ------------------------------------------------
|
|
1745
|
+
attachPointer() {
|
|
1746
|
+
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
|
1747
|
+
this.canvas.addEventListener("pointermove", (e) => this.onPointerMove(e));
|
|
1748
|
+
this.canvas.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
|
1749
|
+
this.canvas.addEventListener("pointercancel", (e) => this.onPointerUp(e));
|
|
1750
|
+
this.canvas.addEventListener("pointerleave", () => {
|
|
1751
|
+
if (!this.drag && !this.scrollbarDrag) {
|
|
1752
|
+
this.hoveredClipId = null;
|
|
1753
|
+
this.hoverCursor = "default";
|
|
1754
|
+
this.hoverScrollbarY = false;
|
|
1755
|
+
this.hoverScrollbarX = false;
|
|
1756
|
+
this.scheduleRender();
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
onPointerDown(e) {
|
|
1761
|
+
const { x, y } = this.localCoords(e);
|
|
1762
|
+
const target = this.hitTarget(x, y);
|
|
1763
|
+
this.canvas.setPointerCapture(e.pointerId);
|
|
1764
|
+
if (target.kind === "scrollbar-thumb-v") {
|
|
1765
|
+
this.scrollbarDrag = {
|
|
1766
|
+
axis: "v",
|
|
1767
|
+
pointerStart: y,
|
|
1768
|
+
scrollStart: this.scrollTop
|
|
1769
|
+
};
|
|
1770
|
+
this.touchScrollbar("v");
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
if (target.kind === "scrollbar-thumb-h") {
|
|
1774
|
+
this.scrollbarDrag = {
|
|
1775
|
+
axis: "h",
|
|
1776
|
+
pointerStart: x,
|
|
1777
|
+
scrollStart: this.scrollLeft
|
|
1778
|
+
};
|
|
1779
|
+
this.touchScrollbar("h");
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
if (target.kind === "scrollbar-track-v") {
|
|
1783
|
+
const page = Math.max(
|
|
1784
|
+
TRACK_HEIGHT,
|
|
1785
|
+
this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS
|
|
1786
|
+
);
|
|
1787
|
+
this.scrollTop += target.before ? -page : page;
|
|
1788
|
+
this.clampScroll();
|
|
1789
|
+
this.touchScrollbar("v");
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (target.kind === "scrollbar-track-h") {
|
|
1793
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
1794
|
+
const page = Math.max(
|
|
1795
|
+
80,
|
|
1796
|
+
this.viewportWidth - baseX - SCROLLBAR_THICKNESS
|
|
1797
|
+
);
|
|
1798
|
+
this.scrollLeft += target.before ? -page : page;
|
|
1799
|
+
this.clampScroll();
|
|
1800
|
+
this.touchScrollbar("h");
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
if (this.readOnly) {
|
|
1804
|
+
if (target.kind === "ruler" || target.kind === "clip" || target.kind === "clip-handle-left" || target.kind === "clip-handle-right" || target.kind === "track-empty") {
|
|
1805
|
+
this.drag = { kind: "scrub" };
|
|
1806
|
+
const ms = this.applySnap(
|
|
1807
|
+
xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
|
|
1808
|
+
null
|
|
1809
|
+
);
|
|
1810
|
+
this.timeMs = ms;
|
|
1811
|
+
this.opts.onSeek?.(ms);
|
|
1812
|
+
this.scheduleRender();
|
|
1813
|
+
}
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
if (target.kind === "header-delete") {
|
|
1817
|
+
const t = this.project.tracks[target.trackIndex];
|
|
1818
|
+
if (t) this.opts.onDeleteTrack?.(t.id);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
if (target.kind === "header") return;
|
|
1822
|
+
if (target.kind === "ruler") {
|
|
1823
|
+
this.drag = { kind: "scrub" };
|
|
1824
|
+
const ms = this.applySnap(
|
|
1825
|
+
xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
|
|
1826
|
+
null
|
|
1827
|
+
);
|
|
1828
|
+
this.timeMs = ms;
|
|
1829
|
+
this.opts.onSeek?.(ms);
|
|
1830
|
+
this.scheduleRender();
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (target.kind === "clip") {
|
|
1834
|
+
const found = findClip(this.project, target.clipId);
|
|
1835
|
+
if (!found) return;
|
|
1836
|
+
this.selectedClipId = target.clipId;
|
|
1837
|
+
this.opts.onSelectClip?.(target.clipId);
|
|
1838
|
+
this.drag = {
|
|
1839
|
+
kind: "move",
|
|
1840
|
+
clipId: target.clipId,
|
|
1841
|
+
trackIndex: target.trackIndex,
|
|
1842
|
+
pointerStartX: x,
|
|
1843
|
+
pointerStartY: y,
|
|
1844
|
+
originalStart: found.clip.start
|
|
1845
|
+
};
|
|
1846
|
+
this.scheduleRender();
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
|
|
1850
|
+
const found = findClip(this.project, target.clipId);
|
|
1851
|
+
if (!found) return;
|
|
1852
|
+
this.selectedClipId = target.clipId;
|
|
1853
|
+
this.opts.onSelectClip?.(target.clipId);
|
|
1854
|
+
this.drag = {
|
|
1855
|
+
kind: target.kind === "clip-handle-left" ? "trim-left" : "trim-right",
|
|
1856
|
+
clipId: target.clipId,
|
|
1857
|
+
trackIndex: target.trackIndex,
|
|
1858
|
+
pointerStartX: x,
|
|
1859
|
+
originalStart: found.clip.start,
|
|
1860
|
+
originalIn: found.clip.in,
|
|
1861
|
+
originalOut: found.clip.out
|
|
1862
|
+
};
|
|
1863
|
+
this.scheduleRender();
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
if (target.kind === "track-empty") {
|
|
1867
|
+
this.selectedClipId = null;
|
|
1868
|
+
this.opts.onSelectClip?.(null);
|
|
1869
|
+
const ms = this.applySnap(
|
|
1870
|
+
xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
|
|
1871
|
+
null
|
|
1872
|
+
);
|
|
1873
|
+
this.timeMs = ms;
|
|
1874
|
+
this.opts.onSeek?.(ms);
|
|
1875
|
+
this.drag = { kind: "scrub" };
|
|
1876
|
+
this.scheduleRender();
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
onPointerMove(e) {
|
|
1881
|
+
const { x, y } = this.localCoords(e);
|
|
1882
|
+
if (this.scrollbarDrag) {
|
|
1883
|
+
if (this.scrollbarDrag.axis === "v") {
|
|
1884
|
+
const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
|
|
1885
|
+
const contentH = contentHeight(
|
|
1886
|
+
this.project.tracks,
|
|
1887
|
+
this.drag?.kind === "move"
|
|
1888
|
+
);
|
|
1889
|
+
const trackLen = visibleH - SCROLLBAR_INSET * 2;
|
|
1890
|
+
const thumbLen = Math.max(
|
|
1891
|
+
SCROLLBAR_MIN_THUMB,
|
|
1892
|
+
trackLen * (visibleH / contentH)
|
|
1893
|
+
);
|
|
1894
|
+
const maxScroll = Math.max(0, contentH - visibleH);
|
|
1895
|
+
const free = Math.max(1, trackLen - thumbLen);
|
|
1896
|
+
const ratio = maxScroll / free;
|
|
1897
|
+
this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
|
|
1898
|
+
} else {
|
|
1899
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
1900
|
+
const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
|
|
1901
|
+
const contentW = contentWidth(this.project, this.pxPerSec);
|
|
1902
|
+
const trackLen = visibleW - SCROLLBAR_INSET * 2;
|
|
1903
|
+
const thumbLen = Math.max(
|
|
1904
|
+
SCROLLBAR_MIN_THUMB,
|
|
1905
|
+
trackLen * (visibleW / contentW)
|
|
1906
|
+
);
|
|
1907
|
+
const maxScroll = Math.max(0, contentW - visibleW);
|
|
1908
|
+
const free = Math.max(1, trackLen - thumbLen);
|
|
1909
|
+
const ratio = maxScroll / free;
|
|
1910
|
+
this.scrollLeft = this.scrollbarDrag.scrollStart + (x - this.scrollbarDrag.pointerStart) * ratio;
|
|
1911
|
+
}
|
|
1912
|
+
this.clampScroll();
|
|
1913
|
+
this.touchScrollbar(this.scrollbarDrag.axis);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
if (!this.drag) {
|
|
1917
|
+
const target = this.hitTarget(x, y);
|
|
1918
|
+
let nextHover = null;
|
|
1919
|
+
let nextHoverTrack = null;
|
|
1920
|
+
let cursor = "default";
|
|
1921
|
+
let onScrollbarV = false;
|
|
1922
|
+
let onScrollbarH = false;
|
|
1923
|
+
if (target.kind === "clip") {
|
|
1924
|
+
nextHover = target.clipId;
|
|
1925
|
+
nextHoverTrack = target.trackIndex;
|
|
1926
|
+
cursor = this.readOnly ? "pointer" : "grab";
|
|
1927
|
+
} else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
|
|
1928
|
+
nextHover = target.clipId;
|
|
1929
|
+
nextHoverTrack = target.trackIndex;
|
|
1930
|
+
cursor = "ew-resize";
|
|
1931
|
+
} else if (target.kind === "ruler") {
|
|
1932
|
+
cursor = "ew-resize";
|
|
1933
|
+
} else if (target.kind === "track-empty") {
|
|
1934
|
+
nextHoverTrack = target.trackIndex;
|
|
1935
|
+
cursor = "crosshair";
|
|
1936
|
+
} else if (target.kind === "header") {
|
|
1937
|
+
nextHoverTrack = target.trackIndex;
|
|
1938
|
+
cursor = "default";
|
|
1939
|
+
} else if (target.kind === "header-delete") {
|
|
1940
|
+
nextHoverTrack = target.trackIndex;
|
|
1941
|
+
cursor = "pointer";
|
|
1942
|
+
} else if (target.kind === "scrollbar-thumb-v" || target.kind === "scrollbar-track-v") {
|
|
1943
|
+
onScrollbarV = true;
|
|
1944
|
+
cursor = "default";
|
|
1945
|
+
} else if (target.kind === "scrollbar-thumb-h" || target.kind === "scrollbar-track-h") {
|
|
1946
|
+
onScrollbarH = true;
|
|
1947
|
+
cursor = "default";
|
|
1948
|
+
}
|
|
1949
|
+
const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
|
|
1950
|
+
if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged) {
|
|
1951
|
+
this.hoveredClipId = nextHover;
|
|
1952
|
+
this.hoveredTrackIndex = nextHoverTrack;
|
|
1953
|
+
this.hoverCursor = cursor;
|
|
1954
|
+
this.hoverScrollbarY = onScrollbarV;
|
|
1955
|
+
this.hoverScrollbarX = onScrollbarH;
|
|
1956
|
+
this.scheduleRender();
|
|
1957
|
+
}
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (this.drag.kind === "scrub") {
|
|
1961
|
+
const ms = this.applySnap(
|
|
1962
|
+
xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
|
|
1963
|
+
null
|
|
1964
|
+
);
|
|
1965
|
+
this.timeMs = ms;
|
|
1966
|
+
this.opts.onSeek?.(ms);
|
|
1967
|
+
this.scheduleRender();
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (this.drag.kind === "move") {
|
|
1971
|
+
this.lastDragPointerX = x;
|
|
1972
|
+
this.lastDragPointerY = y;
|
|
1973
|
+
this.processMoveDrag(x, y);
|
|
1974
|
+
this.scheduleRender();
|
|
1975
|
+
this.maybeStartDragAutoScroll();
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
|
|
1979
|
+
const dxPx = x - this.drag.pointerStartX;
|
|
1980
|
+
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
1981
|
+
const found = findClip(this.project, this.drag.clipId);
|
|
1982
|
+
if (!found) return;
|
|
1983
|
+
const c = found.clip;
|
|
1984
|
+
if (this.drag.kind === "trim-left") {
|
|
1985
|
+
let nextStart = Math.max(0, this.drag.originalStart + dxMs);
|
|
1986
|
+
nextStart = this.applySnap(nextStart, this.drag.clipId);
|
|
1987
|
+
const delta = nextStart - this.drag.originalStart;
|
|
1988
|
+
const nextIn = Math.max(
|
|
1989
|
+
0,
|
|
1990
|
+
Math.min(this.drag.originalIn + delta, this.drag.originalOut - 50)
|
|
1991
|
+
);
|
|
1992
|
+
const adjStart = this.drag.originalStart + (nextIn - this.drag.originalIn);
|
|
1993
|
+
c.in = nextIn;
|
|
1994
|
+
c.start = adjStart;
|
|
1995
|
+
} else {
|
|
1996
|
+
const nextOut = Math.max(
|
|
1997
|
+
this.drag.originalIn + 50,
|
|
1998
|
+
this.drag.originalOut + dxMs
|
|
1999
|
+
);
|
|
2000
|
+
c.out = nextOut;
|
|
2001
|
+
}
|
|
2002
|
+
this.scheduleRender();
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Update dragGhost + dropTargetTrackIndex for the in-flight move
|
|
2008
|
+
* drag, given the current viewport pointer position. Pulled out of
|
|
2009
|
+
* onPointerMove so the edge-autoscroll loop can re-run it on each
|
|
2010
|
+
* tick — autoscroll moves scrollTop under a stationary cursor, and
|
|
2011
|
+
* the ghost must follow the new row under that cursor.
|
|
2012
|
+
*/
|
|
2013
|
+
processMoveDrag(x, y) {
|
|
2014
|
+
if (!this.drag || this.drag.kind !== "move") return;
|
|
2015
|
+
const drag = this.drag;
|
|
2016
|
+
const dxPx = x - drag.pointerStartX;
|
|
2017
|
+
const dxMs = dxPx / this.pxPerSec * 1e3;
|
|
2018
|
+
let nextStart = Math.max(0, drag.originalStart + dxMs);
|
|
2019
|
+
nextStart = this.applySnap(nextStart, drag.clipId);
|
|
2020
|
+
const tiRaw = this.trackIndexAtY(y);
|
|
2021
|
+
const phantomIdx = this.project.tracks.length;
|
|
2022
|
+
const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
|
|
2023
|
+
const onPhantom = y >= phantomScreenY && y < phantomScreenY + TRACK_HEIGHT;
|
|
2024
|
+
const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
|
|
2025
|
+
let ghostTrackIndex = intendedTrackIndex;
|
|
2026
|
+
let overlap = false;
|
|
2027
|
+
if (onPhantom) {
|
|
2028
|
+
ghostTrackIndex = phantomIdx;
|
|
2029
|
+
overlap = true;
|
|
2030
|
+
} else if (this.opts.resolveDrop) {
|
|
2031
|
+
const r = this.opts.resolveDrop(drag.clipId, {
|
|
2032
|
+
start: nextStart,
|
|
2033
|
+
intendedTrackIndex
|
|
2034
|
+
});
|
|
2035
|
+
ghostTrackIndex = r.trackIndex;
|
|
2036
|
+
overlap = r.wouldCreateNew;
|
|
2037
|
+
} else {
|
|
2038
|
+
const found = findClip(this.project, drag.clipId);
|
|
2039
|
+
const dur = found ? found.clip.out - found.clip.in : 0;
|
|
2040
|
+
const targetTrack = this.project.tracks[intendedTrackIndex];
|
|
2041
|
+
overlap = targetTrack ? wouldOverlap(targetTrack, drag.clipId, nextStart, nextStart + dur) : false;
|
|
2042
|
+
}
|
|
2043
|
+
this.dragGhost = {
|
|
2044
|
+
clipId: drag.clipId,
|
|
2045
|
+
ghostStart: nextStart,
|
|
2046
|
+
ghostTrackIndex,
|
|
2047
|
+
wouldOverlap: overlap
|
|
2048
|
+
};
|
|
2049
|
+
this.dropTargetTrackIndex = ghostTrackIndex !== drag.trackIndex ? ghostTrackIndex : null;
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Px-per-frame scroll speed when the pointer is in a vertical edge
|
|
2053
|
+
* zone of the track region. Returns 0 outside the zone. Speed ramps
|
|
2054
|
+
* linearly from 0 at the zone's inner edge to ~16 px/frame at the
|
|
2055
|
+
* outer edge, so brushing the edge gives a gentle nudge and parking
|
|
2056
|
+
* deep at it gives a brisk auto-scroll.
|
|
2057
|
+
*/
|
|
2058
|
+
dragScrollSpeedY() {
|
|
2059
|
+
if (!this.drag || this.drag.kind !== "move") return 0;
|
|
2060
|
+
const y = this.lastDragPointerY;
|
|
2061
|
+
const top = RULER_HEIGHT;
|
|
2062
|
+
const bottom = this.viewportHeight - SCROLLBAR_THICKNESS;
|
|
2063
|
+
const zone = 36;
|
|
2064
|
+
const maxSpeed = 16;
|
|
2065
|
+
if (y >= top && y < top + zone) {
|
|
2066
|
+
if (this.scrollTop <= 0) return 0;
|
|
2067
|
+
const depth = (top + zone - y) / zone;
|
|
2068
|
+
return -Math.max(2, maxSpeed * depth);
|
|
2069
|
+
}
|
|
2070
|
+
if (y <= bottom && y > bottom - zone) {
|
|
2071
|
+
if (this.scrollTop >= this.maxScrollTop()) return 0;
|
|
2072
|
+
const depth = (y - (bottom - zone)) / zone;
|
|
2073
|
+
return Math.max(2, maxSpeed * depth);
|
|
2074
|
+
}
|
|
2075
|
+
return 0;
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Drive vertical auto-scroll while the user holds a clip near the
|
|
2079
|
+
* top/bottom edge of the track area. Self-stopping — exits the loop
|
|
2080
|
+
* once the pointer leaves the zone, the drag ends, or scroll bottoms
|
|
2081
|
+
* out at the clamp.
|
|
2082
|
+
*/
|
|
2083
|
+
maybeStartDragAutoScroll() {
|
|
2084
|
+
if (this.dragScrollRafPending) return;
|
|
2085
|
+
if (this.dragScrollSpeedY() === 0) return;
|
|
2086
|
+
this.dragScrollRafPending = true;
|
|
2087
|
+
requestAnimationFrame(() => {
|
|
2088
|
+
this.dragScrollRafPending = false;
|
|
2089
|
+
if (this.destroyed) return;
|
|
2090
|
+
const speed = this.dragScrollSpeedY();
|
|
2091
|
+
if (speed === 0 || !this.drag || this.drag.kind !== "move") return;
|
|
2092
|
+
this.scrollTop += speed;
|
|
2093
|
+
this.clampScroll();
|
|
2094
|
+
this.touchScrollbar("v");
|
|
2095
|
+
this.processMoveDrag(this.lastDragPointerX, this.lastDragPointerY);
|
|
2096
|
+
this.scheduleRender();
|
|
2097
|
+
this.maybeStartDragAutoScroll();
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
onPointerUp(_e) {
|
|
2101
|
+
if (this.scrollbarDrag) {
|
|
2102
|
+
const axis = this.scrollbarDrag.axis;
|
|
2103
|
+
this.scrollbarDrag = null;
|
|
2104
|
+
this.touchScrollbar(axis);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
if (!this.drag) return;
|
|
2108
|
+
const drag = this.drag;
|
|
2109
|
+
const ghost = this.dragGhost;
|
|
2110
|
+
this.drag = null;
|
|
2111
|
+
this.dragGhost = null;
|
|
2112
|
+
this.dropTargetTrackIndex = null;
|
|
2113
|
+
this.snapX = null;
|
|
2114
|
+
if (drag.kind === "move") {
|
|
2115
|
+
if (ghost) {
|
|
2116
|
+
const isPhantom = ghost.ghostTrackIndex >= this.project.tracks.length;
|
|
2117
|
+
const finalTrackId = isPhantom ? void 0 : ghost.ghostTrackIndex !== drag.trackIndex ? this.project.tracks[ghost.ghostTrackIndex]?.id : void 0;
|
|
2118
|
+
this.opts.onMoveClip?.(drag.clipId, {
|
|
2119
|
+
start: ghost.ghostStart,
|
|
2120
|
+
trackId: finalTrackId,
|
|
2121
|
+
// Phantom row drop is the user explicitly asking for a new
|
|
2122
|
+
// track — bypass the editor's smart routing in that case
|
|
2123
|
+
// (which would otherwise route back to the source track and
|
|
2124
|
+
// make the gesture a no-op).
|
|
2125
|
+
newTrack: isPhantom
|
|
2126
|
+
});
|
|
2127
|
+
this.opts.onChange?.(this.getProject());
|
|
2128
|
+
}
|
|
2129
|
+
} else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
|
|
2130
|
+
const found = findClip(this.project, drag.clipId);
|
|
2131
|
+
if (found) {
|
|
2132
|
+
this.opts.onResizeClip?.(drag.clipId, {
|
|
2133
|
+
in: found.clip.in,
|
|
2134
|
+
out: found.clip.out,
|
|
2135
|
+
start: found.clip.start
|
|
2136
|
+
});
|
|
2137
|
+
this.opts.onChange?.(this.getProject());
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
this.scheduleRender();
|
|
2141
|
+
}
|
|
2142
|
+
attachWheel() {
|
|
2143
|
+
this.canvas.addEventListener(
|
|
2144
|
+
"wheel",
|
|
2145
|
+
(e) => {
|
|
2146
|
+
if (e.ctrlKey || e.metaKey) {
|
|
2147
|
+
e.preventDefault();
|
|
2148
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
2149
|
+
const cursorX = e.clientX - rect.left;
|
|
2150
|
+
const anchorMs = xToMs(
|
|
2151
|
+
cursorX,
|
|
2152
|
+
this.pxPerSec,
|
|
2153
|
+
this.scrollLeft,
|
|
2154
|
+
this.showHeader
|
|
2155
|
+
);
|
|
2156
|
+
const dy = Math.max(-50, Math.min(50, e.deltaY));
|
|
2157
|
+
const factor = Math.exp(-dy * WHEEL_ZOOM_RATE);
|
|
2158
|
+
const next = clampScale(this.pxPerSec * factor);
|
|
2159
|
+
if (Math.abs(next - this.pxPerSec) < 0.01) return;
|
|
2160
|
+
this.pxPerSec = next;
|
|
2161
|
+
this.hasAutoFitted = true;
|
|
2162
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
2163
|
+
this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
|
|
2164
|
+
this.clampScroll();
|
|
2165
|
+
this.touchScrollbar("h");
|
|
2166
|
+
this.opts.onScaleChange?.(this.pxPerSec);
|
|
2167
|
+
this.scheduleRender();
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
const horizDominant = Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
|
2171
|
+
if (horizDominant) {
|
|
2172
|
+
if (e.deltaX === 0) return;
|
|
2173
|
+
e.preventDefault();
|
|
2174
|
+
this.scrollLeft += e.deltaX;
|
|
2175
|
+
this.clampScroll();
|
|
2176
|
+
this.touchScrollbar("h");
|
|
2177
|
+
this.scheduleRender();
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
if (e.deltaY === 0) return;
|
|
2181
|
+
e.preventDefault();
|
|
2182
|
+
if (this.maxScrollTop() > 0) {
|
|
2183
|
+
this.scrollTop += e.deltaY;
|
|
2184
|
+
this.clampScroll();
|
|
2185
|
+
this.touchScrollbar("v");
|
|
2186
|
+
} else {
|
|
2187
|
+
this.scrollLeft += e.deltaY;
|
|
2188
|
+
this.clampScroll();
|
|
2189
|
+
this.touchScrollbar("h");
|
|
2190
|
+
}
|
|
2191
|
+
this.scheduleRender();
|
|
2192
|
+
},
|
|
2193
|
+
{ passive: false }
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
attachResize() {
|
|
2197
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
2198
|
+
this.resizeObs = new ResizeObserver(() => {
|
|
2199
|
+
this.resizeCanvas();
|
|
2200
|
+
if (!this.hasAutoFitted && this.autoFitEnabled) {
|
|
2201
|
+
const fit = this.computeFitScale();
|
|
2202
|
+
if (fit != null) {
|
|
2203
|
+
this.pxPerSec = fit;
|
|
2204
|
+
this.opts.onScaleChange?.(fit);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
this.scheduleRender();
|
|
2208
|
+
});
|
|
2209
|
+
this.resizeObs.observe(this.root);
|
|
2210
|
+
}
|
|
2211
|
+
// ---- helpers --------------------------------------------------------
|
|
2212
|
+
localCoords(e) {
|
|
2213
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
2214
|
+
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
2215
|
+
}
|
|
2216
|
+
hitTarget(x, y) {
|
|
2217
|
+
return hitTest(x, y, {
|
|
2218
|
+
project: this.project,
|
|
2219
|
+
pxPerSec: this.pxPerSec,
|
|
2220
|
+
scrollLeft: this.scrollLeft,
|
|
2221
|
+
scrollTop: this.scrollTop,
|
|
2222
|
+
showHeader: this.showHeader,
|
|
2223
|
+
viewportWidth: this.viewportWidth,
|
|
2224
|
+
viewportHeight: this.viewportHeight,
|
|
2225
|
+
isDragging: this.drag?.kind === "move"
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
trackIndexAtY(y) {
|
|
2229
|
+
return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
|
|
2230
|
+
}
|
|
2231
|
+
applySnap(ms, ignoreClipId) {
|
|
2232
|
+
if (!this.snapEnabled) {
|
|
2233
|
+
this.snapX = null;
|
|
2234
|
+
return ms;
|
|
2235
|
+
}
|
|
2236
|
+
const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
|
|
2237
|
+
const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
|
|
2238
|
+
let best = ms;
|
|
2239
|
+
let bestDist = tolMs;
|
|
2240
|
+
for (const t of targets) {
|
|
2241
|
+
const d = Math.abs(t - ms);
|
|
2242
|
+
if (d < bestDist) {
|
|
2243
|
+
bestDist = d;
|
|
2244
|
+
best = t;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
if (best !== ms) {
|
|
2248
|
+
const baseX = this.showHeader ? HEADER_WIDTH : 0;
|
|
2249
|
+
this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
|
|
2250
|
+
} else {
|
|
2251
|
+
this.snapX = null;
|
|
2252
|
+
}
|
|
2253
|
+
return best;
|
|
2254
|
+
}
|
|
2255
|
+
};
|
|
2256
|
+
|
|
2257
|
+
// src/ui/icons.ts
|
|
2258
|
+
var wrap = (path) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">${path}</svg>`;
|
|
2259
|
+
var ICONS = {
|
|
2260
|
+
undo: wrap(
|
|
2261
|
+
`<g transform="translate(1 1)"><path d="M5.66577 1.38721L2.85034 4.20264H9.64624C11.8471 4.20264 13.6313 5.98724 13.6316 8.18799C13.6315 10.3889 11.8472 12.1733 9.64624 12.1733H7.19116V11.1235H9.64624C11.2673 11.1235 12.5817 9.80904 12.5818 8.18799C12.5815 6.56714 11.2672 5.25342 9.64624 5.25342H2.85034L5.66577 8.06885L4.92358 8.81201L0.8396 4.72803L4.92358 0.644043L5.66577 1.38721Z" fill="currentColor"/></g>`
|
|
2262
|
+
),
|
|
2263
|
+
redo: wrap(
|
|
2264
|
+
`<g transform="translate(1 1)"><path d="M8.55005 2.04655L11.3659 4.86239L4.56982 4.86239C2.36897 4.86251 0.584578 6.64687 0.584473 8.84774C0.584646 11.0485 2.36901 12.833 4.56982 12.8331L7.0245 12.8331L7.0245 11.7832H4.56982C2.94891 11.7831 1.63453 10.4686 1.63436 8.84774C1.63446 7.22677 2.94886 5.91297 4.56982 5.91284H11.3659L8.55005 8.72811L9.29289 9.47095L13.3768 5.38761L9.29289 1.30371L8.55005 2.04655Z" fill="currentColor"/></g>`
|
|
2265
|
+
),
|
|
2266
|
+
split: wrap(
|
|
2267
|
+
`<g transform="translate(1 1)"><path d="M5.7168 12.7754H1.75V11.7246H4.68164V2.27539H1.75V1.22461H5.7168V12.7754ZM12.25 2.27539H9.31836V11.7246H12.25V12.7754H8.2832V1.22461H12.25V2.27539Z" fill="currentColor"/></g>`
|
|
2268
|
+
),
|
|
2269
|
+
trimLeft: wrap(
|
|
2270
|
+
`<g transform="translate(1 1)"><path d="M2.7998 12.7754H1.75V11.7246H2.7998V12.7754ZM4.25781 12.7754H3.20801V11.7246H4.25781V12.7754ZM5.7168 12.7754H4.66699V11.7246H5.7168V12.7754ZM12.25 2.27539H9.31836V11.7246H12.25V12.7754H8.2832V1.22461H12.25V2.27539ZM5.7168 11.0254H4.66699V9.97461H5.7168V11.0254ZM5.7168 9.27539H4.66699V8.22461H5.7168V9.27539ZM5.7168 7.52539H4.66699V6.47461H5.7168V7.52539ZM5.7168 5.77539H4.66699V4.72461H5.7168V5.77539ZM5.7168 4.02539H4.66699V2.97461H5.7168V4.02539ZM2.7998 2.27539H1.75V1.22461H2.7998V2.27539ZM4.25781 2.27539H3.20801V1.22461H4.25781V2.27539ZM5.7168 2.27539H4.66699V1.22461H5.7168V2.27539Z" fill="currentColor"/></g>`
|
|
2271
|
+
),
|
|
2272
|
+
trimRight: wrap(
|
|
2273
|
+
`<g transform="translate(1 1)"><path d="M5.7168 12.7754H1.75V11.7246H4.68164V2.27539H1.75V1.22461H5.7168V12.7754ZM9.33301 12.7754H8.2832V11.7246H9.33301V12.7754ZM10.792 12.7754H9.74219V11.7246H10.792V12.7754ZM12.25 12.7754H11.2002V11.7246H12.25V12.7754ZM9.33301 11.0254H8.2832V9.97461H9.33301V11.0254ZM9.33301 9.27539H8.2832V8.22461H9.33301V9.27539ZM9.33301 7.52539H8.2832V6.47461H9.33301V7.52539ZM9.33301 5.77539H8.2832V4.72461H9.33301V5.77539ZM9.33301 4.02539H8.2832V2.97461H9.33301V4.02539ZM9.33301 2.27539H8.2832V1.22461H9.33301V2.27539ZM10.792 2.27539H9.74219V1.22461H10.792V2.27539ZM12.25 2.27539H11.2002V1.22461H12.25V2.27539Z" fill="currentColor"/></g>`
|
|
2274
|
+
),
|
|
2275
|
+
speed: wrap(
|
|
2276
|
+
`<g transform="translate(1 1)"><path d="M7.00175 0.595229C7.32353 0.596316 7.58439 0.858333 7.58378 1.18019C7.583 1.50212 7.32071 1.76265 6.99882 1.76222C6.45361 1.76108 5.90722 1.8449 5.38065 2.01417C4.23393 2.38294 3.24874 3.13519 2.59061 4.14406C1.93249 5.15302 1.64169 6.35801 1.76639 7.55617C1.89121 8.75413 2.4235 9.87343 3.27518 10.7251C4.12694 11.5767 5.24613 12.1092 6.44413 12.2339C7.6422 12.3585 8.84735 12.0678 9.85624 11.4097C10.8649 10.7516 11.6173 9.76618 11.9861 8.61964C12.5967 6.72047 12.1219 5.04361 11.0633 3.76124L8.57792 6.24757C8.68682 6.4756 8.74974 6.72999 8.74979 6.99953C8.74979 7.9659 7.96612 8.74933 6.99979 8.74953C6.03345 8.74934 5.24979 7.96591 5.24979 6.99953C5.24997 6.03329 6.03356 5.24971 6.99979 5.24953C7.26941 5.24958 7.52464 5.31244 7.75272 5.4214L10.6707 2.50441L10.7137 2.46535C10.8174 2.38043 10.9485 2.33351 11.0838 2.33351C11.2382 2.33369 11.3867 2.39519 11.4959 2.50441C13.0855 4.09426 13.9307 6.38102 13.0965 8.97609C12.6458 10.3776 11.726 11.5819 10.493 12.3862C9.25989 13.1905 7.7873 13.5464 6.32303 13.3941C4.8588 13.2416 3.49099 12.5903 2.44999 11.5493C1.40904 10.5083 0.757676 9.14058 0.605261 7.67628C0.452904 6.21196 0.808759 4.73947 1.61307 3.50636C2.41742 2.27338 3.62178 1.35455 5.02323 0.903823C5.66695 0.696861 6.33524 0.593807 7.00175 0.595229Z" fill="currentColor"/></g>`
|
|
2277
|
+
),
|
|
2278
|
+
play: wrap(
|
|
2279
|
+
// Triangle bbox center at viewBox x=9.4 vs viewBox center 8 — the
|
|
2280
|
+
// raw path leans right. Optical centering: translate by half the
|
|
2281
|
+
// bbox offset (-0.7) so the centroid stays near 7.5 (slight left
|
|
2282
|
+
// of center, which the eye accepts as balanced for a right-pointing
|
|
2283
|
+
// triangle) while the bbox is no longer obviously off to one side.
|
|
2284
|
+
`<g transform="translate(-0.7 0)"><path d="M4.66699 2.64248C4.66717 1.82358 5.59736 1.35167 6.25781 1.83584L13.5674 7.19619C14.1117 7.59579 14.1118 8.40897 13.5674 8.8085L6.25781 14.1688C5.59734 14.6528 4.6671 14.1811 4.66699 13.3622V2.64248Z" fill="currentColor"/></g>`
|
|
2285
|
+
),
|
|
2286
|
+
pause: wrap(
|
|
2287
|
+
`<path d="M4 3h2.5v10H4V3zm5.5 0H12v10H9.5V3z" fill="currentColor"/>`
|
|
2288
|
+
),
|
|
2289
|
+
fullscreen: `<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"><path d="M8 3H5a2 2 0 00-2 2v3"/><path d="M21 8V5a2 2 0 00-2-2h-3"/><path d="M3 16v3a2 2 0 002 2h3"/><path d="M16 21h3a2 2 0 002-2v-3"/></svg>`,
|
|
2290
|
+
snap: `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.275.783a5.07 5.07 0 0 1 4.929 4.927 5.07 5.07 0 0 1-1.311 3.542l-3.937 3.95a.526.526 0 0 1-.744 0l-2.334-2.334a.525.525 0 0 1 .001-.743l2.55-2.538a.5.5 0 0 1 .171-.17l.976-.97a.723.723 0 0 0-1.01-1.008L3.87 9.12a.526.526 0 0 1-.743 0L.795 6.784a.526.526 0 0 1 0-.742l3.937-3.95.022-.02a5.07 5.07 0 0 1 3.52-1.29m-2.281 9.715 1.588 1.589 1.884-1.889-1.583-1.583zm2.253-8.666a4.02 4.02 0 0 0-2.785 1.017l-.936.939 1.602 1.603.729-.726.052-.045a1.776 1.776 0 0 1 2.33.154 1.775 1.775 0 0 1 .155 2.332 1 1 0 0 1-.046.053l-.72.716 1.58 1.58.924-.929a4.019 4.019 0 0 0-2.885-6.694M1.909 6.413 3.5 8.005 5.384 6.13l-1.6-1.6z"/></svg>`,
|
|
2291
|
+
zoomOut: `<svg width="14" height="14" viewBox="0 0 20.2618 20.2564" fill="none" aria-hidden="true"><path d="M9 0C13.9706 0 18 4.02944 18 9C18 11.1612 17.2374 13.1438 15.9678 14.6953L19.998 18.7197C20.3494 19.071 20.3498 19.6404 19.999 19.9922C19.6476 20.3441 19.0775 20.3446 18.7256 19.9932L14.6953 15.9678C13.1438 17.2374 11.1612 18 9 18C4.02944 18 0 13.9706 0 9C0 4.02944 4.02944 0 9 0ZM9 1.7998C5.02355 1.7998 1.7998 5.02355 1.7998 9C1.7998 12.9765 5.02355 16.2002 9 16.2002C12.9765 16.2002 16.2002 12.9765 16.2002 9C16.2002 5.02355 12.9765 1.7998 9 1.7998ZM12.5996 8.09961C13.0969 8.09961 13.5 8.50273 13.5 9C13.5 9.49727 13.0969 9.90039 12.5996 9.90039H5.40039C4.90312 9.90039 4.5 9.49727 4.5 9C4.5 8.50273 4.90312 8.09961 5.40039 8.09961H12.5996Z" fill="currentColor"/></svg>`,
|
|
2292
|
+
zoomIn: `<svg width="14" height="14" viewBox="0 0 20.2613 20.2565" fill="none" aria-hidden="true"><path d="M9 0C13.9705 6.59711e-05 18 4.02948 18 9C18 11.1612 17.2374 13.1438 15.9678 14.6953L19.9971 18.7197C20.3489 19.0711 20.3494 19.6403 19.998 19.9922C19.6466 20.3441 19.0765 20.3446 18.7246 19.9932L14.6953 15.9678C13.1438 17.2374 11.1611 18 9 18C4.02944 18 0 13.9706 0 9C0 4.02944 4.02944 0 9 0ZM9 1.7998C5.02355 1.7998 1.7998 5.02355 1.7998 9C1.7998 12.9765 5.02355 16.2002 9 16.2002C12.9764 16.2001 16.2002 12.9764 16.2002 9C16.2002 5.02359 12.9764 1.79987 9 1.7998ZM8.99512 4.50488C9.49233 4.50495 9.89551 4.90804 9.89551 5.40527V8.09961H12.5996C13.0968 8.09968 13.5 8.50277 13.5 9C13.5 9.49723 13.0968 9.90032 12.5996 9.90039H9.89551V12.6045C9.89551 13.1017 9.49233 13.5048 8.99512 13.5049C8.49785 13.5049 8.09473 13.1018 8.09473 12.6045V9.90039H5.40039C4.90312 9.90039 4.5 9.49727 4.5 9C4.5 8.50273 4.90312 8.09961 5.40039 8.09961H8.09473V5.40527C8.09473 4.908 8.49785 4.50488 8.99512 4.50488Z" fill="currentColor"/></svg>`,
|
|
2293
|
+
export: `<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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
|
|
2294
|
+
addTrack: wrap(
|
|
2295
|
+
`<g transform="translate(2 2)"><rect x="0" y="1.5" width="12" height="2.5" rx="0.5" fill="currentColor"/><rect x="0" y="8" width="12" height="2.5" rx="0.5" fill="currentColor" opacity="0.55"/><circle cx="10" cy="10" r="3.4" fill="currentColor"/><rect x="9.4" y="8.5" width="1.2" height="3" rx="0.4" fill="#fff"/><rect x="8.5" y="9.4" width="3" height="1.2" rx="0.4" fill="#fff"/></g>`
|
|
2296
|
+
),
|
|
2297
|
+
trash: wrap(
|
|
2298
|
+
`<g transform="translate(1 1)" fill="currentColor"><path d="M5 1.25h4v.9H13v1.05H1V2.15h4v-.9zM2.4 4.1h9.2l-.65 8.9c-.04.55-.5.96-1.05.96H4.1c-.55 0-1.01-.41-1.05-.96L2.4 4.1zm2.3 1.7l.35 7.1h1l-.35-7.1h-1zm2.65 0v7.1h1V5.8h-1zm2.3 0l-.35 7.1h1l.35-7.1h-1z"/></g>`
|
|
2299
|
+
),
|
|
2300
|
+
/** Counter-clockwise circular arrow — "reset to initial layout". */
|
|
2301
|
+
reset: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`
|
|
2302
|
+
};
|
|
2303
|
+
|
|
2304
|
+
// src/ui/toolbar.ts
|
|
2305
|
+
var SCALE_MIN2 = 10;
|
|
2306
|
+
var SCALE_MAX2 = 400;
|
|
2307
|
+
var Toolbar = class {
|
|
2308
|
+
root;
|
|
2309
|
+
cb;
|
|
2310
|
+
locale;
|
|
2311
|
+
/**
|
|
2312
|
+
* Bookend slots reserved for host-supplied controls. The library
|
|
2313
|
+
* paints nothing into either — hosts (React/Vue wrappers or plain
|
|
2314
|
+
* JS) append their own buttons / dropdowns. `extrasLeft` sits at
|
|
2315
|
+
* the very start of the toolbar; `extrasRight` at the very end.
|
|
2316
|
+
* Empty by default and visually hidden (no separator) until they
|
|
2317
|
+
* actually contain children.
|
|
2318
|
+
*/
|
|
2319
|
+
extrasLeft;
|
|
2320
|
+
extrasRight;
|
|
2321
|
+
undoBtn;
|
|
2322
|
+
redoBtn;
|
|
2323
|
+
splitBtn;
|
|
2324
|
+
trimLeftBtn;
|
|
2325
|
+
trimRightBtn;
|
|
2326
|
+
playBtn;
|
|
2327
|
+
playIcon;
|
|
2328
|
+
timeLabel;
|
|
2329
|
+
durationLabel;
|
|
2330
|
+
fullscreenBtn;
|
|
2331
|
+
snapBtn;
|
|
2332
|
+
zoomOutBtn;
|
|
2333
|
+
zoomSlider;
|
|
2334
|
+
zoomInBtn;
|
|
2335
|
+
resetBtn;
|
|
2336
|
+
lastState = null;
|
|
2337
|
+
constructor(host, cb, locale) {
|
|
2338
|
+
this.cb = cb;
|
|
2339
|
+
this.locale = locale;
|
|
2340
|
+
this.root = document.createElement("div");
|
|
2341
|
+
this.root.className = "aicut-toolbar";
|
|
2342
|
+
this.root.setAttribute("data-testid", "aicut-toolbar");
|
|
2343
|
+
this.extrasLeft = mkGroup("aicut-toolbar-extras aicut-toolbar-extras-left");
|
|
2344
|
+
this.extrasRight = mkGroup("aicut-toolbar-extras aicut-toolbar-extras-right");
|
|
2345
|
+
const left = mkGroup("aicut-toolbar-left");
|
|
2346
|
+
this.undoBtn = mkIconButton("undo", locale.undo, () => cb.onUndo(), "aicut-undo");
|
|
2347
|
+
this.redoBtn = mkIconButton("redo", locale.redo, () => cb.onRedo(), "aicut-redo");
|
|
2348
|
+
this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
|
|
2349
|
+
this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
|
|
2350
|
+
this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
|
|
2351
|
+
const speedBtn = mkIconButton("speed", locale.speedComingSoon, () => void 0, "aicut-speed");
|
|
2352
|
+
speedBtn.disabled = true;
|
|
2353
|
+
left.append(this.undoBtn, this.redoBtn, this.splitBtn, this.trimLeftBtn, this.trimRightBtn, speedBtn);
|
|
2354
|
+
const center = mkGroup("aicut-toolbar-center");
|
|
2355
|
+
this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
|
|
2356
|
+
this.playBtn = document.createElement("button");
|
|
2357
|
+
this.playBtn.type = "button";
|
|
2358
|
+
this.playBtn.className = "aicut-play-btn";
|
|
2359
|
+
this.playBtn.title = locale.playPause;
|
|
2360
|
+
this.playBtn.setAttribute("data-testid", "aicut-play");
|
|
2361
|
+
this.playIcon = document.createElement("span");
|
|
2362
|
+
this.playIcon.innerHTML = ICONS.play;
|
|
2363
|
+
this.playBtn.appendChild(this.playIcon);
|
|
2364
|
+
this.playBtn.addEventListener("click", () => cb.onPlayToggle());
|
|
2365
|
+
this.durationLabel = mkSpan("aicut-time-total", "00:00", "aicut-time-total");
|
|
2366
|
+
this.fullscreenBtn = mkIconButton("fullscreen", locale.fullscreen, () => cb.onFullscreen(), "aicut-fullscreen");
|
|
2367
|
+
center.append(this.timeLabel, this.playBtn, this.durationLabel, this.fullscreenBtn);
|
|
2368
|
+
const right = mkGroup("aicut-toolbar-right");
|
|
2369
|
+
this.snapBtn = mkIconButton("snap", locale.snap, () => cb.onSnapToggle(), "aicut-snap");
|
|
2370
|
+
this.zoomOutBtn = mkIconButton("zoomOut", locale.zoomOut, () => this.nudgeZoom(-1), "aicut-zoom-out");
|
|
2371
|
+
this.zoomSlider = document.createElement("input");
|
|
2372
|
+
this.zoomSlider.type = "range";
|
|
2373
|
+
this.zoomSlider.min = "0";
|
|
2374
|
+
this.zoomSlider.max = "100";
|
|
2375
|
+
this.zoomSlider.className = "aicut-zoom-slider";
|
|
2376
|
+
this.zoomSlider.setAttribute("data-testid", "aicut-zoom-slider");
|
|
2377
|
+
this.zoomSlider.addEventListener("input", () => {
|
|
2378
|
+
const ratio = Number(this.zoomSlider.value) / 100;
|
|
2379
|
+
cb.onScaleChange(sliderToScale(ratio));
|
|
2380
|
+
});
|
|
2381
|
+
this.zoomInBtn = mkIconButton("zoomIn", locale.zoomIn, () => this.nudgeZoom(1), "aicut-zoom-in");
|
|
2382
|
+
this.resetBtn = mkIconButton("reset", locale.reset, () => cb.onReset(), "aicut-reset");
|
|
2383
|
+
right.append(this.snapBtn, this.zoomOutBtn, this.zoomSlider, this.zoomInBtn, this.resetBtn);
|
|
2384
|
+
this.root.append(this.extrasLeft, left, center, right, this.extrasRight);
|
|
2385
|
+
host.appendChild(this.root);
|
|
2386
|
+
}
|
|
2387
|
+
get element() {
|
|
2388
|
+
return this.root;
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Idempotent render. Critically: only mutates DOM when a state
|
|
2392
|
+
* field actually changed. Without this, `playIcon.innerHTML = ICONS`
|
|
2393
|
+
* re-parsed the SVG every playback tick (~60Hz) — and replacing the
|
|
2394
|
+
* element between a user's mousedown and mouseup meant the browser
|
|
2395
|
+
* never fired `click` (the two events landed on different element
|
|
2396
|
+
* identities). That manifested as "needs 3 clicks to pause".
|
|
2397
|
+
*
|
|
2398
|
+
* Rule of thumb here: any `innerHTML =` / element rebuild MUST be
|
|
2399
|
+
* behind a "did the input change" guard. Plain `.disabled` /
|
|
2400
|
+
* `.textContent` writes are idempotent in browsers and safe to set
|
|
2401
|
+
* unconditionally, but we still diff them for cheap CPU.
|
|
2402
|
+
*/
|
|
2403
|
+
render(state) {
|
|
2404
|
+
if (!this.lastState || this.lastState.time !== state.time) {
|
|
2405
|
+
this.timeLabel.textContent = fmtClock(state.time);
|
|
2406
|
+
}
|
|
2407
|
+
if (!this.lastState || this.lastState.duration !== state.duration) {
|
|
2408
|
+
this.durationLabel.textContent = fmtClock(state.duration);
|
|
2409
|
+
}
|
|
2410
|
+
if (!this.lastState || this.lastState.playing !== state.playing) {
|
|
2411
|
+
this.playIcon.innerHTML = state.playing ? ICONS.pause : ICONS.play;
|
|
2412
|
+
this.playBtn.setAttribute(
|
|
2413
|
+
"data-state",
|
|
2414
|
+
state.playing ? "playing" : "paused"
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
if (!this.lastState || this.lastState.canUndo !== state.canUndo) {
|
|
2418
|
+
this.undoBtn.disabled = !state.canUndo;
|
|
2419
|
+
}
|
|
2420
|
+
if (!this.lastState || this.lastState.canRedo !== state.canRedo) {
|
|
2421
|
+
this.redoBtn.disabled = !state.canRedo;
|
|
2422
|
+
}
|
|
2423
|
+
if (!this.lastState || this.lastState.canSplit !== state.canSplit) {
|
|
2424
|
+
this.splitBtn.disabled = !state.canSplit;
|
|
2425
|
+
}
|
|
2426
|
+
if (!this.lastState || this.lastState.canTrim !== state.canTrim) {
|
|
2427
|
+
this.trimLeftBtn.disabled = !state.canTrim;
|
|
2428
|
+
this.trimRightBtn.disabled = !state.canTrim;
|
|
2429
|
+
}
|
|
2430
|
+
if (!this.lastState || this.lastState.snap !== state.snap) {
|
|
2431
|
+
this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
|
|
2432
|
+
this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
|
|
2433
|
+
this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
|
|
2434
|
+
}
|
|
2435
|
+
if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
|
|
2436
|
+
const ratio = scaleToSlider(state.pxPerSec);
|
|
2437
|
+
const nextVal = String(Math.round(ratio * 100));
|
|
2438
|
+
if (this.zoomSlider.value !== nextVal) this.zoomSlider.value = nextVal;
|
|
2439
|
+
this.zoomSlider.style.setProperty(
|
|
2440
|
+
"--aicut-zoom-fill",
|
|
2441
|
+
`${Math.round(ratio * 100)}%`
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
this.lastState = { ...state };
|
|
2445
|
+
}
|
|
2446
|
+
destroy() {
|
|
2447
|
+
this.root.remove();
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Apply a new locale to all already-mounted controls. Re-uses the
|
|
2451
|
+
* same DOM elements (so event listeners and pointer-capture state
|
|
2452
|
+
* stay intact) — only writes `title` / `aria-label`. Snap toggle
|
|
2453
|
+
* title is then refreshed via the next render pass.
|
|
2454
|
+
*/
|
|
2455
|
+
setLocale(locale) {
|
|
2456
|
+
this.locale = locale;
|
|
2457
|
+
const applyTitle = (el, title) => {
|
|
2458
|
+
if (!el) return;
|
|
2459
|
+
el.title = title;
|
|
2460
|
+
el.setAttribute("aria-label", title);
|
|
2461
|
+
};
|
|
2462
|
+
applyTitle(this.undoBtn, locale.undo);
|
|
2463
|
+
applyTitle(this.redoBtn, locale.redo);
|
|
2464
|
+
applyTitle(this.splitBtn, locale.split);
|
|
2465
|
+
applyTitle(this.trimLeftBtn, locale.trimLeft);
|
|
2466
|
+
applyTitle(this.trimRightBtn, locale.trimRight);
|
|
2467
|
+
applyTitle(this.playBtn, locale.playPause);
|
|
2468
|
+
applyTitle(this.fullscreenBtn, locale.fullscreen);
|
|
2469
|
+
applyTitle(this.snapBtn, locale.snap);
|
|
2470
|
+
applyTitle(this.zoomOutBtn, locale.zoomOut);
|
|
2471
|
+
applyTitle(this.zoomInBtn, locale.zoomIn);
|
|
2472
|
+
applyTitle(this.resetBtn, locale.reset);
|
|
2473
|
+
if (this.lastState) {
|
|
2474
|
+
this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
nudgeZoom(dir) {
|
|
2478
|
+
const cur = Number(this.zoomSlider.value);
|
|
2479
|
+
const next = Math.max(0, Math.min(100, cur + dir * 8));
|
|
2480
|
+
this.zoomSlider.value = String(next);
|
|
2481
|
+
this.cb.onScaleChange(sliderToScale(next / 100));
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
function mkGroup(cls) {
|
|
2485
|
+
const d = document.createElement("div");
|
|
2486
|
+
d.className = cls;
|
|
2487
|
+
return d;
|
|
2488
|
+
}
|
|
2489
|
+
function mkSpan(cls, text, testId) {
|
|
2490
|
+
const s = document.createElement("span");
|
|
2491
|
+
s.className = cls;
|
|
2492
|
+
s.textContent = text;
|
|
2493
|
+
if (testId) s.setAttribute("data-testid", testId);
|
|
2494
|
+
return s;
|
|
2495
|
+
}
|
|
2496
|
+
function mkIconButton(icon, title, onClick, testId) {
|
|
2497
|
+
const b = document.createElement("button");
|
|
2498
|
+
b.type = "button";
|
|
2499
|
+
b.className = "aicut-icon-btn";
|
|
2500
|
+
b.title = title;
|
|
2501
|
+
b.setAttribute("aria-label", title);
|
|
2502
|
+
b.innerHTML = ICONS[icon];
|
|
2503
|
+
if (testId) b.setAttribute("data-testid", testId);
|
|
2504
|
+
b.addEventListener("click", onClick);
|
|
2505
|
+
return b;
|
|
2506
|
+
}
|
|
2507
|
+
function sliderToScale(ratio) {
|
|
2508
|
+
const lo = Math.log(SCALE_MIN2);
|
|
2509
|
+
const hi = Math.log(SCALE_MAX2);
|
|
2510
|
+
return Math.exp(lo + (hi - lo) * ratio);
|
|
2511
|
+
}
|
|
2512
|
+
function scaleToSlider(scale) {
|
|
2513
|
+
const lo = Math.log(SCALE_MIN2);
|
|
2514
|
+
const hi = Math.log(SCALE_MAX2);
|
|
2515
|
+
return (Math.log(Math.max(SCALE_MIN2, Math.min(SCALE_MAX2, scale))) - lo) / (hi - lo);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// src/ui/index.ts
|
|
2519
|
+
var EditorUI = class {
|
|
2520
|
+
root;
|
|
2521
|
+
editor;
|
|
2522
|
+
preview;
|
|
2523
|
+
fullscreenExitBtn;
|
|
2524
|
+
toolbar;
|
|
2525
|
+
timelineHost;
|
|
2526
|
+
timeline;
|
|
2527
|
+
fullscreen = false;
|
|
2528
|
+
onDocKeydown = null;
|
|
2529
|
+
constructor(root, editor, cb) {
|
|
2530
|
+
this.root = root;
|
|
2531
|
+
this.editor = editor;
|
|
2532
|
+
const locale = editor.getLocale();
|
|
2533
|
+
root.classList.add("aicut-root");
|
|
2534
|
+
root.innerHTML = "";
|
|
2535
|
+
this.preview = document.createElement("div");
|
|
2536
|
+
this.preview.className = "aicut-preview-host";
|
|
2537
|
+
this.preview.setAttribute("data-testid", "aicut-preview");
|
|
2538
|
+
root.appendChild(this.preview);
|
|
2539
|
+
this.fullscreenExitBtn = document.createElement("button");
|
|
2540
|
+
this.fullscreenExitBtn.type = "button";
|
|
2541
|
+
this.fullscreenExitBtn.className = "aicut-fullscreen-exit";
|
|
2542
|
+
this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
|
|
2543
|
+
this.fullscreenExitBtn.setAttribute("data-testid", "aicut-fullscreen-exit");
|
|
2544
|
+
this.fullscreenExitBtn.textContent = locale.exitFullscreen;
|
|
2545
|
+
this.fullscreenExitBtn.addEventListener(
|
|
2546
|
+
"click",
|
|
2547
|
+
() => this.setFullscreen(false)
|
|
2548
|
+
);
|
|
2549
|
+
this.preview.appendChild(this.fullscreenExitBtn);
|
|
2550
|
+
this.toolbar = new Toolbar(root, cb, locale);
|
|
2551
|
+
this.timelineHost = document.createElement("div");
|
|
2552
|
+
this.timelineHost.className = "aicut-timeline";
|
|
2553
|
+
this.timelineHost.setAttribute("data-testid", "aicut-timeline");
|
|
2554
|
+
root.appendChild(this.timelineHost);
|
|
2555
|
+
this.timeline = Timeline.create({
|
|
2556
|
+
container: this.timelineHost,
|
|
2557
|
+
project: editor.getProject(),
|
|
2558
|
+
pxPerSec: editor.getScale(),
|
|
2559
|
+
time: editor.getTime(),
|
|
2560
|
+
selectedClipId: editor.getSelection(),
|
|
2561
|
+
snap: editor.getSnap(),
|
|
2562
|
+
autoFit: true,
|
|
2563
|
+
locale,
|
|
2564
|
+
onSeek: cb.onSeek,
|
|
2565
|
+
onSelectClip: cb.onSelectClip,
|
|
2566
|
+
onMoveClip: cb.onMoveClip,
|
|
2567
|
+
onResizeClip: cb.onResizeClip,
|
|
2568
|
+
onScaleChange: cb.onScaleChange,
|
|
2569
|
+
onDeleteTrack: (trackId) => editor.removeTrack(trackId),
|
|
2570
|
+
// Mirror the editor's smart routing into the drag preview so
|
|
2571
|
+
// the ghost lands on the same row the commit will pick.
|
|
2572
|
+
resolveDrop: (clipId, intent) => {
|
|
2573
|
+
const proj = editor.getProject();
|
|
2574
|
+
const intendedTrack = proj.tracks[intent.intendedTrackIndex];
|
|
2575
|
+
const pred = editor.previewMoveTarget(
|
|
2576
|
+
clipId,
|
|
2577
|
+
intent.start,
|
|
2578
|
+
intendedTrack?.id
|
|
2579
|
+
);
|
|
2580
|
+
if (!pred) {
|
|
2581
|
+
return {
|
|
2582
|
+
trackIndex: intent.intendedTrackIndex,
|
|
2583
|
+
wouldCreateNew: false
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
return {
|
|
2587
|
+
trackIndex: pred.trackIndex,
|
|
2588
|
+
wouldCreateNew: pred.wouldCreateNew
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
this.attachKeyboard(cb);
|
|
2593
|
+
}
|
|
2594
|
+
// ---- fullscreen -----------------------------------------------------
|
|
2595
|
+
isFullscreen() {
|
|
2596
|
+
return this.fullscreen;
|
|
2597
|
+
}
|
|
2598
|
+
toggleFullscreen() {
|
|
2599
|
+
this.setFullscreen(!this.fullscreen);
|
|
2600
|
+
}
|
|
2601
|
+
setFullscreen(on) {
|
|
2602
|
+
if (on === this.fullscreen) return;
|
|
2603
|
+
this.fullscreen = on;
|
|
2604
|
+
this.root.classList.toggle("aicut-fullscreen", on);
|
|
2605
|
+
if (on) {
|
|
2606
|
+
this.onDocKeydown = (e) => {
|
|
2607
|
+
if (e.key === "Escape") {
|
|
2608
|
+
e.preventDefault();
|
|
2609
|
+
this.setFullscreen(false);
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
document.addEventListener("keydown", this.onDocKeydown);
|
|
2613
|
+
} else if (this.onDocKeydown) {
|
|
2614
|
+
document.removeEventListener("keydown", this.onDocKeydown);
|
|
2615
|
+
this.onDocKeydown = null;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
get previewHost() {
|
|
2619
|
+
return this.preview;
|
|
2620
|
+
}
|
|
2621
|
+
/** Host-extensible slot at the very left of the top toolbar. */
|
|
2622
|
+
get toolbarLeft() {
|
|
2623
|
+
return this.toolbar.extrasLeft;
|
|
2624
|
+
}
|
|
2625
|
+
/** Host-extensible slot at the very right of the top toolbar. */
|
|
2626
|
+
get toolbarRight() {
|
|
2627
|
+
return this.toolbar.extrasRight;
|
|
2628
|
+
}
|
|
2629
|
+
/** Public for e2e — read-back of timeline canvas state (no DOM clips). */
|
|
2630
|
+
getTimelineDebug() {
|
|
2631
|
+
return this.timeline.getDebugInfo();
|
|
2632
|
+
}
|
|
2633
|
+
/** Full sync from editor state. Idempotent. */
|
|
2634
|
+
render() {
|
|
2635
|
+
const project = this.editor.getProject();
|
|
2636
|
+
const time = this.editor.getTime();
|
|
2637
|
+
const duration = this.editor.getDuration();
|
|
2638
|
+
const selectedClipId = this.editor.getSelection();
|
|
2639
|
+
const pxPerSec = this.editor.getScale();
|
|
2640
|
+
const snap = this.editor.getSnap();
|
|
2641
|
+
this.toolbar.render({
|
|
2642
|
+
playing: this.editor.isPlaying(),
|
|
2643
|
+
time,
|
|
2644
|
+
duration,
|
|
2645
|
+
canUndo: this.editor.canUndo(),
|
|
2646
|
+
canRedo: this.editor.canRedo(),
|
|
2647
|
+
canSplit: this.canSplitAt(time),
|
|
2648
|
+
canTrim: this.canTrimAt(time, selectedClipId),
|
|
2649
|
+
snap,
|
|
2650
|
+
pxPerSec
|
|
2651
|
+
});
|
|
2652
|
+
this.timeline.setProject(project);
|
|
2653
|
+
this.timeline.setTime(time);
|
|
2654
|
+
this.timeline.setScale(pxPerSec);
|
|
2655
|
+
this.timeline.setSelection(selectedClipId);
|
|
2656
|
+
this.timeline.setSnap(snap);
|
|
2657
|
+
}
|
|
2658
|
+
/** Playback-fast path: nudge playhead + toolbar time label only. */
|
|
2659
|
+
onTimeTick(timeMs) {
|
|
2660
|
+
this.timeline.setTime(timeMs);
|
|
2661
|
+
this.toolbar.render({
|
|
2662
|
+
playing: this.editor.isPlaying(),
|
|
2663
|
+
time: timeMs,
|
|
2664
|
+
duration: this.editor.getDuration(),
|
|
2665
|
+
canUndo: this.editor.canUndo(),
|
|
2666
|
+
canRedo: this.editor.canRedo(),
|
|
2667
|
+
canSplit: this.canSplitAt(timeMs),
|
|
2668
|
+
canTrim: this.canTrimAt(timeMs, this.editor.getSelection()),
|
|
2669
|
+
snap: this.editor.getSnap(),
|
|
2670
|
+
pxPerSec: this.editor.getScale()
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
/** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
|
|
2674
|
+
resetAutoFit() {
|
|
2675
|
+
this.timeline.refit();
|
|
2676
|
+
}
|
|
2677
|
+
setLocale(locale) {
|
|
2678
|
+
this.toolbar.setLocale(locale);
|
|
2679
|
+
this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
|
|
2680
|
+
this.fullscreenExitBtn.textContent = locale.exitFullscreen;
|
|
2681
|
+
this.timeline.setLocale(locale);
|
|
2682
|
+
this.render();
|
|
2683
|
+
}
|
|
2684
|
+
destroy() {
|
|
2685
|
+
if (this.onDocKeydown) {
|
|
2686
|
+
document.removeEventListener("keydown", this.onDocKeydown);
|
|
2687
|
+
this.onDocKeydown = null;
|
|
2688
|
+
}
|
|
2689
|
+
this.toolbar.destroy();
|
|
2690
|
+
this.timeline.destroy();
|
|
2691
|
+
this.root.innerHTML = "";
|
|
2692
|
+
this.root.classList.remove("aicut-root", "aicut-fullscreen");
|
|
2693
|
+
}
|
|
2694
|
+
// ---- helpers --------------------------------------------------------
|
|
2695
|
+
canSplitAt(timeMs) {
|
|
2696
|
+
const project = this.editor.getProject();
|
|
2697
|
+
for (const t of project.tracks) {
|
|
2698
|
+
for (const c of t.clips) {
|
|
2699
|
+
if (timeMs > c.start && timeMs < c.start + clipDuration(c)) return true;
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
return false;
|
|
2703
|
+
}
|
|
2704
|
+
canTrimAt(timeMs, selectedClipId) {
|
|
2705
|
+
const project = this.editor.getProject();
|
|
2706
|
+
if (selectedClipId) {
|
|
2707
|
+
const trk = findTrackOfClip(project, selectedClipId);
|
|
2708
|
+
const cl = trk?.clips.find((c) => c.id === selectedClipId);
|
|
2709
|
+
if (cl && timeMs > cl.start && timeMs < cl.start + clipDuration(cl)) {
|
|
2710
|
+
return true;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
for (const t of project.tracks) {
|
|
2714
|
+
const cl = findClipContaining(t, timeMs);
|
|
2715
|
+
if (cl) return true;
|
|
2716
|
+
}
|
|
2717
|
+
return false;
|
|
2718
|
+
}
|
|
2719
|
+
attachKeyboard(cb) {
|
|
2720
|
+
this.root.tabIndex = 0;
|
|
2721
|
+
this.root.addEventListener("keydown", (e) => {
|
|
2722
|
+
const target = e.target;
|
|
2723
|
+
if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return;
|
|
2724
|
+
if (e.code === "Space") {
|
|
2725
|
+
e.preventDefault();
|
|
2726
|
+
cb.onPlayToggle();
|
|
2727
|
+
} else if (e.code === "KeyK") {
|
|
2728
|
+
e.preventDefault();
|
|
2729
|
+
cb.onSplit();
|
|
2730
|
+
} else if (e.code === "KeyQ") {
|
|
2731
|
+
e.preventDefault();
|
|
2732
|
+
cb.onTrimLeft();
|
|
2733
|
+
} else if (e.code === "KeyW") {
|
|
2734
|
+
e.preventDefault();
|
|
2735
|
+
cb.onTrimRight();
|
|
2736
|
+
} else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
|
|
2737
|
+
e.preventDefault();
|
|
2738
|
+
if (e.shiftKey) cb.onRedo();
|
|
2739
|
+
else cb.onUndo();
|
|
2740
|
+
} else if (e.code === "Delete" || e.code === "Backspace") {
|
|
2741
|
+
const sel = this.editor.getSelection();
|
|
2742
|
+
if (sel) {
|
|
2743
|
+
e.preventDefault();
|
|
2744
|
+
cb.onDeleteClip(sel);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
// src/editor.ts
|
|
2752
|
+
var DEFAULT_PX_PER_SEC = 80;
|
|
2753
|
+
var MIN_PX_PER_SEC = 10;
|
|
2754
|
+
var MAX_PX_PER_SEC = 400;
|
|
2755
|
+
var SNAP_PX2 = 8;
|
|
2756
|
+
var Editor = class _Editor {
|
|
2757
|
+
container;
|
|
2758
|
+
project;
|
|
2759
|
+
engine;
|
|
2760
|
+
ui;
|
|
2761
|
+
bus = new EventBus();
|
|
2762
|
+
history = new HistoryStack();
|
|
2763
|
+
selectedClipId = null;
|
|
2764
|
+
pxPerSec;
|
|
2765
|
+
snap;
|
|
2766
|
+
locale;
|
|
2767
|
+
destroyed = false;
|
|
2768
|
+
constructor(opts) {
|
|
2769
|
+
this.container = opts.container;
|
|
2770
|
+
this.project = normalizeProject(opts.project ?? createEmptyProject());
|
|
2771
|
+
this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
|
|
2772
|
+
this.snap = opts.initialSnap !== false;
|
|
2773
|
+
this.locale = mergeLocale(opts.locale);
|
|
2774
|
+
applyTheme(this.container, opts.theme);
|
|
2775
|
+
this.ui = new EditorUI(this.container, this, {
|
|
2776
|
+
onPlayToggle: () => this.togglePlay(),
|
|
2777
|
+
onSplit: () => this.split(),
|
|
2778
|
+
onTrimLeft: () => this.trimLeft(),
|
|
2779
|
+
onTrimRight: () => this.trimRight(),
|
|
2780
|
+
onUndo: () => this.undo(),
|
|
2781
|
+
onRedo: () => this.redo(),
|
|
2782
|
+
onReset: () => this.reset(),
|
|
2783
|
+
onFullscreen: () => this.ui.toggleFullscreen(),
|
|
2784
|
+
onSnapToggle: () => this.setSnap(!this.snap),
|
|
2785
|
+
onScaleChange: (s) => this.setScale(s),
|
|
2786
|
+
onSeek: (t) => this.seek(t),
|
|
2787
|
+
onSelectClip: (id) => this.setSelection(id),
|
|
2788
|
+
onDeleteClip: (id) => this.removeClip(id),
|
|
2789
|
+
onMoveClip: (id, opts2) => this.moveClip(id, opts2),
|
|
2790
|
+
onResizeClip: (id, edits) => this.resizeClip(id, edits)
|
|
2791
|
+
});
|
|
2792
|
+
this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
|
|
2793
|
+
this.engine.onTimeUpdate = (ms) => {
|
|
2794
|
+
this.bus.emit("time", { timeMs: ms });
|
|
2795
|
+
this.ui.onTimeTick(ms);
|
|
2796
|
+
};
|
|
2797
|
+
this.engine.onEnded = () => this.bus.emit("pause", void 0);
|
|
2798
|
+
this.engine.onError = (err) => this.bus.emit("error", { error: err });
|
|
2799
|
+
this.engine.onReady = () => this.bus.emit("ready", { sourceId: null });
|
|
2800
|
+
this.engine.onSourceMetadata = (sourceId, durMs) => this.handleSourceMetadata(sourceId, durMs);
|
|
2801
|
+
if (opts.initialTime) this.engine.seek(opts.initialTime);
|
|
2802
|
+
this.ui.render();
|
|
2803
|
+
}
|
|
2804
|
+
static create(opts) {
|
|
2805
|
+
return new _Editor(opts);
|
|
2806
|
+
}
|
|
2807
|
+
get toolbarLeft() {
|
|
2808
|
+
return this.ui.toolbarLeft;
|
|
2809
|
+
}
|
|
2810
|
+
get toolbarRight() {
|
|
2811
|
+
return this.ui.toolbarRight;
|
|
2812
|
+
}
|
|
2813
|
+
// ---- playback -------------------------------------------------------
|
|
2814
|
+
play() {
|
|
2815
|
+
this.engine.play();
|
|
2816
|
+
this.bus.emit("play", void 0);
|
|
2817
|
+
this.ui.render();
|
|
2818
|
+
}
|
|
2819
|
+
pause() {
|
|
2820
|
+
this.engine.pause();
|
|
2821
|
+
this.bus.emit("pause", void 0);
|
|
2822
|
+
this.ui.render();
|
|
2823
|
+
}
|
|
2824
|
+
togglePlay() {
|
|
2825
|
+
if (this.engine.isPlaying()) this.pause();
|
|
2826
|
+
else this.play();
|
|
2827
|
+
}
|
|
2828
|
+
seek(timeMs) {
|
|
2829
|
+
this.engine.seek(timeMs);
|
|
2830
|
+
this.ui.render();
|
|
2831
|
+
}
|
|
2832
|
+
getTime() {
|
|
2833
|
+
return this.engine?.getTime() ?? 0;
|
|
2834
|
+
}
|
|
2835
|
+
getDuration() {
|
|
2836
|
+
return projectDuration(this.project);
|
|
2837
|
+
}
|
|
2838
|
+
isPlaying() {
|
|
2839
|
+
return this.engine?.isPlaying() ?? false;
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* In-tab "fullscreen" — covers the browser viewport via fixed
|
|
2843
|
+
* positioning, NOT the OS Fullscreen API. This is what the reference
|
|
2844
|
+
* UI calls "全屏预览": the user stays in their tab, no browser
|
|
2845
|
+
* permission prompt, ESC exits. Browser fullscreen would also work
|
|
2846
|
+
* but is heavier UX and gets blocked in iframes.
|
|
2847
|
+
*/
|
|
2848
|
+
async enterFullscreen() {
|
|
2849
|
+
this.ui.setFullscreen(true);
|
|
2850
|
+
}
|
|
2851
|
+
async exitFullscreen() {
|
|
2852
|
+
this.ui.setFullscreen(false);
|
|
2853
|
+
}
|
|
2854
|
+
isFullscreen() {
|
|
2855
|
+
return this.ui.isFullscreen();
|
|
2856
|
+
}
|
|
2857
|
+
// ---- editing --------------------------------------------------------
|
|
2858
|
+
/**
|
|
2859
|
+
* Split the clip at `timeMs` (or playhead). Returns the two new clip ids
|
|
2860
|
+
* or null if there's no clip to split at that time.
|
|
2861
|
+
*/
|
|
2862
|
+
split(timeMs) {
|
|
2863
|
+
const t = timeMs ?? this.engine.getTime();
|
|
2864
|
+
let target = null;
|
|
2865
|
+
if (this.selectedClipId) {
|
|
2866
|
+
const trk = findTrackOfClip(this.project, this.selectedClipId);
|
|
2867
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedClipId) ?? null;
|
|
2868
|
+
if (trk && cl && t > cl.start && t < clipEnd(cl)) target = { track: trk, clip: cl };
|
|
2869
|
+
}
|
|
2870
|
+
if (!target) {
|
|
2871
|
+
for (const trk of this.project.tracks) {
|
|
2872
|
+
const cl = findClipContaining(trk, t);
|
|
2873
|
+
if (cl && t > cl.start && t < clipEnd(cl)) {
|
|
2874
|
+
target = { track: trk, clip: cl };
|
|
2875
|
+
break;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
if (!target) return null;
|
|
2880
|
+
const split = splitClipAt(target.clip, t - target.clip.start);
|
|
2881
|
+
if (!split) return null;
|
|
2882
|
+
this.pushHistory();
|
|
2883
|
+
const [left, right] = split;
|
|
2884
|
+
target.track.clips = target.track.clips.filter((c) => c.id !== target.clip.id).concat(left, right).sort((a, b) => a.start - b.start);
|
|
2885
|
+
this.afterMutation();
|
|
2886
|
+
return [left.id, right.id];
|
|
2887
|
+
}
|
|
2888
|
+
cut(timeMs) {
|
|
2889
|
+
return this.split(timeMs);
|
|
2890
|
+
}
|
|
2891
|
+
trimLeft(timeMs) {
|
|
2892
|
+
const t = timeMs ?? this.engine.getTime();
|
|
2893
|
+
const target = this.resolveTrimTarget(t);
|
|
2894
|
+
if (!target) return false;
|
|
2895
|
+
const { clip } = target;
|
|
2896
|
+
const delta = t - clip.start;
|
|
2897
|
+
if (delta <= 0 || delta >= clipDuration(clip)) return false;
|
|
2898
|
+
this.pushHistory();
|
|
2899
|
+
const oldStart = clip.start;
|
|
2900
|
+
clip.in += delta;
|
|
2901
|
+
clip.start += delta;
|
|
2902
|
+
const gapStart = oldStart;
|
|
2903
|
+
const gapEnd = clip.start;
|
|
2904
|
+
let covered = false;
|
|
2905
|
+
outer: for (const trk of this.project.tracks) {
|
|
2906
|
+
for (const c of trk.clips) {
|
|
2907
|
+
if (c.id === clip.id) continue;
|
|
2908
|
+
const cEnd = c.start + clipDuration(c);
|
|
2909
|
+
if (c.start < gapEnd && cEnd > gapStart) {
|
|
2910
|
+
covered = true;
|
|
2911
|
+
break outer;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
if (!covered) {
|
|
2916
|
+
clip.start = gapStart;
|
|
2917
|
+
for (const trk of this.project.tracks) {
|
|
2918
|
+
for (const c of trk.clips) {
|
|
2919
|
+
if (c.id === clip.id) continue;
|
|
2920
|
+
if (c.start >= gapEnd) c.start -= delta;
|
|
2921
|
+
}
|
|
2922
|
+
trk.clips.sort((a, b) => a.start - b.start);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
this.afterMutation();
|
|
2926
|
+
return true;
|
|
2927
|
+
}
|
|
2928
|
+
trimRight(timeMs) {
|
|
2929
|
+
const t = timeMs ?? this.engine.getTime();
|
|
2930
|
+
const target = this.resolveTrimTarget(t);
|
|
2931
|
+
if (!target) return false;
|
|
2932
|
+
const { clip } = target;
|
|
2933
|
+
const delta = clipEnd(clip) - t;
|
|
2934
|
+
if (delta <= 0 || delta >= clipDuration(clip)) return false;
|
|
2935
|
+
this.pushHistory();
|
|
2936
|
+
clip.out -= delta;
|
|
2937
|
+
this.afterMutation();
|
|
2938
|
+
return true;
|
|
2939
|
+
}
|
|
2940
|
+
removeClip(clipId) {
|
|
2941
|
+
let removed = false;
|
|
2942
|
+
const before = JSON.stringify(this.project);
|
|
2943
|
+
for (const t of this.project.tracks) {
|
|
2944
|
+
const len = t.clips.length;
|
|
2945
|
+
t.clips = t.clips.filter((c) => c.id !== clipId);
|
|
2946
|
+
if (t.clips.length !== len) removed = true;
|
|
2947
|
+
}
|
|
2948
|
+
if (!removed) return false;
|
|
2949
|
+
this.history.push(JSON.parse(before));
|
|
2950
|
+
if (this.selectedClipId === clipId) {
|
|
2951
|
+
this.selectedClipId = null;
|
|
2952
|
+
this.bus.emit("selectionChange", { clipId: null });
|
|
2953
|
+
}
|
|
2954
|
+
this.afterMutation();
|
|
2955
|
+
return true;
|
|
2956
|
+
}
|
|
2957
|
+
setClipSpeed(clipId, speed) {
|
|
2958
|
+
if (!Number.isFinite(speed) || speed <= 0) return false;
|
|
2959
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
2960
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
2961
|
+
if (!trk || !cl) return false;
|
|
2962
|
+
this.pushHistory();
|
|
2963
|
+
cl.speed = speed === 1 ? void 0 : speed;
|
|
2964
|
+
this.afterMutation();
|
|
2965
|
+
return true;
|
|
2966
|
+
}
|
|
2967
|
+
// ---- tracks ---------------------------------------------------------
|
|
2968
|
+
addTrack(kind) {
|
|
2969
|
+
this.pushHistory();
|
|
2970
|
+
const t = { id: createId("track"), kind, clips: [] };
|
|
2971
|
+
this.project.tracks.push(t);
|
|
2972
|
+
this.afterMutation();
|
|
2973
|
+
return t;
|
|
2974
|
+
}
|
|
2975
|
+
removeTrack(trackId) {
|
|
2976
|
+
const idx = this.project.tracks.findIndex((t) => t.id === trackId);
|
|
2977
|
+
if (idx < 0) return false;
|
|
2978
|
+
this.pushHistory();
|
|
2979
|
+
this.project.tracks.splice(idx, 1);
|
|
2980
|
+
this.afterMutation();
|
|
2981
|
+
return true;
|
|
2982
|
+
}
|
|
2983
|
+
/**
|
|
2984
|
+
* Pure prediction of where a `moveClip(...)` would land — same smart
|
|
2985
|
+
* routing as the real move (intended → source → other → new track),
|
|
2986
|
+
* just no mutation, no history. Lets the Timeline preview the
|
|
2987
|
+
* ACTUAL outcome of a drop so the ghost stops lying about new
|
|
2988
|
+
* tracks that won't get created.
|
|
2989
|
+
*/
|
|
2990
|
+
previewMoveTarget(clipId, start, intendedTrackId) {
|
|
2991
|
+
const fromTrack = findTrackOfClip(this.project, clipId);
|
|
2992
|
+
const clip = fromTrack?.clips.find((c) => c.id === clipId);
|
|
2993
|
+
if (!fromTrack || !clip) return null;
|
|
2994
|
+
const nextStart = Math.max(0, start);
|
|
2995
|
+
const nextEnd = nextStart + clipDuration(clip);
|
|
2996
|
+
const intended = intendedTrackId ? this.project.tracks.find((t) => t.id === intendedTrackId) : fromTrack;
|
|
2997
|
+
const candidates = [];
|
|
2998
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2999
|
+
const push = (t) => {
|
|
3000
|
+
if (!t || seen.has(t.id)) return;
|
|
3001
|
+
seen.add(t.id);
|
|
3002
|
+
candidates.push(t);
|
|
3003
|
+
};
|
|
3004
|
+
push(intended);
|
|
3005
|
+
push(fromTrack);
|
|
3006
|
+
for (const t of this.project.tracks) {
|
|
3007
|
+
if (t.kind === fromTrack.kind) push(t);
|
|
3008
|
+
}
|
|
3009
|
+
for (const c of candidates) {
|
|
3010
|
+
if (!wouldOverlap(c, clipId, nextStart, nextEnd)) {
|
|
3011
|
+
const idx = this.project.tracks.indexOf(c);
|
|
3012
|
+
return { trackIndex: idx, trackId: c.id, wouldCreateNew: false };
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
return {
|
|
3016
|
+
trackIndex: this.project.tracks.length,
|
|
3017
|
+
trackId: "",
|
|
3018
|
+
wouldCreateNew: true
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
moveClip(clipId, opts) {
|
|
3022
|
+
const fromTrack = findTrackOfClip(this.project, clipId);
|
|
3023
|
+
const clip = fromTrack?.clips.find((c) => c.id === clipId);
|
|
3024
|
+
if (!fromTrack || !clip) return false;
|
|
3025
|
+
const nextStart = Math.max(0, opts.start ?? clip.start);
|
|
3026
|
+
const nextEnd = nextStart + clipDuration(clip);
|
|
3027
|
+
if (opts.newTrack) {
|
|
3028
|
+
this.pushHistory();
|
|
3029
|
+
const created = this.appendTrack({ kind: fromTrack.kind });
|
|
3030
|
+
clip.start = nextStart;
|
|
3031
|
+
fromTrack.clips = fromTrack.clips.filter((c) => c.id !== clipId);
|
|
3032
|
+
created.clips.push(clip);
|
|
3033
|
+
if (fromTrack.clips.length === 0 && this.project.tracks.filter((t) => t.kind === fromTrack.kind).length > 1) {
|
|
3034
|
+
this.project.tracks = this.project.tracks.filter(
|
|
3035
|
+
(t) => t.id !== fromTrack.id
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
this.afterMutation();
|
|
3039
|
+
return true;
|
|
3040
|
+
}
|
|
3041
|
+
const intended = opts.trackId ? this.project.tracks.find((t) => t.id === opts.trackId) : fromTrack;
|
|
3042
|
+
if (!intended) return false;
|
|
3043
|
+
this.pushHistory();
|
|
3044
|
+
const candidates = [];
|
|
3045
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3046
|
+
const push = (t) => {
|
|
3047
|
+
if (!t || seen.has(t.id)) return;
|
|
3048
|
+
seen.add(t.id);
|
|
3049
|
+
candidates.push(t);
|
|
3050
|
+
};
|
|
3051
|
+
push(intended);
|
|
3052
|
+
push(fromTrack);
|
|
3053
|
+
for (const t of this.project.tracks) {
|
|
3054
|
+
if (t.kind === fromTrack.kind) push(t);
|
|
3055
|
+
}
|
|
3056
|
+
let targetTrack = null;
|
|
3057
|
+
for (const c of candidates) {
|
|
3058
|
+
if (!wouldOverlap(c, clipId, nextStart, nextEnd)) {
|
|
3059
|
+
targetTrack = c;
|
|
3060
|
+
break;
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
if (!targetTrack) {
|
|
3064
|
+
targetTrack = this.appendTrack({ kind: fromTrack.kind });
|
|
3065
|
+
}
|
|
3066
|
+
clip.start = nextStart;
|
|
3067
|
+
if (targetTrack !== fromTrack) {
|
|
3068
|
+
fromTrack.clips = fromTrack.clips.filter((c) => c.id !== clipId);
|
|
3069
|
+
targetTrack.clips.push(clip);
|
|
3070
|
+
}
|
|
3071
|
+
targetTrack.clips.sort((a, b) => a.start - b.start);
|
|
3072
|
+
if (fromTrack !== targetTrack && fromTrack.clips.length === 0 && this.project.tracks.filter((t) => t.kind === fromTrack.kind).length > 1) {
|
|
3073
|
+
this.project.tracks = this.project.tracks.filter(
|
|
3074
|
+
(t) => t.id !== fromTrack.id
|
|
3075
|
+
);
|
|
3076
|
+
}
|
|
3077
|
+
this.afterMutation();
|
|
3078
|
+
return true;
|
|
3079
|
+
}
|
|
3080
|
+
resizeClip(clipId, edits) {
|
|
3081
|
+
const trk = findTrackOfClip(this.project, clipId);
|
|
3082
|
+
const cl = trk?.clips.find((c) => c.id === clipId);
|
|
3083
|
+
if (!trk || !cl) return false;
|
|
3084
|
+
const next = { ...cl, ...edits };
|
|
3085
|
+
if (next.out <= next.in) return false;
|
|
3086
|
+
if (next.start < 0) return false;
|
|
3087
|
+
this.pushHistory();
|
|
3088
|
+
Object.assign(cl, next);
|
|
3089
|
+
trk.clips.sort((a, b) => a.start - b.start);
|
|
3090
|
+
this.afterMutation();
|
|
3091
|
+
return true;
|
|
3092
|
+
}
|
|
3093
|
+
addSource(source, opts = {}) {
|
|
3094
|
+
const src = { ...source, id: source.id || createId("src") };
|
|
3095
|
+
this.pushHistory();
|
|
3096
|
+
this.project.sources.push(src);
|
|
3097
|
+
const append = opts.appendClip !== false;
|
|
3098
|
+
if (append && src.kind === "video") {
|
|
3099
|
+
const track = this.project.tracks.find((t) => t.kind === "video") ?? this.appendTrack({ kind: "video" });
|
|
3100
|
+
const start = trackEnd(track);
|
|
3101
|
+
track.clips.push({
|
|
3102
|
+
id: createId("clip"),
|
|
3103
|
+
sourceId: src.id,
|
|
3104
|
+
in: 0,
|
|
3105
|
+
out: src.duration ?? 0,
|
|
3106
|
+
start
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
this.afterMutation();
|
|
3110
|
+
return src;
|
|
3111
|
+
}
|
|
3112
|
+
// ---- project --------------------------------------------------------
|
|
3113
|
+
setProject(project) {
|
|
3114
|
+
this.pushHistory();
|
|
3115
|
+
this.project = normalizeProject(project);
|
|
3116
|
+
this.engine.setProject(this.project);
|
|
3117
|
+
this.bus.emit("change", { project: this.getProject() });
|
|
3118
|
+
this.emitHistory();
|
|
3119
|
+
this.ui.resetAutoFit();
|
|
3120
|
+
this.ui.render();
|
|
3121
|
+
}
|
|
3122
|
+
getProject() {
|
|
3123
|
+
return JSON.parse(JSON.stringify(this.project));
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* Restore the "fresh import" state: same media library, single
|
|
3127
|
+
* default video track, one full-length clip per video source laid
|
|
3128
|
+
* end-to-end. This mirrors the initial layout a host would get
|
|
3129
|
+
* after dropping their videos in, so "reset" feels like "start
|
|
3130
|
+
* over without re-importing" rather than "wipe everything".
|
|
3131
|
+
*
|
|
3132
|
+
* Goes through the regular history stack — ⌘Z brings the previous
|
|
3133
|
+
* edit back. Sources without a known duration are skipped (they'd
|
|
3134
|
+
* render as zero-width clips, which is worse than absent).
|
|
3135
|
+
*/
|
|
3136
|
+
reset() {
|
|
3137
|
+
const sources = this.project.sources.map((s) => ({ ...s }));
|
|
3138
|
+
const trackId = createId("track");
|
|
3139
|
+
const clips = [];
|
|
3140
|
+
let start = 0;
|
|
3141
|
+
for (const src of sources) {
|
|
3142
|
+
if (src.kind !== "video") continue;
|
|
3143
|
+
const dur = src.duration;
|
|
3144
|
+
if (!dur || dur <= 0) continue;
|
|
3145
|
+
clips.push({
|
|
3146
|
+
id: createId("clip"),
|
|
3147
|
+
sourceId: src.id,
|
|
3148
|
+
in: 0,
|
|
3149
|
+
out: dur,
|
|
3150
|
+
start
|
|
3151
|
+
});
|
|
3152
|
+
start += dur;
|
|
3153
|
+
}
|
|
3154
|
+
this.setProject({
|
|
3155
|
+
version: 1,
|
|
3156
|
+
sources,
|
|
3157
|
+
tracks: [{ id: trackId, kind: "video", clips }]
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
setTheme(theme) {
|
|
3161
|
+
applyTheme(this.container, theme);
|
|
3162
|
+
this.ui.render();
|
|
3163
|
+
}
|
|
3164
|
+
setLocale(locale) {
|
|
3165
|
+
this.locale = mergeLocale(locale);
|
|
3166
|
+
this.ui.setLocale(this.locale);
|
|
3167
|
+
}
|
|
3168
|
+
/** Internal — UI reads the resolved locale here on each render. */
|
|
3169
|
+
getLocale() {
|
|
3170
|
+
return this.locale;
|
|
3171
|
+
}
|
|
3172
|
+
requestExport() {
|
|
3173
|
+
this.bus.emit("export", { project: this.getProject() });
|
|
3174
|
+
}
|
|
3175
|
+
// ---- viewport -------------------------------------------------------
|
|
3176
|
+
getScale() {
|
|
3177
|
+
return this.pxPerSec;
|
|
3178
|
+
}
|
|
3179
|
+
setScale(pxPerSec) {
|
|
3180
|
+
const next = clampScale2(pxPerSec);
|
|
3181
|
+
if (next === this.pxPerSec) return;
|
|
3182
|
+
this.pxPerSec = next;
|
|
3183
|
+
this.bus.emit("scaleChange", { pxPerSec: next });
|
|
3184
|
+
this.ui.render();
|
|
3185
|
+
}
|
|
3186
|
+
getSnap() {
|
|
3187
|
+
return this.snap;
|
|
3188
|
+
}
|
|
3189
|
+
setSnap(snap) {
|
|
3190
|
+
if (snap === this.snap) return;
|
|
3191
|
+
this.snap = snap;
|
|
3192
|
+
this.bus.emit("snapChange", { snap });
|
|
3193
|
+
this.ui.render();
|
|
3194
|
+
}
|
|
3195
|
+
/** Snap a candidate ms to the nearest snappable surface within SNAP_PX. */
|
|
3196
|
+
snapMs(timeMs, ignoreClipId) {
|
|
3197
|
+
if (!this.snap) return timeMs;
|
|
3198
|
+
const snapTol = Math.max(20, SNAP_PX2 / this.pxPerSec * 1e3);
|
|
3199
|
+
const targets = [0, this.engine.getTime()];
|
|
3200
|
+
for (const t of this.project.tracks) {
|
|
3201
|
+
for (const c of t.clips) {
|
|
3202
|
+
if (c.id === ignoreClipId) continue;
|
|
3203
|
+
targets.push(c.start, clipEnd(c));
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
let best = timeMs;
|
|
3207
|
+
let bestDist = snapTol;
|
|
3208
|
+
for (const t of targets) {
|
|
3209
|
+
const d = Math.abs(t - timeMs);
|
|
3210
|
+
if (d < bestDist) {
|
|
3211
|
+
bestDist = d;
|
|
3212
|
+
best = t;
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
return best;
|
|
3216
|
+
}
|
|
3217
|
+
// ---- selection ------------------------------------------------------
|
|
3218
|
+
getSelection() {
|
|
3219
|
+
return this.selectedClipId;
|
|
3220
|
+
}
|
|
3221
|
+
setSelection(clipId) {
|
|
3222
|
+
if (clipId === this.selectedClipId) return;
|
|
3223
|
+
this.selectedClipId = clipId;
|
|
3224
|
+
this.bus.emit("selectionChange", { clipId });
|
|
3225
|
+
this.ui.render();
|
|
3226
|
+
}
|
|
3227
|
+
// ---- history --------------------------------------------------------
|
|
3228
|
+
canUndo() {
|
|
3229
|
+
return this.history.canUndo();
|
|
3230
|
+
}
|
|
3231
|
+
canRedo() {
|
|
3232
|
+
return this.history.canRedo();
|
|
3233
|
+
}
|
|
3234
|
+
undo() {
|
|
3235
|
+
const prev = this.history.undo(this.project);
|
|
3236
|
+
if (!prev) return false;
|
|
3237
|
+
this.project = prev;
|
|
3238
|
+
this.engine.setProject(this.project);
|
|
3239
|
+
this.bus.emit("change", { project: this.getProject() });
|
|
3240
|
+
this.emitHistory();
|
|
3241
|
+
this.ui.render();
|
|
3242
|
+
return true;
|
|
3243
|
+
}
|
|
3244
|
+
redo() {
|
|
3245
|
+
const next = this.history.redo(this.project);
|
|
3246
|
+
if (!next) return false;
|
|
3247
|
+
this.project = next;
|
|
3248
|
+
this.engine.setProject(this.project);
|
|
3249
|
+
this.bus.emit("change", { project: this.getProject() });
|
|
3250
|
+
this.emitHistory();
|
|
3251
|
+
this.ui.render();
|
|
3252
|
+
return true;
|
|
3253
|
+
}
|
|
3254
|
+
// ---- events ---------------------------------------------------------
|
|
3255
|
+
on(event, handler) {
|
|
3256
|
+
return this.bus.on(event, handler);
|
|
3257
|
+
}
|
|
3258
|
+
off(event, handler) {
|
|
3259
|
+
this.bus.off(event, handler);
|
|
3260
|
+
}
|
|
3261
|
+
destroy() {
|
|
3262
|
+
if (this.destroyed) return;
|
|
3263
|
+
this.destroyed = true;
|
|
3264
|
+
this.engine.destroy();
|
|
3265
|
+
this.ui.destroy();
|
|
3266
|
+
this.bus.clear();
|
|
3267
|
+
this.history.clear();
|
|
3268
|
+
}
|
|
3269
|
+
// ---- internals ------------------------------------------------------
|
|
3270
|
+
appendTrack(opts) {
|
|
3271
|
+
const t = { id: createId("track"), kind: opts.kind, clips: [] };
|
|
3272
|
+
this.project.tracks.push(t);
|
|
3273
|
+
return t;
|
|
3274
|
+
}
|
|
3275
|
+
resolveTrimTarget(timeMs) {
|
|
3276
|
+
if (this.selectedClipId) {
|
|
3277
|
+
const trk = findTrackOfClip(this.project, this.selectedClipId);
|
|
3278
|
+
const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
|
|
3279
|
+
if (trk && cl && timeMs >= cl.start && timeMs <= clipEnd(cl)) {
|
|
3280
|
+
return { track: trk, clip: cl };
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
for (const trk of this.project.tracks) {
|
|
3284
|
+
const cl = findClipContaining(trk, timeMs);
|
|
3285
|
+
if (cl) return { track: trk, clip: cl };
|
|
3286
|
+
}
|
|
3287
|
+
return null;
|
|
3288
|
+
}
|
|
3289
|
+
pushHistory() {
|
|
3290
|
+
this.history.push(this.project);
|
|
3291
|
+
this.emitHistory();
|
|
3292
|
+
}
|
|
3293
|
+
emitHistory() {
|
|
3294
|
+
this.bus.emit("historyChange", {
|
|
3295
|
+
canUndo: this.history.canUndo(),
|
|
3296
|
+
canRedo: this.history.canRedo()
|
|
3297
|
+
});
|
|
3298
|
+
}
|
|
3299
|
+
afterMutation() {
|
|
3300
|
+
this.engine.setProject(this.project);
|
|
3301
|
+
this.bus.emit("change", { project: this.getProject() });
|
|
3302
|
+
this.ui.render();
|
|
3303
|
+
}
|
|
3304
|
+
handleSourceMetadata(sourceId, durMs) {
|
|
3305
|
+
const src = this.project.sources.find((s) => s.id === sourceId);
|
|
3306
|
+
let mutated = false;
|
|
3307
|
+
if (src && !src.duration) {
|
|
3308
|
+
src.duration = durMs;
|
|
3309
|
+
mutated = true;
|
|
3310
|
+
}
|
|
3311
|
+
for (const t of this.project.tracks) {
|
|
3312
|
+
for (const c of t.clips) {
|
|
3313
|
+
if (c.sourceId === sourceId && c.out === 0) {
|
|
3314
|
+
c.out = durMs;
|
|
3315
|
+
mutated = true;
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
this.bus.emit("ready", { sourceId });
|
|
3320
|
+
if (mutated) {
|
|
3321
|
+
this.engine.setProject(this.project);
|
|
3322
|
+
this.bus.emit("change", { project: this.getProject() });
|
|
3323
|
+
this.ui.render();
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
};
|
|
3327
|
+
function clampScale2(s) {
|
|
3328
|
+
return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
exports.Editor = Editor;
|
|
3332
|
+
exports.HEADER_WIDTH = HEADER_WIDTH;
|
|
3333
|
+
exports.RULER_HEIGHT = RULER_HEIGHT;
|
|
3334
|
+
exports.TRACK_HEIGHT = TRACK_HEIGHT;
|
|
3335
|
+
exports.Timeline = Timeline;
|
|
3336
|
+
exports.createEmptyProject = createEmptyProject;
|
|
3337
|
+
exports.createId = createId;
|
|
3338
|
+
exports.formatLabel = formatLabel;
|
|
3339
|
+
exports.localeEn = localeEn;
|
|
3340
|
+
exports.localeZh = localeZh;
|
|
3341
|
+
exports.mergeLocale = mergeLocale;
|
|
3342
|
+
exports.normalizeProject = normalizeProject;
|
|
3343
|
+
//# sourceMappingURL=index.cjs.map
|
|
3344
|
+
//# sourceMappingURL=index.cjs.map
|