kingkont 0.7.39 → 0.7.40
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/assets/PROJECT_CLAUDE.md +31 -132
- package/bin/kingkont.js +59 -49
- package/index.html +9 -10137
- package/lib/cli.js +504 -0
- package/lib/projectFs.js +391 -0
- package/lib/providers.js +689 -0
- package/lib/settings.js +70 -0
- package/main.js +22 -0
- package/package.json +5 -1
- package/preload.js +7 -0
- package/renderer/board.js +1522 -0
- package/renderer/generate.js +1727 -0
- package/renderer/media.js +1413 -0
- package/renderer/settings.js +1128 -0
- package/renderer/state.js +566 -0
- package/renderer/styles.css +1018 -0
- package/renderer/timeline.js +2836 -0
- package/server.js +103 -787
- package/settings.html +46 -0
- package/skill/SKILL.md +160 -78
|
@@ -0,0 +1,2836 @@
|
|
|
1
|
+
// renderer/timeline.js — Timeline (CapCut-style), Web Audio engine, replicas, native preview panel
|
|
2
|
+
//
|
|
3
|
+
// Этот модуль был выделен из index.html (раньше всё было в одном <script>
|
|
4
|
+
// блоке на 9123 строки). Все модули загружаются как plain <script>
|
|
5
|
+
// в одном глобальном scope — поэтому функции/переменные между файлами
|
|
6
|
+
// видят друг друга по именам, без import/export. Порядок загрузки
|
|
7
|
+
// важен: см. <script> теги внизу index.html.
|
|
8
|
+
|
|
9
|
+
// =================== Таймлайн (CapCut-style) ===================
|
|
10
|
+
function defaultTimeline() {
|
|
11
|
+
return {
|
|
12
|
+
tracks: [
|
|
13
|
+
{ id: crypto.randomUUID(), name: 'Видео', kind: 'video', clips: [] },
|
|
14
|
+
{ id: crypto.randomUUID(), name: 'Голос', kind: 'audio', clips: [] },
|
|
15
|
+
{ id: crypto.randomUUID(), name: 'Эффекты', kind: 'audio', clips: [] },
|
|
16
|
+
{ id: crypto.randomUUID(), name: 'Аудио', kind: 'audio', clips: [] },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getTimeline() {
|
|
22
|
+
if (!state.currentBoard) return null;
|
|
23
|
+
let tl = state.currentBoard.metadata.timeline;
|
|
24
|
+
// Миграция: старый плоский массив → 3 дорожки
|
|
25
|
+
if (Array.isArray(tl)) {
|
|
26
|
+
const migrated = defaultTimeline();
|
|
27
|
+
for (const c of tl) {
|
|
28
|
+
const targetKind = c.type === 'audio' ? 'audio' : 'video';
|
|
29
|
+
const t = migrated.tracks.find(x => x.kind === targetKind);
|
|
30
|
+
if (t) t.clips.push(c);
|
|
31
|
+
}
|
|
32
|
+
state.currentBoard.metadata.timeline = migrated;
|
|
33
|
+
tl = migrated;
|
|
34
|
+
scheduleSave();
|
|
35
|
+
} else if (!tl || typeof tl !== 'object' || !Array.isArray(tl.tracks)) {
|
|
36
|
+
tl = defaultTimeline();
|
|
37
|
+
state.currentBoard.metadata.timeline = tl;
|
|
38
|
+
}
|
|
39
|
+
return tl;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultClipDuration(node) {
|
|
43
|
+
if (node.type === 'image') return 3;
|
|
44
|
+
return 5;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findClipById(tl, clipId) {
|
|
48
|
+
for (const t of tl.tracks) {
|
|
49
|
+
const i = t.clips.findIndex(c => c.id === clipId);
|
|
50
|
+
if (i >= 0) return { track: t, idx: i, clip: t.clips[i] };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// === Снимок таймлайна для undo (вызывать ДО мутации) ===
|
|
56
|
+
function pushTimelineUndo(label = 'Изменение таймлайна') {
|
|
57
|
+
pushHistory(label);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Разрезать клип на 2 части в timelineTime (timeline-секундах). Возвращает true если успешно.
|
|
61
|
+
function splitClipAt(clip, track, timelineTime) {
|
|
62
|
+
const start = +clip.start || 0;
|
|
63
|
+
const dur = +clip.duration || 0;
|
|
64
|
+
if (dur <= 0) return false;
|
|
65
|
+
const inClip = timelineTime - start;
|
|
66
|
+
// не режем у самых краёв (минимум 0.05с с каждой стороны)
|
|
67
|
+
if (inClip <= 0.05 || inClip >= dur - 0.05) return false;
|
|
68
|
+
const right = {
|
|
69
|
+
...clip,
|
|
70
|
+
id: crypto.randomUUID(),
|
|
71
|
+
start: +(start + inClip).toFixed(3),
|
|
72
|
+
duration: +(dur - inClip).toFixed(3),
|
|
73
|
+
trimStart: +((+clip.trimStart || 0) + inClip).toFixed(3),
|
|
74
|
+
_durationLoaded: clip._durationLoaded,
|
|
75
|
+
sourceDuration: clip.sourceDuration,
|
|
76
|
+
nodeId: clip.nodeId, // обе половинки ссылаются на ту же ноду — удобно для регенерации
|
|
77
|
+
};
|
|
78
|
+
// group: если был — оставим обе половинки в группе
|
|
79
|
+
// (right уже наследует groupId через ...clip)
|
|
80
|
+
clip.duration = +inClip.toFixed(3);
|
|
81
|
+
const idx = track.clips.findIndex(c => c.id === clip.id);
|
|
82
|
+
track.clips.splice(idx + 1, 0, right);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// === Группы клипов: вернуть всех членов группы (без указанного) ===
|
|
87
|
+
function getGroupSiblings(groupId, tl, excludeId) {
|
|
88
|
+
if (!groupId || !tl?.tracks) return [];
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const t of tl.tracks) {
|
|
91
|
+
for (const c of t.clips) {
|
|
92
|
+
if (c.id !== excludeId && c.groupId === groupId) out.push(c);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function getGroupClips(groupId, tl) {
|
|
98
|
+
return getGroupSiblings(groupId, tl, null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Drop клипа на холст: если есть привязанная нода — переносим её, иначе создаём.
|
|
102
|
+
// pushHistory вызван снаружи.
|
|
103
|
+
async function dropClipOnCanvas(clip, clientX, clientY) {
|
|
104
|
+
if (!state.currentBoard) return;
|
|
105
|
+
const rect = canvas.getBoundingClientRect();
|
|
106
|
+
const x = Math.max(0, (clientX - rect.left) / state.zoom - 140);
|
|
107
|
+
const y = Math.max(0, (clientY - rect.top) / state.zoom - 80);
|
|
108
|
+
let node = clip.nodeId
|
|
109
|
+
? state.currentBoard.metadata.nodes.find(n => n.id === clip.nodeId)
|
|
110
|
+
: null;
|
|
111
|
+
if (node) {
|
|
112
|
+
node.x = x; node.y = y;
|
|
113
|
+
const el = canvas.querySelector(`.node[data-id="${node.id}"]`);
|
|
114
|
+
if (el) {
|
|
115
|
+
el.style.left = x + 'px';
|
|
116
|
+
el.style.top = y + 'px';
|
|
117
|
+
// Поднимаем z-order
|
|
118
|
+
const arr = state.currentBoard.metadata.nodes;
|
|
119
|
+
const idx = arr.indexOf(node);
|
|
120
|
+
if (idx >= 0 && idx < arr.length - 1) { arr.splice(idx, 1); arr.push(node); }
|
|
121
|
+
canvas.appendChild(el);
|
|
122
|
+
} else {
|
|
123
|
+
// ноды нет в DOM (не отрисована) — добавляем
|
|
124
|
+
canvas.appendChild(await createNodeEl(node));
|
|
125
|
+
}
|
|
126
|
+
clearSelection();
|
|
127
|
+
state.selectedNodeIds.add(node.id);
|
|
128
|
+
renderSelection();
|
|
129
|
+
renderConnections();
|
|
130
|
+
scheduleSave();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Нет привязки — создадим ноду из клипа
|
|
134
|
+
node = {
|
|
135
|
+
id: crypto.randomUUID(),
|
|
136
|
+
type: clip.type,
|
|
137
|
+
file: clip.file,
|
|
138
|
+
name: clip.name || (clip.file || '').split('/').pop(),
|
|
139
|
+
x, y,
|
|
140
|
+
};
|
|
141
|
+
state.currentBoard.metadata.nodes.push(node);
|
|
142
|
+
canvas.appendChild(await createNodeEl(node));
|
|
143
|
+
clip.nodeId = node.id; // привязали клип к новой ноде
|
|
144
|
+
clearSelection();
|
|
145
|
+
state.selectedNodeIds.add(node.id);
|
|
146
|
+
renderSelection();
|
|
147
|
+
scheduleSave();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Удалить выделенные клипы (с history). Файл-ноды на холсте остаются — клип это
|
|
151
|
+
// просто ссылка на файл/ноду, удаление клипа не трогает источник.
|
|
152
|
+
async function deleteSelectedClips() {
|
|
153
|
+
const tl = getTimeline();
|
|
154
|
+
if (!tl?.tracks || !state.selectedClipIds.size) return;
|
|
155
|
+
const ids = new Set(state.selectedClipIds);
|
|
156
|
+
pushHistory('Удаление клипов');
|
|
157
|
+
for (const t of tl.tracks) {
|
|
158
|
+
t.clips = t.clips.filter(c => !ids.has(c.id));
|
|
159
|
+
}
|
|
160
|
+
state.selectedClipIds.clear();
|
|
161
|
+
scheduleSave();
|
|
162
|
+
await renderTimeline();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// === Выделение клипов и дорожек на таймлайне ===
|
|
166
|
+
function clearTimelineSelection() {
|
|
167
|
+
state.selectedClipIds.clear();
|
|
168
|
+
state.selectedTrackIds.clear();
|
|
169
|
+
applyTimelineSelectionVisuals();
|
|
170
|
+
}
|
|
171
|
+
function applyTimelineSelectionVisuals() {
|
|
172
|
+
const tracksEl = $('timelineTracks');
|
|
173
|
+
if (!tracksEl) return;
|
|
174
|
+
for (const el of tracksEl.querySelectorAll('.timeline-clip')) {
|
|
175
|
+
el.classList.toggle('selected', state.selectedClipIds.has(el.dataset.id));
|
|
176
|
+
}
|
|
177
|
+
for (const el of tracksEl.querySelectorAll('.timeline-track')) {
|
|
178
|
+
el.classList.toggle('selected', state.selectedTrackIds.has(el.dataset.trackId));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function selectClipById(clipId, additive) {
|
|
182
|
+
const tl = state.currentBoard?.metadata?.timeline;
|
|
183
|
+
// Если клип в группе — оперируем всей группой как единым целым
|
|
184
|
+
let ids = [clipId];
|
|
185
|
+
if (tl?.tracks) {
|
|
186
|
+
const clip = tl.tracks.flatMap(t => t.clips).find(c => c.id === clipId);
|
|
187
|
+
if (clip?.groupId) {
|
|
188
|
+
ids = tl.tracks.flatMap(t => t.clips).filter(c => c.groupId === clip.groupId).map(c => c.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!additive) {
|
|
192
|
+
state.selectedClipIds.clear();
|
|
193
|
+
state.selectedTrackIds.clear();
|
|
194
|
+
} else {
|
|
195
|
+
state.selectedTrackIds.clear();
|
|
196
|
+
}
|
|
197
|
+
const allSelected = ids.every(id => state.selectedClipIds.has(id));
|
|
198
|
+
for (const id of ids) {
|
|
199
|
+
if (allSelected) state.selectedClipIds.delete(id);
|
|
200
|
+
else state.selectedClipIds.add(id);
|
|
201
|
+
}
|
|
202
|
+
applyTimelineSelectionVisuals();
|
|
203
|
+
}
|
|
204
|
+
function selectTrackById(trackId, additive) {
|
|
205
|
+
if (!additive) {
|
|
206
|
+
state.selectedTrackIds.clear();
|
|
207
|
+
state.selectedClipIds.clear();
|
|
208
|
+
} else {
|
|
209
|
+
state.selectedClipIds.clear();
|
|
210
|
+
}
|
|
211
|
+
if (state.selectedTrackIds.has(trackId)) state.selectedTrackIds.delete(trackId);
|
|
212
|
+
else state.selectedTrackIds.add(trackId);
|
|
213
|
+
applyTimelineSelectionVisuals();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Скопировать клипы (по списку id) в state.clipboard в формате {node, blob}
|
|
217
|
+
async function copyTimelineClipsToClipboard(clipIds) {
|
|
218
|
+
if (!state.currentBoard || !clipIds?.length) return 0;
|
|
219
|
+
const tl = getTimeline();
|
|
220
|
+
if (!tl) return 0;
|
|
221
|
+
const items = [];
|
|
222
|
+
for (const id of clipIds) {
|
|
223
|
+
const found = findClipById(tl, id);
|
|
224
|
+
if (!found) continue;
|
|
225
|
+
const clip = found.clip;
|
|
226
|
+
if (!clip.file) continue;
|
|
227
|
+
let blob = null;
|
|
228
|
+
try {
|
|
229
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, clip.file);
|
|
230
|
+
blob = await fh.getFile();
|
|
231
|
+
} catch (e) { console.warn('clip blob load failed', clip.file, e); }
|
|
232
|
+
if (!blob) continue;
|
|
233
|
+
items.push({
|
|
234
|
+
node: {
|
|
235
|
+
id: crypto.randomUUID(),
|
|
236
|
+
type: clip.type,
|
|
237
|
+
file: clip.file,
|
|
238
|
+
name: clip.name,
|
|
239
|
+
},
|
|
240
|
+
blob,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (!items.length) return 0;
|
|
244
|
+
state.clipboard = items;
|
|
245
|
+
console.log(`Скопировано в буфер с таймлайна: ${items.length}`);
|
|
246
|
+
return items.length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Перепаковать клипы дорожки последовательно (без зазоров)
|
|
250
|
+
function restackTrack(track) {
|
|
251
|
+
let cum = 0;
|
|
252
|
+
for (const c of track.clips) {
|
|
253
|
+
c.start = cum;
|
|
254
|
+
cum += +c.duration || 0;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Ripple-insert: вставить clip в targetTrack по позиции proposedStart, сдвинув соседей
|
|
259
|
+
function rippleInsertClip(targetTrack, clip, sourceTrack, proposedStart) {
|
|
260
|
+
// Удаляем clip из его текущего места (в обоих track'ах на всякий случай)
|
|
261
|
+
if (sourceTrack !== targetTrack) {
|
|
262
|
+
sourceTrack.clips = sourceTrack.clips.filter(c => c.id !== clip.id);
|
|
263
|
+
}
|
|
264
|
+
targetTrack.clips = targetTrack.clips.filter(c => c.id !== clip.id);
|
|
265
|
+
targetTrack.clips.sort((a, b) => (+a.start || 0) - (+b.start || 0));
|
|
266
|
+
// Точка вставки — по середине клипов: куда ближе, туда и попадаем
|
|
267
|
+
let insertAt = targetTrack.clips.length;
|
|
268
|
+
for (let i = 0; i < targetTrack.clips.length; i++) {
|
|
269
|
+
const c = targetTrack.clips[i];
|
|
270
|
+
const mid = (+c.start || 0) + (+c.duration || 0) / 2;
|
|
271
|
+
if (proposedStart < mid) { insertAt = i; break; }
|
|
272
|
+
}
|
|
273
|
+
targetTrack.clips.splice(insertAt, 0, clip);
|
|
274
|
+
restackTrack(targetTrack);
|
|
275
|
+
if (sourceTrack !== targetTrack) restackTrack(sourceTrack);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Вставить элементы буфера на дорожку таймлайна, начиная с dropTime
|
|
279
|
+
async function pasteClipboardToTimeline(track, dropTime) {
|
|
280
|
+
if (!state.currentBoard || !state.clipboard?.length) return;
|
|
281
|
+
const items = state.clipboard.filter(it => {
|
|
282
|
+
if (it.node.type === 'text') return false;
|
|
283
|
+
if (!it.blob && !it.node.file) return false;
|
|
284
|
+
return track.kind === 'video'
|
|
285
|
+
? (it.node.type === 'video' || it.node.type === 'image')
|
|
286
|
+
: it.node.type === 'audio';
|
|
287
|
+
});
|
|
288
|
+
if (!items.length) return;
|
|
289
|
+
let cursor = Math.max(0, dropTime || 0);
|
|
290
|
+
for (const item of items) {
|
|
291
|
+
const src = item.node;
|
|
292
|
+
let newFile;
|
|
293
|
+
if (item.blob) {
|
|
294
|
+
const sub = boardSubdirForType(src.type);
|
|
295
|
+
const baseName = src.file ? src.file.split('/').pop() : `pasted-${Date.now()}`;
|
|
296
|
+
const dir = sub ? await getOrCreateBoardSubdir(state.currentBoard.handle, sub) : state.currentBoard.handle;
|
|
297
|
+
const newName = await uniqueName(dir, baseName);
|
|
298
|
+
await writeFile(dir, newName, item.blob);
|
|
299
|
+
newFile = sub ? `${sub}/${newName}` : newName;
|
|
300
|
+
} else {
|
|
301
|
+
newFile = src.file;
|
|
302
|
+
}
|
|
303
|
+
const newClip = {
|
|
304
|
+
id: crypto.randomUUID(),
|
|
305
|
+
nodeId: null,
|
|
306
|
+
type: src.type,
|
|
307
|
+
file: newFile,
|
|
308
|
+
name: src.name || newFile.split('/').pop(),
|
|
309
|
+
duration: src.type === 'image' ? 3 : 5,
|
|
310
|
+
start: 0,
|
|
311
|
+
};
|
|
312
|
+
if (track.kind === 'video') {
|
|
313
|
+
track.clips.push(newClip);
|
|
314
|
+
rippleInsertClip(track, newClip, track, cursor);
|
|
315
|
+
cursor = (newClip.start || 0) + (newClip.duration || 5);
|
|
316
|
+
} else {
|
|
317
|
+
newClip.start = cursor;
|
|
318
|
+
track.clips.push(newClip);
|
|
319
|
+
cursor += newClip.duration || 5;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
scheduleSave();
|
|
323
|
+
await renderTimeline();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Открыть gen-модалку для генерации аудио в нужной точке таймлайна
|
|
327
|
+
async function openGenAudioForTimeline(charInfo, track, time) {
|
|
328
|
+
state.timelineGenTarget = { trackId: track.id, time: Math.max(0, +time || 0), charName: charInfo.name };
|
|
329
|
+
state.genKind = 'audio';
|
|
330
|
+
state.regenerateTarget = null;
|
|
331
|
+
state.sourceRef = null;
|
|
332
|
+
state.pendingConnectionFrom = null;
|
|
333
|
+
// UI таблиц/строк
|
|
334
|
+
document.querySelectorAll('#genModal [data-kind]').forEach(b =>
|
|
335
|
+
b.classList.toggle('active', b.dataset.kind === 'audio'));
|
|
336
|
+
$('imageModelRow').style.display = 'none';
|
|
337
|
+
|
|
338
|
+
$('imageOptionsRow').style.display = 'none';
|
|
339
|
+
$('videoOptionsRow').style.display = 'none';
|
|
340
|
+
|
|
341
|
+
$('videoModelRow').style.display = 'none';
|
|
342
|
+
$('voiceRow').style.display = '';
|
|
343
|
+
|
|
344
|
+
$('ttsModelRow').style.display = '';
|
|
345
|
+
$('tonesRow').style.display = '';
|
|
346
|
+
$('sourceRefRow').style.display = 'none';
|
|
347
|
+
$('charsPickRow').style.display = 'none';
|
|
348
|
+
$('locPickRow').style.display = 'none';
|
|
349
|
+
resetPicks();
|
|
350
|
+
await loadVoices();
|
|
351
|
+
if (charInfo.voice) $('genVoice').value = charInfo.voice;
|
|
352
|
+
state.toneSuggestions = (charInfo.commonTones || []).slice();
|
|
353
|
+
// Активные тоны: обычный тон (если задан) → последние использованные → пусто
|
|
354
|
+
if (charInfo.tone) state.activeTones = [charInfo.tone];
|
|
355
|
+
else if (Array.isArray(charInfo.lastTones) && charInfo.lastTones.length) state.activeTones = [...charInfo.lastTones];
|
|
356
|
+
else state.activeTones = [];
|
|
357
|
+
renderTones();
|
|
358
|
+
$('genPrompt').value = '';
|
|
359
|
+
$('genPrompt').setAttribute('placeholder', `Что должен сказать ${charInfo.name}...`);
|
|
360
|
+
$('genStatus').textContent = '';
|
|
361
|
+
$('genStatus').className = 'status';
|
|
362
|
+
$('genSubmit').disabled = false;
|
|
363
|
+
closeMentionPopup();
|
|
364
|
+
$('genModal').classList.remove('hidden');
|
|
365
|
+
setTimeout(() => $('genPrompt').focus(), 50);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Меню после ПКМ-drag: Копировать / Заменить
|
|
369
|
+
function showRightDragMenu(clientX, clientY, ctx) {
|
|
370
|
+
const menu = $('nodeMenu');
|
|
371
|
+
menu.innerHTML = '';
|
|
372
|
+
const add = (label, fn, opts = {}) => {
|
|
373
|
+
const b = document.createElement('button');
|
|
374
|
+
b.textContent = label;
|
|
375
|
+
if (opts.disabled) { b.disabled = true; b.style.opacity = '0.4'; }
|
|
376
|
+
if (opts.danger) b.style.color = '#f88';
|
|
377
|
+
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
378
|
+
menu.appendChild(b);
|
|
379
|
+
};
|
|
380
|
+
add('⎘ Копировать сюда', () => doRightDragAction({ ...ctx, action: 'copy' }));
|
|
381
|
+
add('⇄ Заменить', () => doRightDragAction({ ...ctx, action: 'replace' }), { disabled: !ctx.victimId });
|
|
382
|
+
add('Отмена', () => {});
|
|
383
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
384
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function doRightDragAction(ctx) {
|
|
388
|
+
const { sourceClip, sourceTrack, targetTrack, victimId, dropTime, action } = ctx;
|
|
389
|
+
if (action === 'copy') {
|
|
390
|
+
const dup = { ...sourceClip, id: crypto.randomUUID(), _durationLoaded: false };
|
|
391
|
+
if (targetTrack.kind === 'video') {
|
|
392
|
+
targetTrack.clips.push(dup);
|
|
393
|
+
rippleInsertClip(targetTrack, dup, targetTrack, dropTime);
|
|
394
|
+
} else {
|
|
395
|
+
dup.start = Math.max(0, dropTime);
|
|
396
|
+
targetTrack.clips.push(dup);
|
|
397
|
+
}
|
|
398
|
+
} else if (action === 'replace') {
|
|
399
|
+
if (!victimId) return;
|
|
400
|
+
replaceClipInTrack(targetTrack, sourceClip, sourceTrack, victimId);
|
|
401
|
+
}
|
|
402
|
+
scheduleSave();
|
|
403
|
+
renderTimeline();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Replace: заменить клип victimId в targetTrack на clip
|
|
407
|
+
function replaceClipInTrack(targetTrack, clip, sourceTrack, victimId) {
|
|
408
|
+
if (clip.id === victimId) return;
|
|
409
|
+
// Убираем clip из исходного места
|
|
410
|
+
if (sourceTrack !== targetTrack) {
|
|
411
|
+
sourceTrack.clips = sourceTrack.clips.filter(c => c.id !== clip.id);
|
|
412
|
+
}
|
|
413
|
+
// В target: убираем clip-двойник (если он там был), затем заменяем victim
|
|
414
|
+
targetTrack.clips = targetTrack.clips.filter(c => c.id !== clip.id);
|
|
415
|
+
const victimIdx = targetTrack.clips.findIndex(c => c.id === victimId);
|
|
416
|
+
if (victimIdx < 0) {
|
|
417
|
+
targetTrack.clips.push(clip);
|
|
418
|
+
} else {
|
|
419
|
+
targetTrack.clips.splice(victimIdx, 1, clip);
|
|
420
|
+
}
|
|
421
|
+
restackTrack(targetTrack);
|
|
422
|
+
if (sourceTrack !== targetTrack) restackTrack(sourceTrack);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function addToTimeline(node) {
|
|
426
|
+
if (!state.currentBoard) return;
|
|
427
|
+
if (!['image', 'video', 'audio'].includes(node.type) || !node.file) return;
|
|
428
|
+
const tl = getTimeline();
|
|
429
|
+
const wantedKind = node.type === 'audio' ? 'audio' : 'video';
|
|
430
|
+
// Audio с subKind подбираем профильную дорожку по имени:
|
|
431
|
+
// sfx → «Эффекты», music → «Музыка», обычный голос → «Голос»/«Аудио».
|
|
432
|
+
const subKind = node.generated?.subKind;
|
|
433
|
+
const nameMatch = (track, kw) => kw.test((track.name || '').toLowerCase());
|
|
434
|
+
let target = null;
|
|
435
|
+
if (wantedKind === 'audio') {
|
|
436
|
+
if (subKind === 'sfx') {
|
|
437
|
+
target = tl.tracks.find(t => t.kind === 'audio' && nameMatch(t, /эффект|sfx|sound|fx/i));
|
|
438
|
+
} else if (subKind === 'music') {
|
|
439
|
+
target = tl.tracks.find(t => t.kind === 'audio' && nameMatch(t, /музык|music|саунд/i));
|
|
440
|
+
} else {
|
|
441
|
+
// Голос / реплика — предпочитаем дорожку с «голос/voice/speech/диалог».
|
|
442
|
+
target = tl.tracks.find(t => t.kind === 'audio' && nameMatch(t, /голос|voice|speech|диалог|реплик/i));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (!target) target = tl.tracks.find(t => t.kind === wantedKind) || tl.tracks[0];
|
|
446
|
+
if (!target) {
|
|
447
|
+
alert('Нет дорожек. Создай хотя бы одну.');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const trackEnd = target.clips.reduce((m, c) =>
|
|
451
|
+
Math.max(m, (+c.start || 0) + (+c.duration || 0)), 0);
|
|
452
|
+
target.clips.push({
|
|
453
|
+
id: crypto.randomUUID(),
|
|
454
|
+
nodeId: node.id, type: node.type, file: node.file,
|
|
455
|
+
name: node.name || node.file.split('/').pop(),
|
|
456
|
+
duration: defaultClipDuration(node),
|
|
457
|
+
start: trackEnd,
|
|
458
|
+
});
|
|
459
|
+
scheduleSave();
|
|
460
|
+
$('timelinePanel').classList.remove('hidden');
|
|
461
|
+
await renderTimeline();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function addTrack(name = 'Новая', kind = 'audio') {
|
|
465
|
+
const tl = getTimeline();
|
|
466
|
+
tl.tracks.push({ id: crypto.randomUUID(), name, kind, clips: [] });
|
|
467
|
+
scheduleSave();
|
|
468
|
+
renderTimeline();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function removeTrack(trackId) {
|
|
472
|
+
const tl = getTimeline();
|
|
473
|
+
const t = tl.tracks.find(x => x.id === trackId);
|
|
474
|
+
if (!t) return;
|
|
475
|
+
if (t.clips.length && !confirm(`Удалить дорожку "${t.name}" (${t.clips.length} клип(ов))?`)) return;
|
|
476
|
+
tl.tracks = tl.tracks.filter(x => x.id !== trackId);
|
|
477
|
+
scheduleSave();
|
|
478
|
+
renderTimeline();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function moveClipToTrack(clipId, targetTrackId, beforeClipId = null) {
|
|
482
|
+
const tl = getTimeline();
|
|
483
|
+
const found = findClipById(tl, clipId);
|
|
484
|
+
if (!found) return;
|
|
485
|
+
const target = tl.tracks.find(x => x.id === targetTrackId);
|
|
486
|
+
if (!target) return;
|
|
487
|
+
if (found.track.id === targetTrackId && !beforeClipId) return;
|
|
488
|
+
// удаляем из старой дорожки
|
|
489
|
+
found.track.clips.splice(found.idx, 1);
|
|
490
|
+
// вставка
|
|
491
|
+
if (beforeClipId) {
|
|
492
|
+
const i = target.clips.findIndex(c => c.id === beforeClipId);
|
|
493
|
+
if (i >= 0) target.clips.splice(i, 0, found.clip);
|
|
494
|
+
else target.clips.push(found.clip);
|
|
495
|
+
} else {
|
|
496
|
+
target.clips.push(found.clip);
|
|
497
|
+
}
|
|
498
|
+
scheduleSave();
|
|
499
|
+
renderTimeline();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Snap-магнит: ближайшая «интересная» точка времени для прилипания
|
|
503
|
+
const SNAP_PX = 8;
|
|
504
|
+
function getSnapTargets(tl, excludeClipId) {
|
|
505
|
+
const t = [0];
|
|
506
|
+
for (const tr of tl.tracks) {
|
|
507
|
+
for (const c of tr.clips) {
|
|
508
|
+
if (c.id === excludeClipId) continue;
|
|
509
|
+
const s = +c.start || 0, d = +c.duration || 0;
|
|
510
|
+
t.push(s, s + d);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (typeof state.playheadTime === 'number') t.push(state.playheadTime);
|
|
514
|
+
return t;
|
|
515
|
+
}
|
|
516
|
+
function snapTime(time, targets, zoom) {
|
|
517
|
+
let best = time, bestDist = Infinity;
|
|
518
|
+
for (const tg of targets) {
|
|
519
|
+
const d = Math.abs((time - tg) * zoom);
|
|
520
|
+
if (d < SNAP_PX && d < bestDist) { best = tg; bestDist = d; }
|
|
521
|
+
}
|
|
522
|
+
return best;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function attachClipTrim(el, clip, handle, side) {
|
|
526
|
+
handle.addEventListener('mousedown', e => {
|
|
527
|
+
if (e.button !== 0) return;
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
e.stopPropagation();
|
|
530
|
+
handle.classList.add('active');
|
|
531
|
+
const startX = e.clientX;
|
|
532
|
+
const origStart = +clip.start || 0;
|
|
533
|
+
const origDuration = +clip.duration || 1;
|
|
534
|
+
const origTrim = +clip.trimStart || 0;
|
|
535
|
+
const onMove = ev => {
|
|
536
|
+
const tl = state.currentBoard?.metadata?.timeline;
|
|
537
|
+
const targets = tl ? getSnapTargets(tl, clip.id) : [0];
|
|
538
|
+
const dt = (ev.clientX - startX) / state.timelineZoom;
|
|
539
|
+
// Максимально допустимая длительность клипа: для media — sourceDuration - trimStart
|
|
540
|
+
// для image — без ограничений (картинка timeless)
|
|
541
|
+
const maxDur = (clip.type === 'image' || !clip.sourceDuration)
|
|
542
|
+
? Infinity
|
|
543
|
+
: Math.max(0.1, clip.sourceDuration - (+clip.trimStart || 0));
|
|
544
|
+
if (side === 'right') {
|
|
545
|
+
let tentativeEnd = origStart + origDuration + dt;
|
|
546
|
+
tentativeEnd = snapTime(tentativeEnd, targets, state.timelineZoom);
|
|
547
|
+
let newDur = Math.max(0.1, tentativeEnd - origStart);
|
|
548
|
+
if (clip.type !== 'image' && clip.sourceDuration) {
|
|
549
|
+
const cap = clip.sourceDuration - (+clip.trimStart || 0);
|
|
550
|
+
if (newDur > cap) newDur = cap;
|
|
551
|
+
}
|
|
552
|
+
clip.duration = newDur;
|
|
553
|
+
el.style.width = (newDur * state.timelineZoom) + 'px';
|
|
554
|
+
} else {
|
|
555
|
+
// Левая ручка: одновременно сокращаем длительность и сдвигаем start (+ trimStart для media)
|
|
556
|
+
let newStartUnclamped = origStart + dt;
|
|
557
|
+
newStartUnclamped = snapTime(newStartUnclamped, targets, state.timelineZoom);
|
|
558
|
+
let trimDelta = newStartUnclamped - origStart;
|
|
559
|
+
if (origTrim + trimDelta < 0) trimDelta = -origTrim;
|
|
560
|
+
if (origDuration - trimDelta < 0.1) trimDelta = origDuration - 0.1;
|
|
561
|
+
const newDur = origDuration - trimDelta;
|
|
562
|
+
const newStart = Math.max(0, origStart + trimDelta);
|
|
563
|
+
const newTrim = (clip.type === 'image') ? 0 : Math.max(0, origTrim + trimDelta);
|
|
564
|
+
clip.duration = newDur;
|
|
565
|
+
clip.start = newStart;
|
|
566
|
+
clip.trimStart = newTrim;
|
|
567
|
+
el.style.left = (newStart * state.timelineZoom) + 'px';
|
|
568
|
+
el.style.width = (newDur * state.timelineZoom) + 'px';
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const onUp = () => {
|
|
572
|
+
document.removeEventListener('mousemove', onMove);
|
|
573
|
+
document.removeEventListener('mouseup', onUp);
|
|
574
|
+
handle.classList.remove('active');
|
|
575
|
+
scheduleSave();
|
|
576
|
+
renderTimeline();
|
|
577
|
+
};
|
|
578
|
+
document.addEventListener('mousemove', onMove);
|
|
579
|
+
document.addEventListener('mouseup', onUp);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Стабильный цвет для группы — для подсветки полосой сверху
|
|
584
|
+
function colorForGroup(id) {
|
|
585
|
+
let h = 0;
|
|
586
|
+
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
|
|
587
|
+
return `hsl(${Math.abs(h) % 360}, 60%, 55%)`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function renderTimelineClip(clip, track, tl) {
|
|
591
|
+
const el = document.createElement('div');
|
|
592
|
+
el.className = 'timeline-clip';
|
|
593
|
+
if (clip.disabled) el.classList.add('disabled');
|
|
594
|
+
if (clip.groupId) {
|
|
595
|
+
el.classList.add('grouped');
|
|
596
|
+
el.dataset.groupId = clip.groupId;
|
|
597
|
+
el.style.setProperty('--group-color', colorForGroup(clip.groupId));
|
|
598
|
+
}
|
|
599
|
+
el.dataset.id = clip.id;
|
|
600
|
+
el.dataset.trackId = track.id;
|
|
601
|
+
el.style.position = 'absolute';
|
|
602
|
+
el.style.left = ((+clip.start || 0) * state.timelineZoom) + 'px';
|
|
603
|
+
el.style.top = '4px';
|
|
604
|
+
el.style.bottom = '4px';
|
|
605
|
+
el.style.width = Math.max(40, (+clip.duration || 1) * state.timelineZoom) + 'px';
|
|
606
|
+
|
|
607
|
+
// Trim handles (как в Adobe Premiere)
|
|
608
|
+
const lh = document.createElement('div');
|
|
609
|
+
lh.className = 'clip-handle left';
|
|
610
|
+
lh.title = 'Обрезать спереди';
|
|
611
|
+
el.appendChild(lh);
|
|
612
|
+
attachClipTrim(el, clip, lh, 'left');
|
|
613
|
+
const rh = document.createElement('div');
|
|
614
|
+
rh.className = 'clip-handle right';
|
|
615
|
+
rh.title = 'Обрезать сзади';
|
|
616
|
+
el.appendChild(rh);
|
|
617
|
+
attachClipTrim(el, clip, rh, 'right');
|
|
618
|
+
|
|
619
|
+
const typeLbl = document.createElement('span');
|
|
620
|
+
typeLbl.className = 'clip-type';
|
|
621
|
+
typeLbl.textContent = clip.type;
|
|
622
|
+
el.appendChild(typeLbl);
|
|
623
|
+
|
|
624
|
+
let fileMissing = false;
|
|
625
|
+
if (clip.type === 'image' || clip.type === 'video') {
|
|
626
|
+
try {
|
|
627
|
+
const url = await getOrCreateClipURL(clip);
|
|
628
|
+
const m = getOrCreateClipMedia(clip, url);
|
|
629
|
+
// переиспользуем элемент: он сохраняет загруженные кадры/буферы и не моргает при rerender
|
|
630
|
+
if (m.parentElement) m.parentElement.removeChild(m);
|
|
631
|
+
el.appendChild(m);
|
|
632
|
+
} catch { fileMissing = true; }
|
|
633
|
+
} else if (clip.type === 'audio') {
|
|
634
|
+
try { await getOrCreateClipURL(clip); }
|
|
635
|
+
catch { fileMissing = true; }
|
|
636
|
+
}
|
|
637
|
+
if (fileMissing) {
|
|
638
|
+
el.style.borderColor = '#e07a6a';
|
|
639
|
+
el.style.background = '#2a1818';
|
|
640
|
+
el.title = `Файл не найден: ${clip.file}`;
|
|
641
|
+
const w = document.createElement('div');
|
|
642
|
+
w.style.cssText = 'font-size:10px; color:#e07a6a; padding:2px;';
|
|
643
|
+
w.textContent = '⚠ файл отсутствует';
|
|
644
|
+
el.appendChild(w);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const nameEl = document.createElement('div');
|
|
648
|
+
nameEl.className = 'clip-name';
|
|
649
|
+
nameEl.textContent = clip.name;
|
|
650
|
+
el.appendChild(nameEl);
|
|
651
|
+
|
|
652
|
+
const durRow = document.createElement('div');
|
|
653
|
+
durRow.className = 'clip-dur';
|
|
654
|
+
const durInp = document.createElement('input');
|
|
655
|
+
durInp.type = 'number'; durInp.min = '0.1'; durInp.step = '0.1';
|
|
656
|
+
durInp.value = clip.duration;
|
|
657
|
+
durInp.addEventListener('mousedown', e => e.stopPropagation());
|
|
658
|
+
durInp.addEventListener('change', () => {
|
|
659
|
+
clip.duration = Math.max(0.1, parseFloat(durInp.value) || 0);
|
|
660
|
+
scheduleSave();
|
|
661
|
+
renderTimeline();
|
|
662
|
+
});
|
|
663
|
+
durRow.appendChild(durInp);
|
|
664
|
+
durRow.appendChild(document.createTextNode('сек'));
|
|
665
|
+
el.appendChild(durRow);
|
|
666
|
+
|
|
667
|
+
el.addEventListener('contextmenu', e => {
|
|
668
|
+
if (e.target.closest('input, .clip-handle')) return;
|
|
669
|
+
e.preventDefault();
|
|
670
|
+
e.stopPropagation();
|
|
671
|
+
showClipContextMenu(clip, track, tl, e.clientX, e.clientY);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Статус исходной ноды (генерация/ошибка) — поверх клипа
|
|
675
|
+
if (clip.nodeId) {
|
|
676
|
+
const srcNode = state.currentBoard?.metadata?.nodes?.find(n => n.id === clip.nodeId);
|
|
677
|
+
if (srcNode?.status === 'generating') {
|
|
678
|
+
el.classList.add('is-generating');
|
|
679
|
+
const ov = document.createElement('div');
|
|
680
|
+
ov.className = 'clip-status';
|
|
681
|
+
const sp = document.createElement('div'); sp.className = 'spinner';
|
|
682
|
+
const tx = document.createElement('span');
|
|
683
|
+
const sKey = srcNode.generated?.state;
|
|
684
|
+
const STATE_LABELS = {
|
|
685
|
+
'uploading-refs': 'загрузка референсов...',
|
|
686
|
+
'submitting': 'отправка задачи...',
|
|
687
|
+
'queued': 'в очереди...',
|
|
688
|
+
'waiting': 'в очереди...',
|
|
689
|
+
'queuing': 'в очереди...',
|
|
690
|
+
'generating': 'генерация...',
|
|
691
|
+
};
|
|
692
|
+
tx.textContent = STATE_LABELS[sKey] || (sKey ? sKey : 'генерация...');
|
|
693
|
+
ov.append(sp, tx);
|
|
694
|
+
el.appendChild(ov);
|
|
695
|
+
} else if (srcNode?.status === 'error') {
|
|
696
|
+
el.classList.add('is-error');
|
|
697
|
+
const ov = document.createElement('div');
|
|
698
|
+
ov.className = 'clip-status error';
|
|
699
|
+
ov.textContent = '⚠ ' + (srcNode.error || 'ошибка');
|
|
700
|
+
el.appendChild(ov);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Двойной клик по аудио/картинке → перегенерация исходной ноды
|
|
705
|
+
// Если у клипа нет привязанной ноды — создадим её на лету
|
|
706
|
+
if (clip.type === 'image' || clip.type === 'audio') {
|
|
707
|
+
el.addEventListener('dblclick', async e => {
|
|
708
|
+
if (e.target.closest('input, .clip-handle')) return;
|
|
709
|
+
e.preventDefault();
|
|
710
|
+
e.stopPropagation();
|
|
711
|
+
let node = clip.nodeId
|
|
712
|
+
? state.currentBoard?.metadata?.nodes?.find(n => n.id === clip.nodeId)
|
|
713
|
+
: null;
|
|
714
|
+
if (!node) {
|
|
715
|
+
// Создаём ноду из клипа: тип/файл/имя берём из клипа, кладём в свободное место холста
|
|
716
|
+
if (!state.currentBoard) return;
|
|
717
|
+
const spot = findFreeSpot();
|
|
718
|
+
node = {
|
|
719
|
+
id: crypto.randomUUID(),
|
|
720
|
+
type: clip.type,
|
|
721
|
+
file: clip.file,
|
|
722
|
+
name: clip.name || (clip.file || '').split('/').pop(),
|
|
723
|
+
x: spot.x, y: spot.y,
|
|
724
|
+
};
|
|
725
|
+
state.currentBoard.metadata.nodes.push(node);
|
|
726
|
+
canvas.appendChild(await createNodeEl(node));
|
|
727
|
+
clip.nodeId = node.id;
|
|
728
|
+
scheduleSave();
|
|
729
|
+
}
|
|
730
|
+
if (node.status === 'generating') return;
|
|
731
|
+
regenerateNode(node);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ПКМ-drag: перенос с выбором действия (Копировать / Заменить)
|
|
736
|
+
el.addEventListener('mousedown', e => {
|
|
737
|
+
if (e.button !== 2) return;
|
|
738
|
+
if (e.target.closest('input, button, .clip-x, .clip-handle')) return;
|
|
739
|
+
const startX = e.clientX, startY = e.clientY;
|
|
740
|
+
let dragged = false;
|
|
741
|
+
let ghost = null;
|
|
742
|
+
let lastVictim = null;
|
|
743
|
+
const clearVictim = () => {
|
|
744
|
+
if (lastVictim) { lastVictim.classList.remove('replace-target'); lastVictim = null; }
|
|
745
|
+
};
|
|
746
|
+
const onMove = ev => {
|
|
747
|
+
const dx = ev.clientX - startX, dy = ev.clientY - startY;
|
|
748
|
+
if (!dragged && Math.hypot(dx, dy) < 6) return;
|
|
749
|
+
if (!dragged) {
|
|
750
|
+
e.preventDefault();
|
|
751
|
+
e.stopPropagation();
|
|
752
|
+
}
|
|
753
|
+
dragged = true;
|
|
754
|
+
if (!ghost) {
|
|
755
|
+
ghost = document.createElement('div');
|
|
756
|
+
ghost.style.cssText = 'position:fixed; pointer-events:none; z-index:2000; background:rgba(106,138,170,0.35); border:2px dashed #6a8aaa; border-radius:4px;';
|
|
757
|
+
document.body.appendChild(ghost);
|
|
758
|
+
}
|
|
759
|
+
const w = el.offsetWidth, h = el.offsetHeight;
|
|
760
|
+
ghost.style.left = (ev.clientX - w / 2) + 'px';
|
|
761
|
+
ghost.style.top = (ev.clientY - h / 2) + 'px';
|
|
762
|
+
ghost.style.width = w + 'px';
|
|
763
|
+
ghost.style.height = h + 'px';
|
|
764
|
+
// Подсветка возможной жертвы (для "Заменить")
|
|
765
|
+
const victimEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
766
|
+
.find(n => n.classList?.contains('timeline-clip') && n.dataset.id !== clip.id);
|
|
767
|
+
if (victimEl !== lastVictim) {
|
|
768
|
+
clearVictim();
|
|
769
|
+
if (victimEl) { victimEl.classList.add('replace-target'); lastVictim = victimEl; }
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
const onUp = ev => {
|
|
773
|
+
document.removeEventListener('mousemove', onMove);
|
|
774
|
+
document.removeEventListener('mouseup', onUp);
|
|
775
|
+
if (ghost) { ghost.remove(); ghost = null; }
|
|
776
|
+
clearVictim();
|
|
777
|
+
if (!dragged) return;
|
|
778
|
+
// Нашли target-track + опц. жертву и время дропа
|
|
779
|
+
const targetTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
780
|
+
.find(n => n.classList?.contains('timeline-track'));
|
|
781
|
+
if (!targetTrackEl) return;
|
|
782
|
+
const targetTrack = tl.tracks.find(t => t.id === targetTrackEl.dataset.trackId);
|
|
783
|
+
if (!targetTrack) return;
|
|
784
|
+
const compatible = (targetTrack.kind === 'video' && (clip.type === 'video' || clip.type === 'image'))
|
|
785
|
+
|| (targetTrack.kind === 'audio' && clip.type === 'audio');
|
|
786
|
+
if (!compatible) { alert('Несовместимая дорожка для этого клипа.'); return; }
|
|
787
|
+
const victimEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
788
|
+
.find(n => n.classList?.contains('timeline-clip') && n.dataset.id !== clip.id);
|
|
789
|
+
const victimId = victimEl?.dataset?.id || null;
|
|
790
|
+
const clipsRow = targetTrackEl.querySelector('.track-clips');
|
|
791
|
+
const rect = clipsRow?.getBoundingClientRect();
|
|
792
|
+
const dropTime = rect ? Math.max(0, (ev.clientX - rect.left) / state.timelineZoom) : 0;
|
|
793
|
+
showRightDragMenu(ev.clientX, ev.clientY, {
|
|
794
|
+
sourceClip: clip, sourceTrack: track,
|
|
795
|
+
targetTrack, victimId, dropTime,
|
|
796
|
+
});
|
|
797
|
+
};
|
|
798
|
+
// Подавить нативное contextmenu, если был drag
|
|
799
|
+
const onCtxOnce = ev => {
|
|
800
|
+
if (dragged) { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation(); }
|
|
801
|
+
window.removeEventListener('contextmenu', onCtxOnce, true);
|
|
802
|
+
};
|
|
803
|
+
window.addEventListener('contextmenu', onCtxOnce, true);
|
|
804
|
+
document.addEventListener('mousemove', onMove);
|
|
805
|
+
document.addEventListener('mouseup', onUp);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// Pointer-based перетаскивание: меняет clip.start (плавный offset) и опц. кросс-track
|
|
809
|
+
el.addEventListener('mousedown', e => {
|
|
810
|
+
if (e.button !== 0) return;
|
|
811
|
+
if (e.target.closest('input, button, .clip-x, .clip-handle')) return;
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
e.stopPropagation();
|
|
814
|
+
const startX = e.clientX, startY = e.clientY;
|
|
815
|
+
const origStart = +clip.start || 0;
|
|
816
|
+
let dragged = false;
|
|
817
|
+
let currentStart = origStart;
|
|
818
|
+
let placeholder = null;
|
|
819
|
+
// Сгруппированные клипы: запоминаем siblings для синхронного перемещения
|
|
820
|
+
const groupSiblings = clip.groupId ? getGroupSiblings(clip.groupId, tl, clip.id).map(s => ({
|
|
821
|
+
clip: s,
|
|
822
|
+
origStart: +s.start || 0,
|
|
823
|
+
el: document.querySelector(`.timeline-clip[data-id="${s.id}"]`),
|
|
824
|
+
})).filter(s => s.el) : [];
|
|
825
|
+
const showPlaceholder = (targetClipsEl) => {
|
|
826
|
+
if (!placeholder) {
|
|
827
|
+
placeholder = document.createElement('div');
|
|
828
|
+
placeholder.className = 'timeline-clip-placeholder';
|
|
829
|
+
if (clip.type === 'audio') placeholder.classList.add('audio');
|
|
830
|
+
}
|
|
831
|
+
if (placeholder.parentElement !== targetClipsEl) {
|
|
832
|
+
placeholder.remove();
|
|
833
|
+
targetClipsEl.appendChild(placeholder);
|
|
834
|
+
}
|
|
835
|
+
placeholder.style.left = (currentStart * state.timelineZoom) + 'px';
|
|
836
|
+
placeholder.style.width = ((+clip.duration || 1) * state.timelineZoom) + 'px';
|
|
837
|
+
};
|
|
838
|
+
const removePlaceholder = () => {
|
|
839
|
+
if (placeholder) { placeholder.remove(); placeholder = null; }
|
|
840
|
+
};
|
|
841
|
+
let lastVictimEl = null;
|
|
842
|
+
const clearVictim = () => {
|
|
843
|
+
if (lastVictimEl) { lastVictimEl.classList.remove('replace-target'); lastVictimEl = null; }
|
|
844
|
+
};
|
|
845
|
+
const onMove = ev => {
|
|
846
|
+
const dx = ev.clientX - startX, dy = ev.clientY - startY;
|
|
847
|
+
if (!dragged && Math.hypot(dx, dy) < 4) return;
|
|
848
|
+
dragged = true;
|
|
849
|
+
el.classList.add('dragging');
|
|
850
|
+
let proposed = Math.max(0, origStart + dx / state.timelineZoom);
|
|
851
|
+
// Snap-магнит: пытаемся прилипнуть началом или концом клипа к соседям/playhead/0
|
|
852
|
+
const targets = getSnapTargets(tl, clip.id);
|
|
853
|
+
const dur = +clip.duration || 0;
|
|
854
|
+
const snappedStart = snapTime(proposed, targets, state.timelineZoom);
|
|
855
|
+
const snappedEnd = snapTime(proposed + dur, targets, state.timelineZoom) - dur;
|
|
856
|
+
const ds = Math.abs(snappedStart - proposed) * state.timelineZoom;
|
|
857
|
+
const de = Math.abs(snappedEnd - proposed) * state.timelineZoom;
|
|
858
|
+
if (ds < SNAP_PX && ds <= de) proposed = snappedStart;
|
|
859
|
+
else if (de < SNAP_PX) proposed = snappedEnd;
|
|
860
|
+
currentStart = Math.max(0, proposed);
|
|
861
|
+
el.style.left = (currentStart * state.timelineZoom) + 'px';
|
|
862
|
+
// Группа: двигаем siblings синхронно по той же дельте
|
|
863
|
+
if (groupSiblings.length) {
|
|
864
|
+
const delta = currentStart - origStart;
|
|
865
|
+
for (const s of groupSiblings) {
|
|
866
|
+
const ns = Math.max(0, s.origStart + delta);
|
|
867
|
+
if (s.el) s.el.style.left = (ns * state.timelineZoom) + 'px';
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// Placeholder в целевой дорожке
|
|
871
|
+
const targetTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
872
|
+
.find(n => n.classList?.contains('timeline-track'));
|
|
873
|
+
if (!targetTrackEl) { removePlaceholder(); clearVictim(); return; }
|
|
874
|
+
const targetKind = targetTrackEl.dataset.trackKind;
|
|
875
|
+
const compat = (targetKind === 'video' && (clip.type === 'video' || clip.type === 'image'))
|
|
876
|
+
|| (targetKind === 'audio' && clip.type === 'audio');
|
|
877
|
+
if (!compat) { removePlaceholder(); clearVictim(); return; }
|
|
878
|
+
const targetClipsEl = targetTrackEl.querySelector('.track-clips');
|
|
879
|
+
if (targetClipsEl) showPlaceholder(targetClipsEl);
|
|
880
|
+
// Подсветка режима replace: видео-дорожка + Cmd/Ctrl + курсор над чужим клипом
|
|
881
|
+
const cmdHeld = ev.metaKey || ev.ctrlKey;
|
|
882
|
+
const isReplace = cmdHeld && targetKind === 'video' && !clip.groupId;
|
|
883
|
+
let victimEl = null;
|
|
884
|
+
if (isReplace) {
|
|
885
|
+
victimEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
886
|
+
.find(n => n.classList?.contains('timeline-clip') && n.dataset.id !== clip.id);
|
|
887
|
+
}
|
|
888
|
+
if (victimEl !== lastVictimEl) {
|
|
889
|
+
clearVictim();
|
|
890
|
+
if (victimEl) { victimEl.classList.add('replace-target'); lastVictimEl = victimEl; }
|
|
891
|
+
}
|
|
892
|
+
if (placeholder) placeholder.classList.toggle('replace', !!(isReplace && victimEl));
|
|
893
|
+
};
|
|
894
|
+
const onUp = async ev => {
|
|
895
|
+
document.removeEventListener('mousemove', onMove);
|
|
896
|
+
document.removeEventListener('mouseup', onUp);
|
|
897
|
+
el.classList.remove('dragging');
|
|
898
|
+
removePlaceholder();
|
|
899
|
+
clearVictim();
|
|
900
|
+
if (!dragged) {
|
|
901
|
+
// Это клик по клипу — выделение (Cmd/Shift = добавить)
|
|
902
|
+
const additive = ev.metaKey || ev.ctrlKey || ev.shiftKey;
|
|
903
|
+
selectClipById(clip.id, additive);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// Drop над холстом → создаём ноду из клипа (или фокусируем существующую).
|
|
907
|
+
const overCanvas = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
908
|
+
.some(n => n === canvasWrap || n === canvas || n.classList?.contains('canvas-frame'));
|
|
909
|
+
const overTrack = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
910
|
+
.some(n => n.classList?.contains('timeline-track'));
|
|
911
|
+
if (overCanvas && !overTrack) {
|
|
912
|
+
try {
|
|
913
|
+
pushHistory('Drag клипа на холст');
|
|
914
|
+
await dropClipOnCanvas(clip, ev.clientX, ev.clientY);
|
|
915
|
+
} catch (err) {
|
|
916
|
+
console.error('drop clip→canvas', err);
|
|
917
|
+
}
|
|
918
|
+
// позиция клипа на таймлайне не должна меняться — рендерим как было
|
|
919
|
+
renderTimeline();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const targetTrackEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
923
|
+
.find(n => n.classList?.contains('timeline-track'));
|
|
924
|
+
const targetTrackId = targetTrackEl?.dataset?.trackId || track.id;
|
|
925
|
+
const targetTrack = tl.tracks.find(t => t.id === targetTrackId) || track;
|
|
926
|
+
const compatible =
|
|
927
|
+
(targetTrack.kind === 'video' && (clip.type === 'video' || clip.type === 'image')) ||
|
|
928
|
+
(targetTrack.kind === 'audio' && clip.type === 'audio');
|
|
929
|
+
if (!compatible) { renderTimeline(); return; }
|
|
930
|
+
|
|
931
|
+
// Сгруппированные клипы: ripple-insert если ведущим тащим видео-клип на той же видео-дорожке,
|
|
932
|
+
// siblings (аудио) — на ту же дельту. Иначе — free-form sync drag.
|
|
933
|
+
if (clip.groupId) {
|
|
934
|
+
const isVideoLeader = track.kind === 'video'
|
|
935
|
+
&& (clip.type === 'video' || clip.type === 'image')
|
|
936
|
+
&& targetTrack.id === track.id;
|
|
937
|
+
if (isVideoLeader) {
|
|
938
|
+
rippleInsertClip(targetTrack, clip, track, currentStart);
|
|
939
|
+
const delta = (+clip.start || 0) - origStart;
|
|
940
|
+
for (const s of groupSiblings) {
|
|
941
|
+
s.clip.start = Math.max(0, s.origStart + delta);
|
|
942
|
+
}
|
|
943
|
+
} else {
|
|
944
|
+
// free-form sync (например, ведущий — аудио, или drag в другую дорожку)
|
|
945
|
+
const delta = currentStart - origStart;
|
|
946
|
+
clip.start = currentStart;
|
|
947
|
+
for (const s of groupSiblings) {
|
|
948
|
+
s.clip.start = Math.max(0, s.origStart + delta);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
scheduleSave();
|
|
952
|
+
renderTimeline();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Видео: stack-режим. По умолчанию — ripple-insert.
|
|
957
|
+
// С Cmd/Ctrl — заменяем клип, на который попали.
|
|
958
|
+
if (targetTrack.kind === 'video') {
|
|
959
|
+
const cmdHeld = ev.metaKey || ev.ctrlKey;
|
|
960
|
+
if (cmdHeld) {
|
|
961
|
+
const victimEl = document.elementsFromPoint(ev.clientX, ev.clientY)
|
|
962
|
+
.find(n => n.classList?.contains('timeline-clip'));
|
|
963
|
+
const victimId = victimEl?.dataset?.id;
|
|
964
|
+
if (victimId && victimId !== clip.id) {
|
|
965
|
+
replaceClipInTrack(targetTrack, clip, track, victimId);
|
|
966
|
+
scheduleSave(); renderTimeline(); return;
|
|
967
|
+
}
|
|
968
|
+
// нет жертвы — падаем в обычный ripple-insert
|
|
969
|
+
}
|
|
970
|
+
rippleInsertClip(targetTrack, clip, track, currentStart);
|
|
971
|
+
scheduleSave(); renderTimeline(); return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Audio: свободная позиция (как и было)
|
|
975
|
+
clip.start = currentStart;
|
|
976
|
+
if (targetTrack.id !== track.id) {
|
|
977
|
+
track.clips = track.clips.filter(c => c.id !== clip.id);
|
|
978
|
+
targetTrack.clips.push(clip);
|
|
979
|
+
}
|
|
980
|
+
scheduleSave();
|
|
981
|
+
renderTimeline();
|
|
982
|
+
};
|
|
983
|
+
document.addEventListener('mousemove', onMove);
|
|
984
|
+
document.addEventListener('mouseup', onUp);
|
|
985
|
+
});
|
|
986
|
+
return el;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function renderTimeline() {
|
|
990
|
+
const tl = getTimeline();
|
|
991
|
+
const tracksEl = $('timelineTracks');
|
|
992
|
+
if (!tracksEl) return;
|
|
993
|
+
tracksEl.innerHTML = '';
|
|
994
|
+
// Гарантируем clip.start у всех клипов (sequential по умолчанию), сортируем по start
|
|
995
|
+
for (const t of tl.tracks) {
|
|
996
|
+
let cum = 0;
|
|
997
|
+
for (let i = 0; i < t.clips.length; i++) {
|
|
998
|
+
const c = t.clips[i];
|
|
999
|
+
if (typeof c.start !== 'number' || !isFinite(c.start)) c.start = cum;
|
|
1000
|
+
cum = Math.max(cum, c.start + (+c.duration || 0));
|
|
1001
|
+
}
|
|
1002
|
+
t.clips.sort((a, b) => (a.start || 0) - (b.start || 0));
|
|
1003
|
+
}
|
|
1004
|
+
let videoTotal = 0;
|
|
1005
|
+
// Общая длина = max(end of any clip)
|
|
1006
|
+
let maxDur = 0;
|
|
1007
|
+
for (const t of tl.tracks) {
|
|
1008
|
+
for (const c of t.clips) {
|
|
1009
|
+
const end = (+c.start || 0) + (+c.duration || 0);
|
|
1010
|
+
if (end > maxDur) maxDur = end;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Линейка
|
|
1014
|
+
const ruler = document.createElement('div');
|
|
1015
|
+
ruler.className = 'timeline-ruler';
|
|
1016
|
+
const rulerContent = document.createElement('div');
|
|
1017
|
+
rulerContent.className = 'ruler-content';
|
|
1018
|
+
rulerContent.style.width = (maxDur * state.timelineZoom + 138) + 'px';
|
|
1019
|
+
// Шаг тиков зависит от zoom
|
|
1020
|
+
const step = state.timelineZoom >= 80 ? 1 : state.timelineZoom >= 40 ? 2 : state.timelineZoom >= 20 ? 5 : 10;
|
|
1021
|
+
for (let s = 0; s <= maxDur; s += step) {
|
|
1022
|
+
const tick = document.createElement('div');
|
|
1023
|
+
tick.className = 'tick';
|
|
1024
|
+
tick.style.left = (s * state.timelineZoom) + 'px';
|
|
1025
|
+
tick.textContent = s + 's';
|
|
1026
|
+
rulerContent.appendChild(tick);
|
|
1027
|
+
}
|
|
1028
|
+
ruler.appendChild(rulerContent);
|
|
1029
|
+
tracksEl.appendChild(ruler);
|
|
1030
|
+
|
|
1031
|
+
$('tlZoomLabel').textContent = Math.round(state.timelineZoom);
|
|
1032
|
+
for (const track of tl.tracks) {
|
|
1033
|
+
const trackEl = document.createElement('div');
|
|
1034
|
+
trackEl.className = 'timeline-track';
|
|
1035
|
+
trackEl.dataset.trackId = track.id;
|
|
1036
|
+
trackEl.dataset.trackKind = track.kind;
|
|
1037
|
+
trackEl.addEventListener('contextmenu', e => {
|
|
1038
|
+
// Не перехватываем ПКМ на инпутах и на самих клипах (у них своё меню)
|
|
1039
|
+
if (e.target.closest('input, textarea')) return;
|
|
1040
|
+
if (e.target.closest('.timeline-clip')) return;
|
|
1041
|
+
e.preventDefault();
|
|
1042
|
+
e.stopPropagation();
|
|
1043
|
+
const clipsRow = trackEl.querySelector('.track-clips');
|
|
1044
|
+
const rect = clipsRow?.getBoundingClientRect();
|
|
1045
|
+
const dropTime = rect ? Math.max(0, (e.clientX - rect.left) / state.timelineZoom) : 0;
|
|
1046
|
+
showTrackContextMenu(track, e.clientX, e.clientY, { dropTime });
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// Header (meta)
|
|
1050
|
+
const meta = document.createElement('div');
|
|
1051
|
+
meta.className = 'track-meta';
|
|
1052
|
+
// Клик по track-meta — выделить дорожку (Cmd/Shift = добавить к выделению)
|
|
1053
|
+
meta.addEventListener('click', e => {
|
|
1054
|
+
if (e.target.closest('input, button')) return;
|
|
1055
|
+
const additive = e.metaKey || e.ctrlKey || e.shiftKey;
|
|
1056
|
+
selectTrackById(track.id, additive);
|
|
1057
|
+
});
|
|
1058
|
+
const kindLbl = document.createElement('div');
|
|
1059
|
+
kindLbl.className = 'track-kind';
|
|
1060
|
+
kindLbl.textContent = (track.kind === 'video' ? '🎬 ' : '🔊 ') + track.kind;
|
|
1061
|
+
meta.appendChild(kindLbl);
|
|
1062
|
+
const nameInp = document.createElement('input');
|
|
1063
|
+
nameInp.className = 'track-name';
|
|
1064
|
+
nameInp.value = track.name;
|
|
1065
|
+
nameInp.addEventListener('mousedown', e => e.stopPropagation());
|
|
1066
|
+
nameInp.addEventListener('change', () => {
|
|
1067
|
+
track.name = nameInp.value.trim() || track.name;
|
|
1068
|
+
scheduleSave();
|
|
1069
|
+
});
|
|
1070
|
+
meta.appendChild(nameInp);
|
|
1071
|
+
let trackDur = 0;
|
|
1072
|
+
for (const c of track.clips) {
|
|
1073
|
+
const end = (+c.start || 0) + (+c.duration || 0);
|
|
1074
|
+
if (end > trackDur) trackDur = end;
|
|
1075
|
+
}
|
|
1076
|
+
if (track.kind === 'video') videoTotal = Math.max(videoTotal, trackDur);
|
|
1077
|
+
const durLbl = document.createElement('div');
|
|
1078
|
+
durLbl.className = 'track-kind';
|
|
1079
|
+
durLbl.style.color = '#888';
|
|
1080
|
+
durLbl.textContent = trackDur > 0 ? `${trackDur.toFixed(1)} сек` : '— пусто —';
|
|
1081
|
+
meta.appendChild(durLbl);
|
|
1082
|
+
const actions = document.createElement('div');
|
|
1083
|
+
actions.className = 'track-actions';
|
|
1084
|
+
const delBtn = document.createElement('button');
|
|
1085
|
+
delBtn.textContent = 'удалить';
|
|
1086
|
+
delBtn.title = 'Удалить дорожку';
|
|
1087
|
+
delBtn.addEventListener('click', () => removeTrack(track.id));
|
|
1088
|
+
actions.appendChild(delBtn);
|
|
1089
|
+
meta.appendChild(actions);
|
|
1090
|
+
trackEl.appendChild(meta);
|
|
1091
|
+
|
|
1092
|
+
// Clips
|
|
1093
|
+
const clipsRow = document.createElement('div');
|
|
1094
|
+
clipsRow.className = 'track-clips';
|
|
1095
|
+
clipsRow.dataset.trackId = track.id;
|
|
1096
|
+
// Все track-clips имеют одинаковую ширину = maxDur*zoom (треки выравниваются по концу)
|
|
1097
|
+
clipsRow.style.width = (maxDur * state.timelineZoom) + 'px';
|
|
1098
|
+
clipsRow.style.minWidth = (maxDur * state.timelineZoom) + 'px';
|
|
1099
|
+
for (const clip of track.clips) {
|
|
1100
|
+
clipsRow.appendChild(await renderTimelineClip(clip, track, tl));
|
|
1101
|
+
}
|
|
1102
|
+
// Двойной клик на пустом месте аудио-дорожки → меню реплик персонажей
|
|
1103
|
+
if (track.kind === 'audio') {
|
|
1104
|
+
clipsRow.addEventListener('dblclick', e => {
|
|
1105
|
+
if (e.target.closest('.timeline-clip')) return;
|
|
1106
|
+
e.preventDefault();
|
|
1107
|
+
e.stopPropagation();
|
|
1108
|
+
const rect = clipsRow.getBoundingClientRect();
|
|
1109
|
+
const time = Math.max(0, (e.clientX - rect.left) / state.timelineZoom);
|
|
1110
|
+
showCharSelectMenu(track, time, e.clientX, e.clientY);
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
// Drop zone (на пустую дорожку или в конец)
|
|
1114
|
+
clipsRow.addEventListener('dragover', e => {
|
|
1115
|
+
if (!e.dataTransfer.types.includes('text/x-clip-id')) return;
|
|
1116
|
+
e.preventDefault();
|
|
1117
|
+
e.dataTransfer.dropEffect = 'move';
|
|
1118
|
+
});
|
|
1119
|
+
clipsRow.addEventListener('drop', e => {
|
|
1120
|
+
const fromId = e.dataTransfer.getData('text/x-clip-id');
|
|
1121
|
+
if (!fromId) return;
|
|
1122
|
+
// Не наседать на конкретный clip — drop в конец дорожки
|
|
1123
|
+
if (e.target === clipsRow) {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
moveClipToTrack(fromId, track.id, null);
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
trackEl.appendChild(clipsRow);
|
|
1129
|
+
tracksEl.appendChild(trackEl);
|
|
1130
|
+
}
|
|
1131
|
+
$('timelineDuration').textContent = videoTotal > 0 ? `${videoTotal.toFixed(1)} сек` : '';
|
|
1132
|
+
|
|
1133
|
+
// Playhead
|
|
1134
|
+
const ph = document.createElement('div');
|
|
1135
|
+
ph.className = 'timeline-playhead';
|
|
1136
|
+
ph.id = 'timelinePlayhead';
|
|
1137
|
+
const handle = document.createElement('div');
|
|
1138
|
+
handle.className = 'ph-handle';
|
|
1139
|
+
ph.appendChild(handle);
|
|
1140
|
+
positionPlayhead(ph);
|
|
1141
|
+
attachPlayheadDrag(handle, ph);
|
|
1142
|
+
tracksEl.appendChild(ph);
|
|
1143
|
+
attachTimelineClickToSeek();
|
|
1144
|
+
|
|
1145
|
+
// Подгружаем точные длительности видео/аудио из metadata
|
|
1146
|
+
let durationsChanged = false;
|
|
1147
|
+
for (const track of tl.tracks) {
|
|
1148
|
+
for (const clip of track.clips) {
|
|
1149
|
+
if ((clip.type === 'video' || clip.type === 'audio') && !clip._durationLoaded) {
|
|
1150
|
+
try {
|
|
1151
|
+
const url = await getOrCreateClipURL(clip);
|
|
1152
|
+
const m = document.createElement(clip.type);
|
|
1153
|
+
m.preload = 'metadata'; m.src = url; m.muted = true;
|
|
1154
|
+
await new Promise(r => { m.addEventListener('loadedmetadata', r, { once: true }); m.addEventListener('error', r, { once: true }); });
|
|
1155
|
+
if (m.duration && isFinite(m.duration)) {
|
|
1156
|
+
const oldDur = clip.duration;
|
|
1157
|
+
clip.sourceDuration = +m.duration.toFixed(3);
|
|
1158
|
+
if (!clip._durationLoaded) {
|
|
1159
|
+
const maxDur = clip.sourceDuration - (+clip.trimStart || 0);
|
|
1160
|
+
if (!clip.duration || clip.duration > maxDur) clip.duration = +maxDur.toFixed(2);
|
|
1161
|
+
}
|
|
1162
|
+
clip._durationLoaded = true;
|
|
1163
|
+
if (clip.duration !== oldDur) {
|
|
1164
|
+
durationsChanged = true;
|
|
1165
|
+
const clipEl = tracksEl.querySelector(`.timeline-clip[data-id="${clip.id}"]`);
|
|
1166
|
+
if (clipEl) {
|
|
1167
|
+
clipEl.style.width = Math.max(40, clip.duration * state.timelineZoom) + 'px';
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
} catch {}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (durationsChanged) scheduleSave();
|
|
1176
|
+
// Подчистим выделение от устаревших id и применим визуал
|
|
1177
|
+
const validClipIds = new Set();
|
|
1178
|
+
const validTrackIds = new Set();
|
|
1179
|
+
for (const t of tl.tracks) {
|
|
1180
|
+
validTrackIds.add(t.id);
|
|
1181
|
+
for (const c of t.clips) validClipIds.add(c.id);
|
|
1182
|
+
}
|
|
1183
|
+
for (const id of [...state.selectedClipIds]) if (!validClipIds.has(id)) state.selectedClipIds.delete(id);
|
|
1184
|
+
for (const id of [...state.selectedTrackIds]) if (!validTrackIds.has(id)) state.selectedTrackIds.delete(id);
|
|
1185
|
+
applyTimelineSelectionVisuals();
|
|
1186
|
+
pruneClipMediaCache(validClipIds);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// === Web Audio: sample-accurate audio engine ===
|
|
1190
|
+
let audioCtx = null;
|
|
1191
|
+
function getAudioCtx() {
|
|
1192
|
+
if (!audioCtx) {
|
|
1193
|
+
const Ctor = window.AudioContext || window.webkitAudioContext;
|
|
1194
|
+
audioCtx = new Ctor();
|
|
1195
|
+
}
|
|
1196
|
+
return audioCtx;
|
|
1197
|
+
}
|
|
1198
|
+
const _audioBufferCache = new Map();
|
|
1199
|
+
async function getOrDecodeAudioBuffer(clip) {
|
|
1200
|
+
const key = `${state.currentBoard.key}::${clip.file}`;
|
|
1201
|
+
if (_audioBufferCache.has(key)) return _audioBufferCache.get(key);
|
|
1202
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, clip.file);
|
|
1203
|
+
const ab = await (await fh.getFile()).arrayBuffer();
|
|
1204
|
+
const buf = await getAudioCtx().decodeAudioData(ab.slice(0));
|
|
1205
|
+
_audioBufferCache.set(key, buf);
|
|
1206
|
+
return buf;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Кэш HTML-элементов <img>/<video> для клипов: сохраняет загруженные кадры и не моргает при rerender
|
|
1210
|
+
const _clipMediaCache = new Map(); // `${clipId}::${file}` -> HTMLElement
|
|
1211
|
+
function getOrCreateClipMedia(clip, url) {
|
|
1212
|
+
const key = `${clip.id}::${clip.file}`;
|
|
1213
|
+
let m = _clipMediaCache.get(key);
|
|
1214
|
+
if (m && m.tagName.toLowerCase() === clip.type) return m;
|
|
1215
|
+
if (m) { try { m.src = ''; } catch {} }
|
|
1216
|
+
m = clip.type === 'image' ? document.createElement('img') : document.createElement('video');
|
|
1217
|
+
m.src = url;
|
|
1218
|
+
if (clip.type === 'image') m.decoding = 'async';
|
|
1219
|
+
if (clip.type === 'video') { m.preload = 'metadata'; m.muted = true; m.disablePictureInPicture = true; }
|
|
1220
|
+
_clipMediaCache.set(key, m);
|
|
1221
|
+
return m;
|
|
1222
|
+
}
|
|
1223
|
+
function pruneClipMediaCache(validClipIds) {
|
|
1224
|
+
for (const [key, el] of [..._clipMediaCache.entries()]) {
|
|
1225
|
+
const id = key.split('::')[0];
|
|
1226
|
+
if (validClipIds.has(id)) continue;
|
|
1227
|
+
try { el.src = ''; } catch {}
|
|
1228
|
+
if (el.parentElement) el.parentElement.removeChild(el);
|
|
1229
|
+
_clipMediaCache.delete(key);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const _clipURLCache = new Map();
|
|
1234
|
+
async function getOrCreateClipURL(clip) {
|
|
1235
|
+
const key = `${state.currentBoard.key}::${clip.file}`;
|
|
1236
|
+
if (_clipURLCache.has(key)) return _clipURLCache.get(key);
|
|
1237
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, clip.file);
|
|
1238
|
+
const url = URL.createObjectURL(await fh.getFile());
|
|
1239
|
+
_clipURLCache.set(key, url);
|
|
1240
|
+
return url;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function removeFromTimeline(clipId) {
|
|
1244
|
+
const tl = getTimeline();
|
|
1245
|
+
const found = findClipById(tl, clipId);
|
|
1246
|
+
if (!found) return;
|
|
1247
|
+
found.track.clips.splice(found.idx, 1);
|
|
1248
|
+
scheduleSave();
|
|
1249
|
+
await renderTimeline();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function setPanelsVisible(timeline, preview) {
|
|
1253
|
+
$('timelinePanel').classList.toggle('hidden', !timeline);
|
|
1254
|
+
// Превью теперь не скрывается, а сворачивается в полоску
|
|
1255
|
+
setPreviewCollapsed(!preview);
|
|
1256
|
+
localStorage.setItem('timelineOpen', timeline ? '1' : '0');
|
|
1257
|
+
localStorage.setItem('previewOpen', preview ? '1' : '0');
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// === Зафиксированная панель превью: свернуть/развернуть + горизонтальный resize ===
|
|
1261
|
+
function setPreviewCollapsed(collapsed) {
|
|
1262
|
+
const panel = $('previewPanel');
|
|
1263
|
+
panel.classList.toggle('collapsed', !!collapsed);
|
|
1264
|
+
document.body.classList.toggle('preview-collapsed', !!collapsed);
|
|
1265
|
+
localStorage.setItem('previewCollapsed', collapsed ? '1' : '0');
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function applyPreviewState() {
|
|
1269
|
+
const savedW = parseInt(localStorage.getItem('previewWidth') || '', 10);
|
|
1270
|
+
if (savedW && savedW >= 280 && savedW <= window.innerWidth * 0.7) {
|
|
1271
|
+
document.documentElement.style.setProperty('--preview-w', savedW + 'px');
|
|
1272
|
+
}
|
|
1273
|
+
// По умолчанию превью свёрнуто (если пользователь явно не разворачивал — '0').
|
|
1274
|
+
const collapsed = localStorage.getItem('previewCollapsed') !== '0';
|
|
1275
|
+
setPreviewCollapsed(collapsed);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
(function initPreviewControls() {
|
|
1279
|
+
const panel = $('previewPanel');
|
|
1280
|
+
const header = $('previewHeader');
|
|
1281
|
+
const toggle = $('previewToggle');
|
|
1282
|
+
const handle = $('previewResize');
|
|
1283
|
+
|
|
1284
|
+
// Клик по кнопке-стрелке — свернуть/развернуть
|
|
1285
|
+
toggle.addEventListener('click', e => {
|
|
1286
|
+
e.stopPropagation();
|
|
1287
|
+
setPreviewCollapsed(!panel.classList.contains('collapsed'));
|
|
1288
|
+
});
|
|
1289
|
+
// Клик в любую точку header'а в свернутом состоянии — развернуть
|
|
1290
|
+
header.addEventListener('click', e => {
|
|
1291
|
+
if (e.target === toggle) return;
|
|
1292
|
+
if (panel.classList.contains('collapsed')) setPreviewCollapsed(false);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// Resize за левый край
|
|
1296
|
+
let resizing = null;
|
|
1297
|
+
handle.addEventListener('mousedown', e => {
|
|
1298
|
+
if (panel.classList.contains('collapsed')) return;
|
|
1299
|
+
e.preventDefault();
|
|
1300
|
+
resizing = { startX: e.clientX, startW: panel.getBoundingClientRect().width };
|
|
1301
|
+
document.body.style.cursor = 'ew-resize';
|
|
1302
|
+
document.addEventListener('mousemove', onMove);
|
|
1303
|
+
document.addEventListener('mouseup', onUp);
|
|
1304
|
+
});
|
|
1305
|
+
function onMove(e) {
|
|
1306
|
+
if (!resizing) return;
|
|
1307
|
+
const dx = resizing.startX - e.clientX; // тянем влево → ширина растёт
|
|
1308
|
+
const min = 280, max = Math.floor(window.innerWidth * 0.7);
|
|
1309
|
+
const w = Math.max(min, Math.min(max, resizing.startW + dx));
|
|
1310
|
+
document.documentElement.style.setProperty('--preview-w', w + 'px');
|
|
1311
|
+
}
|
|
1312
|
+
function onUp() {
|
|
1313
|
+
if (!resizing) return;
|
|
1314
|
+
resizing = null;
|
|
1315
|
+
document.body.style.cursor = '';
|
|
1316
|
+
document.removeEventListener('mousemove', onMove);
|
|
1317
|
+
document.removeEventListener('mouseup', onUp);
|
|
1318
|
+
const w = panel.getBoundingClientRect().width;
|
|
1319
|
+
localStorage.setItem('previewWidth', String(Math.round(w)));
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
applyPreviewState();
|
|
1323
|
+
})();
|
|
1324
|
+
|
|
1325
|
+
$('timelineBtn').addEventListener('click', () => {
|
|
1326
|
+
const willShow = $('timelinePanel').classList.contains('hidden');
|
|
1327
|
+
setPanelsVisible(willShow, willShow);
|
|
1328
|
+
if (willShow && state.currentBoard) {
|
|
1329
|
+
renderTimeline();
|
|
1330
|
+
scheduleUpdatePreview();
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
$('timelineClear').addEventListener('click', () => {
|
|
1336
|
+
if (!state.currentBoard) return;
|
|
1337
|
+
if (!confirm('Очистить все дорожки таймлайна?')) return;
|
|
1338
|
+
state.currentBoard.metadata.timeline = defaultTimeline();
|
|
1339
|
+
scheduleSave();
|
|
1340
|
+
renderTimeline();
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
// === Playhead ===
|
|
1344
|
+
const META_WIDTH = 138; // ширина sticky-meta столбца + padding
|
|
1345
|
+
function positionPlayhead(phEl) {
|
|
1346
|
+
if (!phEl) phEl = $('timelinePlayhead');
|
|
1347
|
+
if (!phEl) return;
|
|
1348
|
+
// Считаем maxDur — клампим playhead, чтобы не уезжал в бесконечность
|
|
1349
|
+
const tl = state.currentBoard?.metadata?.timeline;
|
|
1350
|
+
let maxDur = 0;
|
|
1351
|
+
if (tl?.tracks) {
|
|
1352
|
+
for (const tr of tl.tracks) {
|
|
1353
|
+
for (const c of tr.clips) {
|
|
1354
|
+
const end = (+c.start || 0) + (+c.duration || 0);
|
|
1355
|
+
if (end > maxDur) maxDur = end;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const raw = state.playheadTime || 0;
|
|
1360
|
+
if (maxDur > 0 && raw > maxDur) state.playheadTime = maxDur;
|
|
1361
|
+
const t = Math.max(0, Math.min(maxDur || raw, state.playheadTime || 0));
|
|
1362
|
+
phEl.style.left = (META_WIDTH + t * state.timelineZoom) + 'px';
|
|
1363
|
+
$('timelinePlayheadInfo').textContent = `${t.toFixed(1)} с`;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function setPlayheadTime(t) {
|
|
1367
|
+
state.playheadTime = Math.max(0, t);
|
|
1368
|
+
positionPlayhead();
|
|
1369
|
+
// Persist в scene.json
|
|
1370
|
+
if (state.currentBoard?.metadata) {
|
|
1371
|
+
const tl = getTimeline();
|
|
1372
|
+
if (tl) {
|
|
1373
|
+
tl.playhead = state.playheadTime;
|
|
1374
|
+
scheduleSave();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (!_playStop) scheduleUpdatePreview();
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
let _previewUpdateRaf = null;
|
|
1381
|
+
function scheduleUpdatePreview() {
|
|
1382
|
+
if (_previewUpdateRaf) return;
|
|
1383
|
+
_previewUpdateRaf = requestAnimationFrame(() => {
|
|
1384
|
+
_previewUpdateRaf = null;
|
|
1385
|
+
updatePreviewToPlayhead().catch(e => console.error(e));
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
let _previewLoadingId = 0;
|
|
1390
|
+
async function updatePreviewToPlayhead() {
|
|
1391
|
+
const tl = getTimeline();
|
|
1392
|
+
if (!tl) return;
|
|
1393
|
+
const videoTrack = tl.tracks.find(t => t.kind === 'video' && t.clips.length);
|
|
1394
|
+
const preview = $('timelinePreview');
|
|
1395
|
+
const previewImg = $('timelinePreviewImg');
|
|
1396
|
+
if (!videoTrack) {
|
|
1397
|
+
preview.classList.add('hidden');
|
|
1398
|
+
previewImg.classList.add('hidden');
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
let elapsed = 0;
|
|
1402
|
+
let foundClip = null, foundOffset = 0;
|
|
1403
|
+
for (const clip of videoTrack.clips) {
|
|
1404
|
+
const dur = +clip.duration || 1;
|
|
1405
|
+
if (state.playheadTime < elapsed + dur) {
|
|
1406
|
+
foundClip = clip;
|
|
1407
|
+
foundOffset = state.playheadTime - elapsed;
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
elapsed += dur;
|
|
1411
|
+
}
|
|
1412
|
+
if (!foundClip) {
|
|
1413
|
+
// За пределами — показываем последний кадр
|
|
1414
|
+
foundClip = videoTrack.clips[videoTrack.clips.length - 1];
|
|
1415
|
+
foundOffset = (+foundClip.duration || 1);
|
|
1416
|
+
}
|
|
1417
|
+
const url = await getOrCreateClipURL(foundClip);
|
|
1418
|
+
const myId = ++_previewLoadingId;
|
|
1419
|
+
if (foundClip.type === 'image') {
|
|
1420
|
+
preview.classList.add('hidden');
|
|
1421
|
+
preview.pause();
|
|
1422
|
+
if (previewImg.src !== url) previewImg.src = url;
|
|
1423
|
+
previewImg.classList.remove('hidden');
|
|
1424
|
+
} else {
|
|
1425
|
+
previewImg.classList.add('hidden');
|
|
1426
|
+
preview.classList.remove('hidden');
|
|
1427
|
+
if (preview.dataset.clipId !== foundClip.id) {
|
|
1428
|
+
preview.src = url;
|
|
1429
|
+
preview.dataset.clipId = foundClip.id;
|
|
1430
|
+
await new Promise(res => {
|
|
1431
|
+
preview.addEventListener('loadedmetadata', res, { once: true });
|
|
1432
|
+
preview.addEventListener('error', res, { once: true });
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
if (myId !== _previewLoadingId) return; // другой кадр уже запросили
|
|
1436
|
+
try { preview.currentTime = Math.max(0, Math.min(preview.duration || 0, foundOffset)); } catch {}
|
|
1437
|
+
preview.pause();
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function stopTimelinePlayback() {
|
|
1442
|
+
if (typeof _playStop === 'function' && _playStop) {
|
|
1443
|
+
_playStop();
|
|
1444
|
+
_playStop = null;
|
|
1445
|
+
$('timelinePlay').textContent = '▶';
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function attachPlayheadDrag(handle, phEl) {
|
|
1450
|
+
handle.addEventListener('mousedown', e => {
|
|
1451
|
+
e.preventDefault();
|
|
1452
|
+
e.stopPropagation();
|
|
1453
|
+
stopTimelinePlayback();
|
|
1454
|
+
const tracksEl = $('timelineTracks');
|
|
1455
|
+
const rect = tracksEl.getBoundingClientRect();
|
|
1456
|
+
const onMove = ev => {
|
|
1457
|
+
const localX = ev.clientX - rect.left + tracksEl.scrollLeft - META_WIDTH;
|
|
1458
|
+
const t = Math.max(0, localX / state.timelineZoom);
|
|
1459
|
+
setPlayheadTime(t);
|
|
1460
|
+
};
|
|
1461
|
+
const onUp = () => {
|
|
1462
|
+
document.removeEventListener('mousemove', onMove);
|
|
1463
|
+
document.removeEventListener('mouseup', onUp);
|
|
1464
|
+
};
|
|
1465
|
+
document.addEventListener('mousemove', onMove);
|
|
1466
|
+
document.addEventListener('mouseup', onUp);
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// === Zoom ===
|
|
1471
|
+
// Зум таймлайна: меняем только размеры/позиции существующего DOM,
|
|
1472
|
+
// не пересоздаём video/img элементы (они дорогие). При первом зуме до renderTimeline
|
|
1473
|
+
// делаем полный рендер.
|
|
1474
|
+
let _zoomTarget = null;
|
|
1475
|
+
let _zoomFrame = null;
|
|
1476
|
+
function setTimelineZoom(z) {
|
|
1477
|
+
const newZ = Math.max(5, Math.min(400, z));
|
|
1478
|
+
if (Math.abs(newZ - state.timelineZoom) < 0.001) return;
|
|
1479
|
+
_zoomTarget = newZ;
|
|
1480
|
+
if (_zoomFrame) return;
|
|
1481
|
+
_zoomFrame = requestAnimationFrame(() => {
|
|
1482
|
+
_zoomFrame = null;
|
|
1483
|
+
if (_zoomTarget == null) return;
|
|
1484
|
+
state.timelineZoom = _zoomTarget;
|
|
1485
|
+
_zoomTarget = null;
|
|
1486
|
+
localStorage.setItem('timelineZoom', String(state.timelineZoom));
|
|
1487
|
+
if (!applyTimelineZoom()) renderTimeline();
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Переразложить таймлайн под новый zoom без пересоздания элементов.
|
|
1492
|
+
// Возвращает false, если DOM ещё не построен (тогда нужен renderTimeline).
|
|
1493
|
+
function applyTimelineZoom() {
|
|
1494
|
+
const tl = state.currentBoard?.metadata?.timeline;
|
|
1495
|
+
const tracksEl = $('timelineTracks');
|
|
1496
|
+
if (!tl || !tracksEl || !tracksEl.children.length) return false;
|
|
1497
|
+
|
|
1498
|
+
let maxDur = 0;
|
|
1499
|
+
for (const t of tl.tracks) for (const c of t.clips) {
|
|
1500
|
+
const end = (+c.start || 0) + (+c.duration || 0);
|
|
1501
|
+
if (end > maxDur) maxDur = end;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const z = state.timelineZoom;
|
|
1505
|
+
$('tlZoomLabel').textContent = Math.round(z);
|
|
1506
|
+
|
|
1507
|
+
// Линейка: ширина + перерисовать тики (шаг зависит от zoom)
|
|
1508
|
+
const rulerContent = tracksEl.querySelector('.ruler-content');
|
|
1509
|
+
if (rulerContent) {
|
|
1510
|
+
rulerContent.style.width = (maxDur * z + 138) + 'px';
|
|
1511
|
+
rulerContent.innerHTML = '';
|
|
1512
|
+
const step = z >= 80 ? 1 : z >= 40 ? 2 : z >= 20 ? 5 : 10;
|
|
1513
|
+
for (let s = 0; s <= maxDur; s += step) {
|
|
1514
|
+
const tick = document.createElement('div');
|
|
1515
|
+
tick.className = 'tick';
|
|
1516
|
+
tick.style.left = (s * z) + 'px';
|
|
1517
|
+
tick.textContent = s + 's';
|
|
1518
|
+
rulerContent.appendChild(tick);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// Ширина строк клипов
|
|
1523
|
+
for (const trackEl of tracksEl.querySelectorAll('.timeline-track')) {
|
|
1524
|
+
const clipsRow = trackEl.querySelector('.track-clips');
|
|
1525
|
+
if (clipsRow) {
|
|
1526
|
+
const w = (maxDur * z) + 'px';
|
|
1527
|
+
clipsRow.style.width = w;
|
|
1528
|
+
clipsRow.style.minWidth = w;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Каждый клип: left + width
|
|
1533
|
+
for (const t of tl.tracks) {
|
|
1534
|
+
for (const c of t.clips) {
|
|
1535
|
+
const el = tracksEl.querySelector(`.timeline-clip[data-id="${c.id}"]`);
|
|
1536
|
+
if (!el) continue;
|
|
1537
|
+
el.style.left = ((+c.start || 0) * z) + 'px';
|
|
1538
|
+
el.style.width = Math.max(40, (+c.duration || 1) * z) + 'px';
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
positionPlayhead();
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
$('tlZoomIn').addEventListener('click', () => setTimelineZoom(state.timelineZoom * 1.25));
|
|
1547
|
+
$('tlZoomOut').addEventListener('click', () => setTimelineZoom(state.timelineZoom / 1.25));
|
|
1548
|
+
$('tlZoomLabel').addEventListener('click', () => setTimelineZoom(50));
|
|
1549
|
+
|
|
1550
|
+
// Cmd/Ctrl + wheel внутри таймлайна — зум
|
|
1551
|
+
document.addEventListener('wheel', e => {
|
|
1552
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
1553
|
+
if (!e.target.closest('#timelinePanel')) return;
|
|
1554
|
+
e.preventDefault();
|
|
1555
|
+
const factor = Math.exp(-e.deltaY * 0.005);
|
|
1556
|
+
setTimelineZoom(state.timelineZoom * factor);
|
|
1557
|
+
}, { passive: false });
|
|
1558
|
+
|
|
1559
|
+
// Клик по линейке/треку — установить playhead в эту позицию
|
|
1560
|
+
function attachTimelineClickToSeek() {
|
|
1561
|
+
const tracksEl = $('timelineTracks');
|
|
1562
|
+
if (!tracksEl || tracksEl.dataset.seekBound) return;
|
|
1563
|
+
tracksEl.dataset.seekBound = '1';
|
|
1564
|
+
const handler = e => {
|
|
1565
|
+
if (e.target.closest('.track-meta, button, input, .ph-handle, .clip-handle')) return;
|
|
1566
|
+
const onClip = e.target.closest('.timeline-clip');
|
|
1567
|
+
// Во время воспроизведения — клик в любое место (включая клип) останавливает и сикает
|
|
1568
|
+
const playing = !!_playStop;
|
|
1569
|
+
if (onClip && !playing) return;
|
|
1570
|
+
const rect = tracksEl.getBoundingClientRect();
|
|
1571
|
+
const localX = e.clientX - rect.left + tracksEl.scrollLeft - META_WIDTH;
|
|
1572
|
+
if (localX < 0) return;
|
|
1573
|
+
if (playing) stopTimelinePlayback();
|
|
1574
|
+
setPlayheadTime(localX / state.timelineZoom);
|
|
1575
|
+
};
|
|
1576
|
+
// capture-фаза, чтобы перехватить клик до того, как clip остановит распространение
|
|
1577
|
+
tracksEl.addEventListener('click', handler, true);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// =================== Реплики персонажей ===================
|
|
1581
|
+
async function saveCharacterReplicas(charInfo, replicas) {
|
|
1582
|
+
const meta = await loadBoardMetadata(charInfo.handle);
|
|
1583
|
+
if (!meta.character) meta.character = {};
|
|
1584
|
+
meta.character.replicas = replicas;
|
|
1585
|
+
await saveBoardMetadata(charInfo.handle, {
|
|
1586
|
+
nodes: meta.nodes, connections: meta.connections,
|
|
1587
|
+
view: meta.view, character: meta.character, timeline: meta.timeline,
|
|
1588
|
+
});
|
|
1589
|
+
charInfo.replicas = replicas;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Генерируем аудио для реплики, кэшируем в папке персонажа: <char>/audio/replica-<id>.mp3
|
|
1593
|
+
async function generateReplicaCached(charInfo, replica) {
|
|
1594
|
+
if (!charInfo.voice) throw new Error('У персонажа не задан голос — задай через ⚙ Настройки персонажа');
|
|
1595
|
+
const text = replica.text || '';
|
|
1596
|
+
if (!text.trim()) throw new Error('Пустая реплика');
|
|
1597
|
+
// Применяем обычный тон персонажа, если он задан и в тексте ещё нет [tag] в начале
|
|
1598
|
+
const finalText = charInfo.tone && !/^\s*\[/.test(text)
|
|
1599
|
+
? `[${charInfo.tone}] ${text}`
|
|
1600
|
+
: text;
|
|
1601
|
+
const r = await fetch('/api/tts', {
|
|
1602
|
+
method: 'POST',
|
|
1603
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1604
|
+
body: JSON.stringify({ text: finalText, voiceId: charInfo.voice, modelId: 'eleven_v3' }),
|
|
1605
|
+
});
|
|
1606
|
+
console.log(`[replica TTS] via ${r.headers.get('x-provider') || '?'}`);
|
|
1607
|
+
if (!r.ok) {
|
|
1608
|
+
const t = await r.text().catch(() => '');
|
|
1609
|
+
throw new Error(t || `HTTP ${r.status}`);
|
|
1610
|
+
}
|
|
1611
|
+
const blob = await r.blob();
|
|
1612
|
+
const dir = await getOrCreateBoardSubdir(charInfo.handle, 'audio');
|
|
1613
|
+
const fname = `replica-${replica.id}.mp3`;
|
|
1614
|
+
await writeFile(dir, fname, blob);
|
|
1615
|
+
replica.file = `audio/${fname}`;
|
|
1616
|
+
replica.cachedText = text;
|
|
1617
|
+
replica.generatedAt = Date.now();
|
|
1618
|
+
await saveCharacterReplicas(charInfo, charInfo.replicas);
|
|
1619
|
+
return blob;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Получить blob: если есть свежий кэш → читаем из папки персонажа, иначе генерируем
|
|
1623
|
+
async function getReplicaBlob(charInfo, replica) {
|
|
1624
|
+
const text = replica.text || '';
|
|
1625
|
+
if (replica.file && replica.cachedText === text) {
|
|
1626
|
+
try {
|
|
1627
|
+
const fh = await resolveBoardFile(charInfo.handle, replica.file);
|
|
1628
|
+
return await fh.getFile();
|
|
1629
|
+
} catch {}
|
|
1630
|
+
}
|
|
1631
|
+
await generateReplicaCached(charInfo, replica);
|
|
1632
|
+
const fh = await resolveBoardFile(charInfo.handle, replica.file);
|
|
1633
|
+
return await fh.getFile();
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
async function copyReplicaToCurrentBoard(charInfo, replica) {
|
|
1637
|
+
// Берём кэш (или генерим), копируем в текущую доску audio/
|
|
1638
|
+
const blob = await getReplicaBlob(charInfo, replica);
|
|
1639
|
+
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'audio');
|
|
1640
|
+
const slug = slugifyPrompt(replica.text) || 'replica';
|
|
1641
|
+
const baseName = await uniqueName(dir, `${charInfo.name}-${slug}.mp3`);
|
|
1642
|
+
await writeFile(dir, baseName, blob);
|
|
1643
|
+
return { file: `audio/${baseName}`, slug };
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function replicaToBoard(charInfo, replica, statusEl) {
|
|
1647
|
+
if (!state.currentBoard) return;
|
|
1648
|
+
statusEl.classList.remove('error');
|
|
1649
|
+
statusEl.textContent = '⏳ Копирую...';
|
|
1650
|
+
try {
|
|
1651
|
+
const { file, slug } = await copyReplicaToCurrentBoard(charInfo, replica);
|
|
1652
|
+
const node = {
|
|
1653
|
+
id: crypto.randomUUID(),
|
|
1654
|
+
type: 'audio',
|
|
1655
|
+
name: slug,
|
|
1656
|
+
file,
|
|
1657
|
+
x: canvasWrap.scrollLeft / state.zoom + 80,
|
|
1658
|
+
y: canvasWrap.scrollTop / state.zoom + 80,
|
|
1659
|
+
generated: {
|
|
1660
|
+
kind: 'audio', prompt: replica.text, rawPrompt: replica.text,
|
|
1661
|
+
model: 'eleven_v3', voiceId: charInfo.voice, voiceName: charInfo.voiceName || '',
|
|
1662
|
+
replica: { charName: charInfo.name, replicaId: replica.id },
|
|
1663
|
+
},
|
|
1664
|
+
};
|
|
1665
|
+
state.currentBoard.metadata.nodes.push(node);
|
|
1666
|
+
canvas.appendChild(await createNodeEl(node));
|
|
1667
|
+
scheduleSave();
|
|
1668
|
+
statusEl.textContent = '✓ На доске';
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
statusEl.classList.add('error');
|
|
1671
|
+
statusEl.textContent = e.message;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function replicaToTimeline(charInfo, replica, statusEl) {
|
|
1676
|
+
if (!state.currentBoard) return;
|
|
1677
|
+
statusEl.classList.remove('error');
|
|
1678
|
+
statusEl.textContent = '⏳ Копирую...';
|
|
1679
|
+
try {
|
|
1680
|
+
const { file, slug } = await copyReplicaToCurrentBoard(charInfo, replica);
|
|
1681
|
+
const tl = getTimeline();
|
|
1682
|
+
let track = tl.tracks.find(t => t.kind === 'audio');
|
|
1683
|
+
if (!track) {
|
|
1684
|
+
track = { id: crypto.randomUUID(), name: 'Аудио', kind: 'audio', clips: [] };
|
|
1685
|
+
tl.tracks.push(track);
|
|
1686
|
+
}
|
|
1687
|
+
const trackEnd = track.clips.reduce((m, c) =>
|
|
1688
|
+
Math.max(m, (+c.start || 0) + (+c.duration || 0)), 0);
|
|
1689
|
+
track.clips.push({
|
|
1690
|
+
id: crypto.randomUUID(), nodeId: null, type: 'audio', file,
|
|
1691
|
+
name: slug, duration: 5, start: trackEnd,
|
|
1692
|
+
});
|
|
1693
|
+
scheduleSave();
|
|
1694
|
+
if (!$('timelinePanel').classList.contains('hidden')) renderTimeline();
|
|
1695
|
+
statusEl.textContent = '✓ В таймлайне';
|
|
1696
|
+
} catch (e) {
|
|
1697
|
+
statusEl.classList.add('error');
|
|
1698
|
+
statusEl.textContent = e.message;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const _replicaURLs = new Map(); // replica.id -> blob URL для аудио-плеера
|
|
1703
|
+
|
|
1704
|
+
async function getReplicaPreviewURL(charInfo, replica) {
|
|
1705
|
+
if (!replica.file) return null;
|
|
1706
|
+
// В ключ включаем generatedAt + актуальный fingerprint файла (size+mtime) — иначе после
|
|
1707
|
+
// перегенерации Chrome возвращает ERR_UPLOAD_FILE_CHANGED для старого blob URL.
|
|
1708
|
+
let fingerprint = '';
|
|
1709
|
+
let file = null;
|
|
1710
|
+
try {
|
|
1711
|
+
const fh = await resolveBoardFile(charInfo.handle, replica.file);
|
|
1712
|
+
file = await fh.getFile();
|
|
1713
|
+
fingerprint = `${file.size}.${file.lastModified || 0}`;
|
|
1714
|
+
} catch { return null; }
|
|
1715
|
+
const cacheKey = `${charInfo.name}::${replica.id}::${replica.generatedAt || 0}::${fingerprint}`;
|
|
1716
|
+
if (_replicaURLs.has(cacheKey)) return _replicaURLs.get(cacheKey);
|
|
1717
|
+
// Освобождаем все старые URL этой реплики (они могли стать невалидными после регена файла)
|
|
1718
|
+
for (const [k, u] of [..._replicaURLs.entries()]) {
|
|
1719
|
+
if (k.startsWith(`${charInfo.name}::${replica.id}::`)) {
|
|
1720
|
+
URL.revokeObjectURL(u);
|
|
1721
|
+
_replicaURLs.delete(k);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
const url = URL.createObjectURL(file);
|
|
1725
|
+
_replicaURLs.set(cacheKey, url);
|
|
1726
|
+
return url;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const _replicaGenTimers = new Map(); // replica.id -> debounce timer
|
|
1730
|
+
|
|
1731
|
+
function scheduleReplicaRegen(charInfo, replica, statusEl, audioEl) {
|
|
1732
|
+
if (_replicaGenTimers.has(replica.id)) clearTimeout(_replicaGenTimers.get(replica.id));
|
|
1733
|
+
const t = setTimeout(async () => {
|
|
1734
|
+
_replicaGenTimers.delete(replica.id);
|
|
1735
|
+
if (!replica.text || !replica.text.trim()) return;
|
|
1736
|
+
if (replica.cachedText === replica.text) return;
|
|
1737
|
+
statusEl.classList.remove('error');
|
|
1738
|
+
statusEl.textContent = '⏳ Генерирую...';
|
|
1739
|
+
try {
|
|
1740
|
+
await generateReplicaCached(charInfo, replica);
|
|
1741
|
+
const url = await getReplicaPreviewURL(charInfo, replica);
|
|
1742
|
+
if (url && audioEl) audioEl.src = url;
|
|
1743
|
+
statusEl.textContent = `✓ ${new Date().toLocaleTimeString()}`;
|
|
1744
|
+
} catch (e) {
|
|
1745
|
+
statusEl.classList.add('error');
|
|
1746
|
+
statusEl.textContent = e.message;
|
|
1747
|
+
}
|
|
1748
|
+
}, 800);
|
|
1749
|
+
_replicaGenTimers.set(replica.id, t);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
let _replicaPlayer = null; // <audio> для проигрывания текущей реплики
|
|
1753
|
+
let _selectedReplicaId = null;
|
|
1754
|
+
|
|
1755
|
+
async function renderRepliquesList(charInfo) {
|
|
1756
|
+
const list = $('repliquesList');
|
|
1757
|
+
list.innerHTML = '';
|
|
1758
|
+
const all = charInfo.replicas || [];
|
|
1759
|
+
const query = ($('repliquesSearch')?.value || '').trim().toLowerCase();
|
|
1760
|
+
const replicas = query
|
|
1761
|
+
? all.filter(r => (r.text || '').toLowerCase().includes(query))
|
|
1762
|
+
: all;
|
|
1763
|
+
if (!replicas.length) {
|
|
1764
|
+
const empty = document.createElement('div');
|
|
1765
|
+
empty.style.cssText = 'color:#888; padding:12px; text-align:center;';
|
|
1766
|
+
empty.textContent = all.length === 0
|
|
1767
|
+
? 'Пока нет реплик. Нажми «+ Добавить реплику».'
|
|
1768
|
+
: 'Ничего не найдено по запросу.';
|
|
1769
|
+
list.appendChild(empty);
|
|
1770
|
+
renderRepliqueActions(charInfo);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Если выделение протухло — выберем первую
|
|
1775
|
+
if (!replicas.some(r => r.id === _selectedReplicaId)) {
|
|
1776
|
+
_selectedReplicaId = replicas[0].id;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
for (const replica of replicas) {
|
|
1780
|
+
const row = document.createElement('div');
|
|
1781
|
+
row.className = 'replique-row';
|
|
1782
|
+
if (replica.id === _selectedReplicaId) row.classList.add('selected');
|
|
1783
|
+
row.dataset.id = replica.id;
|
|
1784
|
+
|
|
1785
|
+
const playBtn = document.createElement('button');
|
|
1786
|
+
playBtn.className = 'play-btn';
|
|
1787
|
+
playBtn.textContent = '▶';
|
|
1788
|
+
playBtn.title = 'Прослушать (если есть кэш)';
|
|
1789
|
+
playBtn.disabled = !replica.file;
|
|
1790
|
+
if (!replica.file) playBtn.style.opacity = '0.3';
|
|
1791
|
+
playBtn.addEventListener('click', async (e) => {
|
|
1792
|
+
e.stopPropagation();
|
|
1793
|
+
// Toggle: если этот же играет — стоп
|
|
1794
|
+
if (_replicaPlayer && !_replicaPlayer.paused && playBtn.classList.contains('playing')) {
|
|
1795
|
+
_replicaPlayer.pause();
|
|
1796
|
+
playBtn.classList.remove('playing');
|
|
1797
|
+
playBtn.textContent = '▶';
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
// Стопаем все другие play-btn
|
|
1801
|
+
list.querySelectorAll('.play-btn.playing').forEach(b => {
|
|
1802
|
+
b.classList.remove('playing'); b.textContent = '▶';
|
|
1803
|
+
});
|
|
1804
|
+
try {
|
|
1805
|
+
const url = await getReplicaPreviewURL(charInfo, replica);
|
|
1806
|
+
if (!url) return;
|
|
1807
|
+
if (!_replicaPlayer) _replicaPlayer = new Audio();
|
|
1808
|
+
_replicaPlayer.src = url;
|
|
1809
|
+
_replicaPlayer.currentTime = 0;
|
|
1810
|
+
playBtn.classList.add('playing');
|
|
1811
|
+
playBtn.textContent = '■';
|
|
1812
|
+
_replicaPlayer.onended = () => {
|
|
1813
|
+
playBtn.classList.remove('playing'); playBtn.textContent = '▶';
|
|
1814
|
+
};
|
|
1815
|
+
await _replicaPlayer.play();
|
|
1816
|
+
} catch (err) { console.error(err); }
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
const ta = document.createElement('textarea');
|
|
1820
|
+
ta.value = replica.text || '';
|
|
1821
|
+
ta.placeholder = 'Текст реплики ([whispers], [excited]...)';
|
|
1822
|
+
ta.addEventListener('input', () => {
|
|
1823
|
+
replica.text = ta.value;
|
|
1824
|
+
renderRepliqueActions(charInfo);
|
|
1825
|
+
});
|
|
1826
|
+
ta.addEventListener('change', async () => {
|
|
1827
|
+
replica.text = ta.value;
|
|
1828
|
+
await saveCharacterReplicas(charInfo, charInfo.replicas);
|
|
1829
|
+
});
|
|
1830
|
+
ta.addEventListener('focus', () => selectReplicaRow(replica.id, charInfo));
|
|
1831
|
+
|
|
1832
|
+
const status = document.createElement('div');
|
|
1833
|
+
status.className = 'replique-status';
|
|
1834
|
+
if (replica.file && replica.cachedText === replica.text) {
|
|
1835
|
+
status.textContent = `✓ ${new Date(replica.generatedAt || 0).toLocaleTimeString()}`;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
row.appendChild(playBtn);
|
|
1839
|
+
row.appendChild(ta);
|
|
1840
|
+
row.appendChild(status);
|
|
1841
|
+
row.addEventListener('click', () => selectReplicaRow(replica.id, charInfo));
|
|
1842
|
+
list.appendChild(row);
|
|
1843
|
+
}
|
|
1844
|
+
renderRepliqueActions(charInfo);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function selectReplicaRow(id, charInfo) {
|
|
1848
|
+
_selectedReplicaId = id;
|
|
1849
|
+
document.querySelectorAll('#repliquesList .replique-row').forEach(r => {
|
|
1850
|
+
r.classList.toggle('selected', r.dataset.id === id);
|
|
1851
|
+
});
|
|
1852
|
+
renderRepliqueActions(charInfo);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function getSelectedReplica(charInfo) {
|
|
1856
|
+
return (charInfo.replicas || []).find(r => r.id === _selectedReplicaId);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function renderRepliqueActions(charInfo) {
|
|
1860
|
+
let bar = document.getElementById('repliquesActionBar');
|
|
1861
|
+
if (!bar) {
|
|
1862
|
+
bar = document.createElement('div');
|
|
1863
|
+
bar.id = 'repliquesActionBar';
|
|
1864
|
+
bar.className = 'replique-actions';
|
|
1865
|
+
$('repliquesList').after(bar);
|
|
1866
|
+
}
|
|
1867
|
+
bar.innerHTML = '';
|
|
1868
|
+
const replica = getSelectedReplica(charInfo);
|
|
1869
|
+
if (!replica) {
|
|
1870
|
+
bar.style.display = 'none';
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
bar.style.display = '';
|
|
1874
|
+
|
|
1875
|
+
const status = document.createElement('div');
|
|
1876
|
+
status.className = 'replique-status';
|
|
1877
|
+
status.style.flex = '1 1 100%';
|
|
1878
|
+
|
|
1879
|
+
const ready = !!replica.file && replica.cachedText === replica.text;
|
|
1880
|
+
const genBtn = document.createElement('button');
|
|
1881
|
+
genBtn.className = 'primary';
|
|
1882
|
+
genBtn.textContent = ready ? '🎙 Перегенерировать' : '🎙 Сгенерировать';
|
|
1883
|
+
genBtn.addEventListener('click', async () => {
|
|
1884
|
+
genBtn.disabled = true;
|
|
1885
|
+
genBtn.textContent = '⏳ TTS...';
|
|
1886
|
+
status.classList.remove('error');
|
|
1887
|
+
try {
|
|
1888
|
+
await generateReplicaCached(charInfo, replica);
|
|
1889
|
+
status.textContent = `✓ ${new Date().toLocaleTimeString()}`;
|
|
1890
|
+
renderRepliquesList(charInfo);
|
|
1891
|
+
} catch (e) {
|
|
1892
|
+
status.classList.add('error');
|
|
1893
|
+
status.textContent = e.message;
|
|
1894
|
+
genBtn.disabled = false;
|
|
1895
|
+
genBtn.textContent = ready ? '🎙 Перегенерировать' : '🎙 Сгенерировать';
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
const toBoardBtn = document.createElement('button');
|
|
1900
|
+
toBoardBtn.textContent = '→ На доску';
|
|
1901
|
+
toBoardBtn.addEventListener('click', () => replicaToBoard(charInfo, replica, status));
|
|
1902
|
+
|
|
1903
|
+
const toTlBtn = document.createElement('button');
|
|
1904
|
+
toTlBtn.textContent = '→ В таймлайн';
|
|
1905
|
+
toTlBtn.addEventListener('click', () => replicaToTimelineWithContext(charInfo, replica, status));
|
|
1906
|
+
|
|
1907
|
+
const delBtn = document.createElement('button');
|
|
1908
|
+
delBtn.className = 'danger';
|
|
1909
|
+
delBtn.textContent = '🗑 Удалить';
|
|
1910
|
+
delBtn.style.flex = '0 0 auto';
|
|
1911
|
+
delBtn.addEventListener('click', async () => {
|
|
1912
|
+
if (!confirm('Удалить реплику?')) return;
|
|
1913
|
+
charInfo.replicas = (charInfo.replicas || []).filter(r => r.id !== replica.id);
|
|
1914
|
+
if (replica.file) {
|
|
1915
|
+
try { await removeBoardFile(charInfo.handle, replica.file); } catch {}
|
|
1916
|
+
}
|
|
1917
|
+
await saveCharacterReplicas(charInfo, charInfo.replicas);
|
|
1918
|
+
_selectedReplicaId = null;
|
|
1919
|
+
renderRepliquesList(charInfo);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
bar.appendChild(genBtn);
|
|
1923
|
+
bar.appendChild(toBoardBtn);
|
|
1924
|
+
bar.appendChild(toTlBtn);
|
|
1925
|
+
bar.appendChild(delBtn);
|
|
1926
|
+
bar.appendChild(status);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Контекст для → В таймлайн (когда вызвано из dblclick таймлайна)
|
|
1930
|
+
state.timelineDropContext = null;
|
|
1931
|
+
async function replicaToTimelineWithContext(charInfo, replica, statusEl) {
|
|
1932
|
+
const ctx = state.timelineDropContext;
|
|
1933
|
+
if (ctx && Date.now() - ctx.ts < 60_000) {
|
|
1934
|
+
// Используем сохранённый контекст: trackId + time
|
|
1935
|
+
const tl = getTimeline();
|
|
1936
|
+
const track = tl.tracks.find(t => t.id === ctx.trackId);
|
|
1937
|
+
if (track) {
|
|
1938
|
+
try {
|
|
1939
|
+
statusEl.textContent = '⏳ Копирую...';
|
|
1940
|
+
const blob = await getReplicaBlob(charInfo, replica);
|
|
1941
|
+
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'audio');
|
|
1942
|
+
const slug = slugifyPrompt(replica.text) || 'replica';
|
|
1943
|
+
const baseName = await uniqueName(dir, `${charInfo.name}-${slug}.mp3`);
|
|
1944
|
+
await writeFile(dir, baseName, blob);
|
|
1945
|
+
track.clips.push({
|
|
1946
|
+
id: crypto.randomUUID(), nodeId: null, type: 'audio',
|
|
1947
|
+
file: `audio/${baseName}`, name: slug, duration: 5,
|
|
1948
|
+
start: Math.max(0, ctx.time),
|
|
1949
|
+
});
|
|
1950
|
+
scheduleSave();
|
|
1951
|
+
if (!$('timelinePanel').classList.contains('hidden')) renderTimeline();
|
|
1952
|
+
statusEl.textContent = '✓ В таймлайне';
|
|
1953
|
+
state.timelineDropContext = null;
|
|
1954
|
+
return;
|
|
1955
|
+
} catch (e) {
|
|
1956
|
+
statusEl.classList.add('error');
|
|
1957
|
+
statusEl.textContent = e.message;
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
// Fallback: стандартный replicaToTimeline (в конец первой audio-дорожки)
|
|
1963
|
+
return replicaToTimeline(charInfo, replica, statusEl);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function getSelectedReplicaChar() {
|
|
1967
|
+
const id = $('repliquesChar').value;
|
|
1968
|
+
return state.charactersInfo.find(c => c.name === id);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Открыть панель реплик и предвыбрать заданного персонажа (или последнего).
|
|
1972
|
+
async function openRepliquesFor(charName) {
|
|
1973
|
+
const panel = $('repliquesPanel');
|
|
1974
|
+
await loadAllCharactersInfo();
|
|
1975
|
+
const sel = $('repliquesChar');
|
|
1976
|
+
sel.innerHTML = '';
|
|
1977
|
+
if (!state.charactersInfo.length) return;
|
|
1978
|
+
for (const c of state.charactersInfo) {
|
|
1979
|
+
const opt = document.createElement('option');
|
|
1980
|
+
opt.value = c.name;
|
|
1981
|
+
const voice = c.voiceName ? ` (${c.voiceName.split(' — ')[0]})` : '';
|
|
1982
|
+
opt.textContent = c.name + voice + (c.voice ? '' : ' — голос не задан');
|
|
1983
|
+
sel.appendChild(opt);
|
|
1984
|
+
}
|
|
1985
|
+
if (charName && state.charactersInfo.some(c => c.name === charName)) sel.value = charName;
|
|
1986
|
+
else {
|
|
1987
|
+
const last = localStorage.getItem('lastReplicaChar');
|
|
1988
|
+
if (last && state.charactersInfo.some(c => c.name === last)) sel.value = last;
|
|
1989
|
+
}
|
|
1990
|
+
const ch = getSelectedReplicaChar();
|
|
1991
|
+
if (ch) renderRepliquesList(ch);
|
|
1992
|
+
panel.classList.remove('hidden');
|
|
1993
|
+
localStorage.setItem('repliquesOpen', '1');
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
$('repliquesBtn').addEventListener('click', async () => {
|
|
1997
|
+
const panel = $('repliquesPanel');
|
|
1998
|
+
if (!panel.classList.contains('hidden')) {
|
|
1999
|
+
panel.classList.add('hidden');
|
|
2000
|
+
localStorage.setItem('repliquesOpen', '0');
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
await loadAllCharactersInfo();
|
|
2004
|
+
const sel = $('repliquesChar');
|
|
2005
|
+
sel.innerHTML = '';
|
|
2006
|
+
if (!state.charactersInfo.length) {
|
|
2007
|
+
alert('Сначала создай хотя бы одного персонажа.');
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
for (const c of state.charactersInfo) {
|
|
2011
|
+
const opt = document.createElement('option');
|
|
2012
|
+
opt.value = c.name;
|
|
2013
|
+
const voice = c.voiceName ? ` (${c.voiceName.split(' — ')[0]})` : '';
|
|
2014
|
+
opt.textContent = c.name + voice + (c.voice ? '' : ' — голос не задан');
|
|
2015
|
+
sel.appendChild(opt);
|
|
2016
|
+
}
|
|
2017
|
+
const last = localStorage.getItem('lastReplicaChar');
|
|
2018
|
+
if (last && state.charactersInfo.some(c => c.name === last)) sel.value = last;
|
|
2019
|
+
const ch = getSelectedReplicaChar();
|
|
2020
|
+
if (ch) renderRepliquesList(ch);
|
|
2021
|
+
panel.classList.remove('hidden');
|
|
2022
|
+
localStorage.setItem('repliquesOpen', '1');
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
$('repliquesChar').addEventListener('change', () => {
|
|
2026
|
+
const ch = getSelectedReplicaChar();
|
|
2027
|
+
if (!ch) return;
|
|
2028
|
+
localStorage.setItem('lastReplicaChar', ch.name);
|
|
2029
|
+
$('repliquesSearch').value = '';
|
|
2030
|
+
renderRepliquesList(ch);
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
$('repliquesSearch').addEventListener('input', () => {
|
|
2034
|
+
const ch = getSelectedReplicaChar();
|
|
2035
|
+
if (ch) renderRepliquesList(ch);
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
$('repliqueAdd').addEventListener('click', async () => {
|
|
2039
|
+
const ch = getSelectedReplicaChar();
|
|
2040
|
+
if (!ch) return;
|
|
2041
|
+
if (!Array.isArray(ch.replicas)) ch.replicas = [];
|
|
2042
|
+
const newReplica = { id: crypto.randomUUID(), text: '' };
|
|
2043
|
+
ch.replicas.unshift(newReplica); // в начало списка для удобства
|
|
2044
|
+
await saveCharacterReplicas(ch, ch.replicas);
|
|
2045
|
+
$('repliquesSearch').value = '';
|
|
2046
|
+
await renderRepliquesList(ch);
|
|
2047
|
+
// Автофокус на textarea новой реплики
|
|
2048
|
+
const firstRow = $('repliquesList').querySelector('.replique-row');
|
|
2049
|
+
const ta = firstRow?.querySelector('textarea');
|
|
2050
|
+
if (ta) ta.focus();
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
$('repliquesClose').addEventListener('click', () => {
|
|
2054
|
+
$('repliquesPanel').classList.add('hidden');
|
|
2055
|
+
localStorage.setItem('repliquesOpen', '0');
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// Меню выбора персонажа для вставки реплики на таймлайн
|
|
2059
|
+
function showCharSelectMenu(track, time, clientX, clientY) {
|
|
2060
|
+
const menu = $('nodeMenu');
|
|
2061
|
+
menu.innerHTML = '';
|
|
2062
|
+
const add = (label, fn, opts = {}) => {
|
|
2063
|
+
const b = document.createElement('button');
|
|
2064
|
+
b.textContent = label;
|
|
2065
|
+
if (opts.disabled) { b.disabled = true; b.style.opacity = '0.5'; }
|
|
2066
|
+
b.addEventListener('click', () => fn());
|
|
2067
|
+
menu.appendChild(b);
|
|
2068
|
+
};
|
|
2069
|
+
// Свободная генерация — без привязки к персонажу.
|
|
2070
|
+
add('🎙 Свободно (без персонажа)', () => {
|
|
2071
|
+
state.timelineGenTarget = { trackId: track.id, time: Math.max(0, +time || 0), charName: null };
|
|
2072
|
+
openGenModal('audio');
|
|
2073
|
+
});
|
|
2074
|
+
add('—', () => {}, { disabled: true });
|
|
2075
|
+
add('💬 Реплики персонажей:', () => {}, { disabled: true });
|
|
2076
|
+
const withReplicas = state.charactersInfo.filter(c => (c.replicas?.length || 0) > 0 && c.voice);
|
|
2077
|
+
if (!withReplicas.length) {
|
|
2078
|
+
add('— ни у кого нет записанных реплик с голосом —', () => {}, { disabled: true });
|
|
2079
|
+
} else {
|
|
2080
|
+
for (const c of withReplicas) {
|
|
2081
|
+
add(`👤 ${c.name} (${c.replicas.length})`, () => showCharReplicaMenu(c, track, time, clientX, clientY));
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
2085
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
function showCharReplicaMenu(charInfo, track, time, clientX, clientY) {
|
|
2089
|
+
const menu = $('nodeMenu');
|
|
2090
|
+
menu.innerHTML = '';
|
|
2091
|
+
const back = document.createElement('button');
|
|
2092
|
+
back.textContent = '◀ Назад';
|
|
2093
|
+
back.addEventListener('click', () => showCharSelectMenu(track, time, clientX, clientY));
|
|
2094
|
+
menu.appendChild(back);
|
|
2095
|
+
const title = document.createElement('button');
|
|
2096
|
+
title.textContent = `👤 ${charInfo.name}`;
|
|
2097
|
+
title.disabled = true; title.style.opacity = '0.6';
|
|
2098
|
+
menu.appendChild(title);
|
|
2099
|
+
// 🎙 Сгенерировать новую реплику — открывает gen-модалку с привязкой к этой точке таймлайна
|
|
2100
|
+
const genBtn = document.createElement('button');
|
|
2101
|
+
genBtn.textContent = '🎙 Сгенерировать';
|
|
2102
|
+
genBtn.addEventListener('click', () => {
|
|
2103
|
+
menu.classList.add('hidden');
|
|
2104
|
+
openGenAudioForTimeline(charInfo, track, time);
|
|
2105
|
+
});
|
|
2106
|
+
menu.appendChild(genBtn);
|
|
2107
|
+
for (const r of charInfo.replicas || []) {
|
|
2108
|
+
const b = document.createElement('button');
|
|
2109
|
+
const txt = (r.text || '(пусто)').slice(0, 80);
|
|
2110
|
+
b.textContent = `💬 ${txt}${(r.text || '').length > 80 ? '…' : ''}`;
|
|
2111
|
+
b.style.maxWidth = '360px';
|
|
2112
|
+
b.addEventListener('click', async () => {
|
|
2113
|
+
menu.classList.add('hidden');
|
|
2114
|
+
await insertReplicaAtTime(charInfo, r, track, time);
|
|
2115
|
+
});
|
|
2116
|
+
menu.appendChild(b);
|
|
2117
|
+
}
|
|
2118
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
async function insertReplicaAtTime(charInfo, replica, track, time) {
|
|
2122
|
+
try {
|
|
2123
|
+
const blob = await getReplicaBlob(charInfo, replica);
|
|
2124
|
+
const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'audio');
|
|
2125
|
+
const slug = slugifyPrompt(replica.text) || 'replica';
|
|
2126
|
+
const baseName = await uniqueName(dir, `${charInfo.name}-${slug}.mp3`);
|
|
2127
|
+
await writeFile(dir, baseName, blob);
|
|
2128
|
+
track.clips.push({
|
|
2129
|
+
id: crypto.randomUUID(),
|
|
2130
|
+
nodeId: null, type: 'audio',
|
|
2131
|
+
file: `audio/${baseName}`,
|
|
2132
|
+
name: slug,
|
|
2133
|
+
duration: 5, // обновится после loadedmetadata
|
|
2134
|
+
start: Math.max(0, time),
|
|
2135
|
+
});
|
|
2136
|
+
scheduleSave();
|
|
2137
|
+
renderTimeline();
|
|
2138
|
+
} catch (e) {
|
|
2139
|
+
alert('Не удалось вставить реплику: ' + (e?.message || e));
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function showClipContextMenu(clip, track, tl, clientX, clientY) {
|
|
2144
|
+
const menu = $('nodeMenu');
|
|
2145
|
+
menu.innerHTML = '';
|
|
2146
|
+
const add = (label, fn, opts = {}) => {
|
|
2147
|
+
const b = document.createElement('button');
|
|
2148
|
+
b.textContent = label;
|
|
2149
|
+
if (opts.danger) b.style.color = '#f88';
|
|
2150
|
+
if (opts.disabled) { b.disabled = true; b.style.opacity = '0.5'; }
|
|
2151
|
+
b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
|
|
2152
|
+
menu.appendChild(b);
|
|
2153
|
+
};
|
|
2154
|
+
const title = `${clip.type === 'audio' ? '🔊' : clip.type === 'video' ? '🎬' : '🖼'} ${clip.name || ''}`;
|
|
2155
|
+
add(title, () => {}, { disabled: true });
|
|
2156
|
+
// Если клип не выделен — выделим его перед действиями (чтобы "Скопировать" работал на этот клип)
|
|
2157
|
+
const selIds = state.selectedClipIds.has(clip.id) && state.selectedClipIds.size > 1
|
|
2158
|
+
? [...state.selectedClipIds]
|
|
2159
|
+
: [clip.id];
|
|
2160
|
+
// Группа: создать / разгруппировать
|
|
2161
|
+
if (selIds.length > 1) {
|
|
2162
|
+
add(`🔗 Группировать (${selIds.length})`, () => {
|
|
2163
|
+
const gid = crypto.randomUUID();
|
|
2164
|
+
for (const id of selIds) {
|
|
2165
|
+
const f = findClipById(tl, id);
|
|
2166
|
+
if (f) f.clip.groupId = gid;
|
|
2167
|
+
}
|
|
2168
|
+
scheduleSave();
|
|
2169
|
+
renderTimeline();
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
if (clip.groupId) {
|
|
2173
|
+
const groupSize = getGroupClips(clip.groupId, tl).length;
|
|
2174
|
+
add(`🔓 Разгруппировать (${groupSize})`, () => {
|
|
2175
|
+
const gid = clip.groupId;
|
|
2176
|
+
for (const t of tl.tracks) {
|
|
2177
|
+
for (const c of t.clips) {
|
|
2178
|
+
if (c.groupId === gid) delete c.groupId;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
scheduleSave();
|
|
2182
|
+
renderTimeline();
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Разрезать клип в точке playhead (если playhead внутри этого клипа)
|
|
2187
|
+
const ph = state.playheadTime || 0;
|
|
2188
|
+
const cs = +clip.start || 0;
|
|
2189
|
+
const cd = +clip.duration || 0;
|
|
2190
|
+
const canSplit = ph > cs + 0.05 && ph < cs + cd - 0.05 && cd > 0.1;
|
|
2191
|
+
add('✂ Разрезать у playhead', async () => {
|
|
2192
|
+
pushTimelineUndo('Разрезать клип');
|
|
2193
|
+
const ok = splitClipAt(clip, track, ph);
|
|
2194
|
+
if (!ok) {
|
|
2195
|
+
// Откат — снимаем последнюю запись истории, мутации не было
|
|
2196
|
+
const h = _getHistory(); if (h && h.past.length) h.past.pop();
|
|
2197
|
+
alert('Не удалось разрезать в этой точке.');
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
scheduleSave();
|
|
2201
|
+
renderTimeline();
|
|
2202
|
+
}, { disabled: !canSplit });
|
|
2203
|
+
|
|
2204
|
+
// Включить / отключить клип (skip при playback)
|
|
2205
|
+
const allDisabled = selIds.every(id => {
|
|
2206
|
+
const f = findClipById(tl, id); return f && f.clip.disabled;
|
|
2207
|
+
});
|
|
2208
|
+
const toggleLbl = allDisabled
|
|
2209
|
+
? (selIds.length > 1 ? `👁 Включить (${selIds.length})` : '👁 Включить')
|
|
2210
|
+
: (selIds.length > 1 ? `🚫 Отключить (${selIds.length})` : '🚫 Отключить');
|
|
2211
|
+
add(toggleLbl, () => {
|
|
2212
|
+
for (const id of selIds) {
|
|
2213
|
+
const f = findClipById(tl, id);
|
|
2214
|
+
if (f) f.clip.disabled = !allDisabled;
|
|
2215
|
+
}
|
|
2216
|
+
scheduleSave();
|
|
2217
|
+
renderTimeline();
|
|
2218
|
+
});
|
|
2219
|
+
const copyLbl = selIds.length > 1 ? `⎘ Скопировать (${selIds.length})` : '⎘ Скопировать';
|
|
2220
|
+
add(copyLbl, async () => {
|
|
2221
|
+
const n = await copyTimelineClipsToClipboard(selIds);
|
|
2222
|
+
if (!n) alert('Не удалось скопировать в буфер.');
|
|
2223
|
+
});
|
|
2224
|
+
const cutLbl = selIds.length > 1 ? `✂ Вырезать (${selIds.length})` : '✂ Вырезать';
|
|
2225
|
+
add(cutLbl, async () => {
|
|
2226
|
+
const n = await copyTimelineClipsToClipboard(selIds);
|
|
2227
|
+
if (!n) { alert('Не удалось скопировать в буфер.'); return; }
|
|
2228
|
+
// Удаляем все вырезанные клипы
|
|
2229
|
+
for (const id of selIds) await removeFromTimeline(id);
|
|
2230
|
+
state.selectedClipIds.clear();
|
|
2231
|
+
applyTimelineSelectionVisuals();
|
|
2232
|
+
});
|
|
2233
|
+
// Вставить из буфера — на ту же дорожку, прямо на позицию этого клипа (ripple для видео)
|
|
2234
|
+
const compatPaste = (state.clipboard || []).filter(it => {
|
|
2235
|
+
if (it.node.type === 'text') return false;
|
|
2236
|
+
if (!it.blob && !it.node.file) return false;
|
|
2237
|
+
return track.kind === 'video'
|
|
2238
|
+
? (it.node.type === 'video' || it.node.type === 'image')
|
|
2239
|
+
: it.node.type === 'audio';
|
|
2240
|
+
});
|
|
2241
|
+
add('📥 Вставить из буфера' + (compatPaste.length > 1 ? ` (${compatPaste.length})` : ''),
|
|
2242
|
+
() => pasteClipboardToTimeline(track, +clip.start || 0),
|
|
2243
|
+
{ disabled: !compatPaste.length });
|
|
2244
|
+
add('🗑 Удалить с таймлайна', () => removeFromTimeline(clip.id), { danger: true });
|
|
2245
|
+
if ((+clip.trimStart || 0) > 0 || +clip.duration !== undefined) {
|
|
2246
|
+
add('↺ Сбросить trim', () => {
|
|
2247
|
+
clip.trimStart = 0;
|
|
2248
|
+
// Длительность не сбрасываем — user может задать вручную
|
|
2249
|
+
scheduleSave();
|
|
2250
|
+
renderTimeline();
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
add('⎘ Дублировать', () => {
|
|
2254
|
+
const trackEnd = track.clips.reduce((m, c) =>
|
|
2255
|
+
Math.max(m, (+c.start || 0) + (+c.duration || 0)), 0);
|
|
2256
|
+
track.clips.push({
|
|
2257
|
+
...clip, id: crypto.randomUUID(),
|
|
2258
|
+
start: trackEnd,
|
|
2259
|
+
});
|
|
2260
|
+
scheduleSave();
|
|
2261
|
+
renderTimeline();
|
|
2262
|
+
});
|
|
2263
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
2264
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function showTrackContextMenu(track, clientX, clientY, opts = {}) {
|
|
2268
|
+
const menu = $('nodeMenu');
|
|
2269
|
+
menu.innerHTML = '';
|
|
2270
|
+
const add = (label, fn, mopts = {}) => {
|
|
2271
|
+
const b = document.createElement('button');
|
|
2272
|
+
b.textContent = label;
|
|
2273
|
+
if (mopts.danger) b.style.color = '#f88';
|
|
2274
|
+
if (mopts.disabled) { b.disabled = true; b.style.opacity = '0.4'; b.style.cursor = 'default'; }
|
|
2275
|
+
b.addEventListener('click', () => { if (b.disabled) return; menu.classList.add('hidden'); fn(); });
|
|
2276
|
+
menu.appendChild(b);
|
|
2277
|
+
};
|
|
2278
|
+
const icon = track.kind === 'video' ? '🎬' : '🔊';
|
|
2279
|
+
add(`${icon} ${track.name}`, () => {}, {});
|
|
2280
|
+
menu.children[0].disabled = true;
|
|
2281
|
+
menu.children[0].style.opacity = '0.5';
|
|
2282
|
+
menu.children[0].style.pointerEvents = 'none';
|
|
2283
|
+
|
|
2284
|
+
// Аудио-дорожка: сгенерировать реплику от любого персонажа в эту точку времени
|
|
2285
|
+
if (track.kind === 'audio') {
|
|
2286
|
+
const chars = state.charactersInfo.filter(c => c.voice);
|
|
2287
|
+
if (chars.length) {
|
|
2288
|
+
add('🎙 Сгенерировать реплику:', () => {}, { disabled: true });
|
|
2289
|
+
for (const c of chars) {
|
|
2290
|
+
add(` 👤 ${c.name}`, () => openGenAudioForTimeline(c, track, opts.dropTime || 0));
|
|
2291
|
+
}
|
|
2292
|
+
} else {
|
|
2293
|
+
add('🎙 Реплика — нет персонажей с голосом', () => {}, { disabled: true });
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Скопировать клипы дорожки в буфер
|
|
2298
|
+
add(`⎘ Скопировать клипы дорожки${track.clips.length ? ' (' + track.clips.length + ')' : ''}`,
|
|
2299
|
+
async () => {
|
|
2300
|
+
const ids = track.clips.map(c => c.id);
|
|
2301
|
+
const n = await copyTimelineClipsToClipboard(ids);
|
|
2302
|
+
if (!n) alert('На дорожке нет клипов с файлами.');
|
|
2303
|
+
},
|
|
2304
|
+
{ disabled: !track.clips.length });
|
|
2305
|
+
add(`✂ Вырезать клипы дорожки${track.clips.length ? ' (' + track.clips.length + ')' : ''}`,
|
|
2306
|
+
async () => {
|
|
2307
|
+
const ids = track.clips.map(c => c.id);
|
|
2308
|
+
const n = await copyTimelineClipsToClipboard(ids);
|
|
2309
|
+
if (!n) { alert('На дорожке нет клипов с файлами.'); return; }
|
|
2310
|
+
track.clips = [];
|
|
2311
|
+
scheduleSave();
|
|
2312
|
+
renderTimeline();
|
|
2313
|
+
},
|
|
2314
|
+
{ disabled: !track.clips.length });
|
|
2315
|
+
|
|
2316
|
+
// Вставить из буфера — если есть совместимые элементы
|
|
2317
|
+
const compatItems = (state.clipboard || []).filter(it => {
|
|
2318
|
+
if (it.node.type === 'text') return false;
|
|
2319
|
+
if (!it.blob && !it.node.file) return false;
|
|
2320
|
+
return track.kind === 'video'
|
|
2321
|
+
? (it.node.type === 'video' || it.node.type === 'image')
|
|
2322
|
+
: it.node.type === 'audio';
|
|
2323
|
+
});
|
|
2324
|
+
add('📥 Вставить из буфера' + (compatItems.length > 1 ? ` (${compatItems.length})` : ''),
|
|
2325
|
+
() => pasteClipboardToTimeline(track, opts.dropTime || 0),
|
|
2326
|
+
{ disabled: !compatItems.length });
|
|
2327
|
+
|
|
2328
|
+
add('🗑 Удалить дорожку', () => removeTrack(track.id), { danger: true });
|
|
2329
|
+
if (track.clips.length) {
|
|
2330
|
+
add('⌫ Очистить клипы', () => {
|
|
2331
|
+
if (!confirm(`Очистить клипы дорожки «${track.name}»?`)) return;
|
|
2332
|
+
track.clips = [];
|
|
2333
|
+
scheduleSave();
|
|
2334
|
+
renderTimeline();
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
positionFloatingMenu(menu, clientX, clientY);
|
|
2338
|
+
setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
$('addVideoTrack').addEventListener('click', () => {
|
|
2342
|
+
const tl = getTimeline();
|
|
2343
|
+
const n = tl.tracks.filter(t => t.kind === 'video').length;
|
|
2344
|
+
addTrack(n === 0 ? 'Видео' : `Видео ${n + 1}`, 'video');
|
|
2345
|
+
});
|
|
2346
|
+
$('addAudioTrack').addEventListener('click', () => {
|
|
2347
|
+
const tl = getTimeline();
|
|
2348
|
+
const n = tl.tracks.filter(t => t.kind === 'audio').length;
|
|
2349
|
+
addTrack(n === 0 ? 'Аудио' : (n === 1 ? 'Музыка' : `Аудио ${n + 1}`), 'audio');
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
// === Воспроизведение последовательности (Web Audio + video element) ===
|
|
2353
|
+
// Аудио-движок: AudioContext является источником истины для времени.
|
|
2354
|
+
// Видео-элемент пока используется для рендера видео-клипов; sync с аудио-clock.
|
|
2355
|
+
let _playStop = null;
|
|
2356
|
+
$('timelinePlay').addEventListener('click', async () => {
|
|
2357
|
+
if (_playStop) { _playStop(); _playStop = null; $('timelinePlay').textContent = '▶'; return; }
|
|
2358
|
+
const tl = getTimeline();
|
|
2359
|
+
const videoTrack = tl.tracks.find(t => t.kind === 'video' && t.clips.length);
|
|
2360
|
+
const audioTracks = tl.tracks.filter(t => t.kind === 'audio' && t.clips.length);
|
|
2361
|
+
if (!videoTrack && !audioTracks.length) { alert('Нет клипов в дорожках'); return; }
|
|
2362
|
+
// Развернуть превью при старте воспроизведения
|
|
2363
|
+
setPreviewCollapsed(false);
|
|
2364
|
+
localStorage.setItem('previewOpen', '1');
|
|
2365
|
+
$('timelinePlay').textContent = '⏹';
|
|
2366
|
+
const preview = $('timelinePreview');
|
|
2367
|
+
const previewImg = $('timelinePreviewImg');
|
|
2368
|
+
if (videoTrack) preview.classList.remove('hidden');
|
|
2369
|
+
else { preview.classList.add('hidden'); previewImg.classList.add('hidden'); }
|
|
2370
|
+
|
|
2371
|
+
// Резюмируем AudioContext в click-gesture (autoplay policy)
|
|
2372
|
+
const ctx = getAudioCtx();
|
|
2373
|
+
if (ctx.state === 'suspended') { try { await ctx.resume(); } catch {} }
|
|
2374
|
+
|
|
2375
|
+
const startTime = state.playheadTime || 0;
|
|
2376
|
+
let cancelled = false;
|
|
2377
|
+
const sources = [];
|
|
2378
|
+
const timers = [];
|
|
2379
|
+
|
|
2380
|
+
_playStop = () => {
|
|
2381
|
+
cancelled = true;
|
|
2382
|
+
// Превью со стоп-кадром: только пауза, без сброса src
|
|
2383
|
+
try { preview.pause(); } catch {}
|
|
2384
|
+
for (const s of sources) { try { s.stop(); } catch {} }
|
|
2385
|
+
for (const t of timers) clearTimeout(t);
|
|
2386
|
+
};
|
|
2387
|
+
|
|
2388
|
+
// Параллельно декодируем аудио-буферы (sample-accurate scheduling требует AudioBuffer'ов заранее)
|
|
2389
|
+
const audioPlan = [];
|
|
2390
|
+
for (const track of audioTracks) {
|
|
2391
|
+
for (const clip of track.clips) {
|
|
2392
|
+
if (clip.disabled) continue;
|
|
2393
|
+
const cs = +clip.start || 0;
|
|
2394
|
+
const cd = +clip.duration || 1;
|
|
2395
|
+
if (cs + cd <= startTime) continue;
|
|
2396
|
+
audioPlan.push({ clip, cs, cd });
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
await Promise.all(audioPlan.map(async p => {
|
|
2400
|
+
try { p.buf = await getOrDecodeAudioBuffer(p.clip); }
|
|
2401
|
+
catch (e) { console.warn('audio decode failed:', p.clip.file, e); }
|
|
2402
|
+
}));
|
|
2403
|
+
if (cancelled) return;
|
|
2404
|
+
|
|
2405
|
+
// Пре-резолвим URL для видео внутри gesture
|
|
2406
|
+
if (videoTrack) {
|
|
2407
|
+
for (const c of videoTrack.clips) {
|
|
2408
|
+
try { await getOrCreateClipURL(c); } catch {}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Стартовое время в audio clock (с небольшим запасом чтобы успеть назначить все source'ы)
|
|
2413
|
+
const playStartCtxTime = ctx.currentTime + 0.05;
|
|
2414
|
+
// Удобный конвертер: timeline-секунды → audio clock
|
|
2415
|
+
const tlToCtx = (t) => playStartCtxTime + Math.max(0, t - startTime);
|
|
2416
|
+
|
|
2417
|
+
// Запускаем аудио сэмпл-точно
|
|
2418
|
+
for (const p of audioPlan) {
|
|
2419
|
+
if (!p.buf) continue;
|
|
2420
|
+
const offsetIn = Math.max(0, startTime - p.cs);
|
|
2421
|
+
const remain = Math.max(0, p.cd - offsetIn);
|
|
2422
|
+
if (remain <= 0) continue;
|
|
2423
|
+
const audioOffset = (+p.clip.trimStart || 0) + offsetIn;
|
|
2424
|
+
if (audioOffset >= p.buf.duration) continue;
|
|
2425
|
+
const playableDur = Math.min(remain, p.buf.duration - audioOffset);
|
|
2426
|
+
const src = ctx.createBufferSource();
|
|
2427
|
+
src.buffer = p.buf;
|
|
2428
|
+
src.connect(ctx.destination);
|
|
2429
|
+
try { src.start(tlToCtx(p.cs), audioOffset, playableDur); sources.push(src); }
|
|
2430
|
+
catch (e) { console.warn('audio start failed:', e); }
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
preview.muted = audioTracks.length > 0;
|
|
2434
|
+
|
|
2435
|
+
// Считаем maxDur для остановки в конце таймлайна
|
|
2436
|
+
let maxDur = 0;
|
|
2437
|
+
for (const t of tl.tracks) {
|
|
2438
|
+
for (const c of t.clips) {
|
|
2439
|
+
const end = (+c.start || 0) + (+c.duration || 0);
|
|
2440
|
+
if (end > maxDur) maxDur = end;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// Playhead ticker от audio clock — синхронен с аудио-планом
|
|
2445
|
+
const phRaf = () => {
|
|
2446
|
+
if (cancelled) return;
|
|
2447
|
+
const newT = startTime + Math.max(0, ctx.currentTime - playStartCtxTime);
|
|
2448
|
+
state.playheadTime = newT;
|
|
2449
|
+
positionPlayhead();
|
|
2450
|
+
if (maxDur > 0 && newT >= maxDur) {
|
|
2451
|
+
if (_playStop) _playStop();
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
requestAnimationFrame(phRaf);
|
|
2455
|
+
};
|
|
2456
|
+
requestAnimationFrame(phRaf);
|
|
2457
|
+
|
|
2458
|
+
// Помощник: ждём до достижения timeline-времени targetT (по audio clock)
|
|
2459
|
+
const waitUntilTL = (targetT) => new Promise(res => {
|
|
2460
|
+
const check = () => {
|
|
2461
|
+
if (cancelled) return res();
|
|
2462
|
+
const nowT = startTime + Math.max(0, ctx.currentTime - playStartCtxTime);
|
|
2463
|
+
if (nowT >= targetT) return res();
|
|
2464
|
+
const ms = Math.max(15, (targetT - nowT) * 1000);
|
|
2465
|
+
timers.push(setTimeout(check, Math.min(ms, 60)));
|
|
2466
|
+
};
|
|
2467
|
+
check();
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
// Видео — последовательно по clip.start, тайминг привязан к audio clock
|
|
2471
|
+
if (videoTrack) {
|
|
2472
|
+
const videoClips = [...videoTrack.clips].sort((a, b) => (+a.start || 0) - (+b.start || 0));
|
|
2473
|
+
let lastVideoEnd = 0;
|
|
2474
|
+
for (const c of videoClips) {
|
|
2475
|
+
const e = (+c.start || 0) + (+c.duration || 0);
|
|
2476
|
+
if (e > lastVideoEnd) lastVideoEnd = e;
|
|
2477
|
+
}
|
|
2478
|
+
for (const clip of videoClips) {
|
|
2479
|
+
if (cancelled) break;
|
|
2480
|
+
if (clip.disabled) continue;
|
|
2481
|
+
const cs = +clip.start || 0;
|
|
2482
|
+
const cd = +clip.duration || 1;
|
|
2483
|
+
const ce = cs + cd;
|
|
2484
|
+
if (ce <= startTime) continue;
|
|
2485
|
+
// Дождаться времени начала клипа (если playhead ещё до него)
|
|
2486
|
+
if (cs > startTime) {
|
|
2487
|
+
preview.classList.add('hidden');
|
|
2488
|
+
previewImg.classList.add('hidden');
|
|
2489
|
+
await waitUntilTL(cs);
|
|
2490
|
+
if (cancelled) break;
|
|
2491
|
+
}
|
|
2492
|
+
// Текущая позиция внутри клипа
|
|
2493
|
+
const nowTL = startTime + Math.max(0, ctx.currentTime - playStartCtxTime);
|
|
2494
|
+
const offsetIn = Math.max(0, nowTL - cs);
|
|
2495
|
+
let url;
|
|
2496
|
+
try { url = await getOrCreateClipURL(clip); }
|
|
2497
|
+
catch { continue; }
|
|
2498
|
+
try {
|
|
2499
|
+
if (clip.type === 'image') {
|
|
2500
|
+
preview.classList.add('hidden'); preview.pause();
|
|
2501
|
+
previewImg.src = url;
|
|
2502
|
+
previewImg.classList.remove('hidden');
|
|
2503
|
+
await waitUntilTL(ce);
|
|
2504
|
+
} else {
|
|
2505
|
+
previewImg.classList.add('hidden');
|
|
2506
|
+
preview.classList.remove('hidden');
|
|
2507
|
+
preview.src = url;
|
|
2508
|
+
await new Promise(res => {
|
|
2509
|
+
preview.addEventListener('canplay', res, { once: true });
|
|
2510
|
+
preview.addEventListener('error', res, { once: true });
|
|
2511
|
+
});
|
|
2512
|
+
preview.currentTime = (+clip.trimStart || 0) + offsetIn;
|
|
2513
|
+
try { await preview.play(); } catch {}
|
|
2514
|
+
await waitUntilTL(ce);
|
|
2515
|
+
try { preview.pause(); } catch {}
|
|
2516
|
+
}
|
|
2517
|
+
} catch (e) { console.error('play clip failed', e); }
|
|
2518
|
+
}
|
|
2519
|
+
// После последнего видео-клипа аудио на других дорожках может ещё идти —
|
|
2520
|
+
// дожидаемся maxDur (или ручного стопа), чтобы _playStop оставался активен и пользователь мог остановить.
|
|
2521
|
+
if (!cancelled && maxDur > lastVideoEnd) {
|
|
2522
|
+
preview.classList.add('hidden');
|
|
2523
|
+
previewImg.classList.add('hidden');
|
|
2524
|
+
await waitUntilTL(maxDur);
|
|
2525
|
+
}
|
|
2526
|
+
} else {
|
|
2527
|
+
// Только аудио — ждём до конца таймлайна (rAF-ticker сам остановит на maxDur)
|
|
2528
|
+
await waitUntilTL(maxDur);
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// Натуральный конец проигрывания
|
|
2532
|
+
try { preview.pause(); } catch {}
|
|
2533
|
+
for (const s of sources) { try { s.stop(); } catch {} }
|
|
2534
|
+
for (const t of timers) clearTimeout(t);
|
|
2535
|
+
$('timelinePlay').textContent = '▶';
|
|
2536
|
+
_playStop = null;
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
function waitMs(ms, cancelFn) {
|
|
2540
|
+
return new Promise(res => {
|
|
2541
|
+
const start = Date.now();
|
|
2542
|
+
const t = setInterval(() => {
|
|
2543
|
+
if (cancelFn?.() || Date.now() - start >= ms) { clearInterval(t); res(); }
|
|
2544
|
+
}, 100);
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// === Экспорт в один mp4 через ffmpeg.wasm (мульти-track) ===
|
|
2549
|
+
$('timelineExport').addEventListener('click', async () => {
|
|
2550
|
+
const tl = getTimeline();
|
|
2551
|
+
const videoTrack = tl.tracks.find(t => t.kind === 'video' && t.clips.length);
|
|
2552
|
+
if (!videoTrack) { alert('Нет клипов в видео-дорожке'); return; }
|
|
2553
|
+
const audioTracks = tl.tracks.filter(t => t.kind === 'audio' && t.clips.length);
|
|
2554
|
+
const status = $('timelineStatus');
|
|
2555
|
+
status.classList.remove('error');
|
|
2556
|
+
const setStatus = (m) => { status.textContent = m; };
|
|
2557
|
+
const cleanupNames = [];
|
|
2558
|
+
const W = 1280, H = 720;
|
|
2559
|
+
try {
|
|
2560
|
+
setStatus('Загружаю ffmpeg...');
|
|
2561
|
+
const ff = await ensureFFmpeg(setStatus);
|
|
2562
|
+
|
|
2563
|
+
// 1) Видео-дорожка: каждый клип → silent mp4 segment
|
|
2564
|
+
setStatus('Готовлю видео-клипы...');
|
|
2565
|
+
const videoSegments = [];
|
|
2566
|
+
let videoTotalDur = 0;
|
|
2567
|
+
for (let i = 0; i < videoTrack.clips.length; i++) {
|
|
2568
|
+
const clip = videoTrack.clips[i];
|
|
2569
|
+
setStatus(`Видео ${i+1}/${videoTrack.clips.length}: ${clip.name}`);
|
|
2570
|
+
const file = await (await resolveBoardFile(state.currentBoard.handle, clip.file)).getFile();
|
|
2571
|
+
const inExt = (clip.file.split('.').pop() || 'bin').toLowerCase();
|
|
2572
|
+
const inName = `vin${i}.${inExt}`;
|
|
2573
|
+
const segName = `vseg${i}.mp4`;
|
|
2574
|
+
await ff.writeFile(inName, new Uint8Array(await file.arrayBuffer()));
|
|
2575
|
+
const dur = Math.max(0.1, +clip.duration || 1);
|
|
2576
|
+
videoTotalDur += dur;
|
|
2577
|
+
const durS = String(dur);
|
|
2578
|
+
const trim = Math.max(0, +clip.trimStart || 0);
|
|
2579
|
+
const vfilter = `scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`;
|
|
2580
|
+
if (clip.type === 'image') {
|
|
2581
|
+
await ff.exec(['-loop', '1', '-t', durS, '-i', inName,
|
|
2582
|
+
'-vf', vfilter, '-r', '30', '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
|
|
2583
|
+
'-preset', 'ultrafast', '-t', durS, '-an', segName]);
|
|
2584
|
+
} else {
|
|
2585
|
+
const args = trim > 0 ? ['-ss', String(trim), '-i', inName, '-t', durS]
|
|
2586
|
+
: ['-i', inName, '-t', durS];
|
|
2587
|
+
args.push('-vf', vfilter, '-r', '30', '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
|
|
2588
|
+
'-preset', 'ultrafast', '-an', segName);
|
|
2589
|
+
await ff.exec(args);
|
|
2590
|
+
}
|
|
2591
|
+
videoSegments.push(segName);
|
|
2592
|
+
cleanupNames.push(segName);
|
|
2593
|
+
await ff.deleteFile(inName).catch(() => {});
|
|
2594
|
+
}
|
|
2595
|
+
// Concat видео
|
|
2596
|
+
setStatus('Сшиваю видео-дорожку...');
|
|
2597
|
+
const vList = videoSegments.map(s => `file '${s}'`).join('\n');
|
|
2598
|
+
await ff.writeFile('vlist.txt', new TextEncoder().encode(vList));
|
|
2599
|
+
cleanupNames.push('vlist.txt');
|
|
2600
|
+
await ff.exec(['-f', 'concat', '-safe', '0', '-i', 'vlist.txt', '-c', 'copy', 'video.mp4']);
|
|
2601
|
+
cleanupNames.push('video.mp4');
|
|
2602
|
+
|
|
2603
|
+
// 2) Аудио-дорожки: каждая → wav, обрезанная/удлиненная до videoTotalDur
|
|
2604
|
+
const trackAudioFiles = [];
|
|
2605
|
+
for (let ti = 0; ti < audioTracks.length; ti++) {
|
|
2606
|
+
const tr = audioTracks[ti];
|
|
2607
|
+
setStatus(`Аудио "${tr.name}"...`);
|
|
2608
|
+
// Render each clip → wav segment
|
|
2609
|
+
const audSegs = [];
|
|
2610
|
+
for (let i = 0; i < tr.clips.length; i++) {
|
|
2611
|
+
const clip = tr.clips[i];
|
|
2612
|
+
const file = await (await resolveBoardFile(state.currentBoard.handle, clip.file)).getFile();
|
|
2613
|
+
const inExt = (clip.file.split('.').pop() || 'bin').toLowerCase();
|
|
2614
|
+
const inName = `at${ti}_${i}.${inExt}`;
|
|
2615
|
+
const segName = `at${ti}_${i}.wav`;
|
|
2616
|
+
await ff.writeFile(inName, new Uint8Array(await file.arrayBuffer()));
|
|
2617
|
+
const dur = Math.max(0.1, +clip.duration || 1);
|
|
2618
|
+
const durS = String(dur);
|
|
2619
|
+
const trim = Math.max(0, +clip.trimStart || 0);
|
|
2620
|
+
const aArgs = trim > 0 ? ['-ss', String(trim), '-i', inName, '-t', durS]
|
|
2621
|
+
: ['-i', inName, '-t', durS];
|
|
2622
|
+
aArgs.push('-ar', '44100', '-ac', '2', '-c:a', 'pcm_s16le', segName);
|
|
2623
|
+
await ff.exec(aArgs);
|
|
2624
|
+
audSegs.push(segName);
|
|
2625
|
+
cleanupNames.push(segName);
|
|
2626
|
+
await ff.deleteFile(inName).catch(() => {});
|
|
2627
|
+
}
|
|
2628
|
+
// Concat all clips into single track WAV
|
|
2629
|
+
const aList = audSegs.map(s => `file '${s}'`).join('\n');
|
|
2630
|
+
const listName = `alist_${ti}.txt`;
|
|
2631
|
+
await ff.writeFile(listName, new TextEncoder().encode(aList));
|
|
2632
|
+
cleanupNames.push(listName);
|
|
2633
|
+
const concatName = `atrack_${ti}_raw.wav`;
|
|
2634
|
+
await ff.exec(['-f', 'concat', '-safe', '0', '-i', listName, '-c', 'copy', concatName]);
|
|
2635
|
+
cleanupNames.push(concatName);
|
|
2636
|
+
// Pad/cut to videoTotalDur
|
|
2637
|
+
const finalName = `atrack_${ti}.wav`;
|
|
2638
|
+
await ff.exec(['-i', concatName,
|
|
2639
|
+
'-af', `apad=whole_dur=${videoTotalDur},atrim=0:${videoTotalDur}`,
|
|
2640
|
+
'-ar', '44100', '-ac', '2', '-c:a', 'pcm_s16le', finalName]);
|
|
2641
|
+
cleanupNames.push(finalName);
|
|
2642
|
+
trackAudioFiles.push(finalName);
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// 3) Финальная сборка: video + mix всех audio
|
|
2646
|
+
setStatus('Финальная сборка...');
|
|
2647
|
+
const finalName = 'output.mp4';
|
|
2648
|
+
if (trackAudioFiles.length === 0) {
|
|
2649
|
+
await ff.exec(['-i', 'video.mp4', '-c', 'copy', finalName]);
|
|
2650
|
+
} else if (trackAudioFiles.length === 1) {
|
|
2651
|
+
await ff.exec(['-i', 'video.mp4', '-i', trackAudioFiles[0],
|
|
2652
|
+
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
|
|
2653
|
+
'-map', '0:v', '-map', '1:a', '-shortest', finalName]);
|
|
2654
|
+
} else {
|
|
2655
|
+
const args = ['-i', 'video.mp4'];
|
|
2656
|
+
for (const a of trackAudioFiles) args.push('-i', a);
|
|
2657
|
+
const audioInputs = trackAudioFiles.map((_, i) => `[${i + 1}:a]`).join('');
|
|
2658
|
+
const filter = `${audioInputs}amix=inputs=${trackAudioFiles.length}:duration=longest:dropout_transition=0[aout]`;
|
|
2659
|
+
args.push('-filter_complex', filter,
|
|
2660
|
+
'-map', '0:v', '-map', '[aout]',
|
|
2661
|
+
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
|
|
2662
|
+
'-shortest', finalName);
|
|
2663
|
+
await ff.exec(args);
|
|
2664
|
+
}
|
|
2665
|
+
cleanupNames.push(finalName);
|
|
2666
|
+
|
|
2667
|
+
const data = await ff.readFile(finalName);
|
|
2668
|
+
const blob = new Blob([data.buffer], { type: 'video/mp4' });
|
|
2669
|
+
triggerDownload(blob, `${state.currentBoard.name}_timeline.mp4`);
|
|
2670
|
+
setStatus('Готово ✓');
|
|
2671
|
+
} catch (e) {
|
|
2672
|
+
console.error('timeline export failed:', e);
|
|
2673
|
+
status.classList.add('error');
|
|
2674
|
+
setStatus('Ошибка: ' + (e?.message || e));
|
|
2675
|
+
} finally {
|
|
2676
|
+
// Cleanup
|
|
2677
|
+
if (ffmpegInstance) {
|
|
2678
|
+
for (const n of cleanupNames) await ffmpegInstance.deleteFile(n).catch(() => {});
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
});
|
|
2682
|
+
|
|
2683
|
+
// =================== Native preview (опционально) ===================
|
|
2684
|
+
// Доступно только в Electron, где preload пробросил window.nativePlayer.
|
|
2685
|
+
// При включении: спавним python+mpv, шлём ему текущую видео-дорожку
|
|
2686
|
+
// и зеркалим play/pause/seek. Веб-превью на это время прячем и мьютим.
|
|
2687
|
+
// Аудио продолжает играть существующий Web Audio движок — он мастер по времени.
|
|
2688
|
+
(() => {
|
|
2689
|
+
if (!window.nativePlayer) return;
|
|
2690
|
+
const btn = document.getElementById('timelineNative');
|
|
2691
|
+
if (!btn) return;
|
|
2692
|
+
btn.style.display = '';
|
|
2693
|
+
|
|
2694
|
+
let ws = null;
|
|
2695
|
+
let enabled = false;
|
|
2696
|
+
let lastClipsSig = '';
|
|
2697
|
+
let lastSentTime = -1;
|
|
2698
|
+
let lastSentPlaying = false;
|
|
2699
|
+
let pollT = null;
|
|
2700
|
+
let mo = null;
|
|
2701
|
+
|
|
2702
|
+
function setBtnState() {
|
|
2703
|
+
btn.style.background = enabled ? '#3a5a8a' : '';
|
|
2704
|
+
btn.style.borderColor = enabled ? '#4a6a9a' : '';
|
|
2705
|
+
btn.title = enabled ? 'Нативный плеер: вкл (клик — выкл)' : 'Нативный плеер (mpv): выкл';
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
async function buildClips() {
|
|
2709
|
+
const tl = (typeof getTimeline === 'function') ? getTimeline() : null;
|
|
2710
|
+
if (!tl || !state.currentBoard?.handle) return [];
|
|
2711
|
+
const videoTrack = tl.tracks.find(t => t.kind === 'video' && t.clips.length);
|
|
2712
|
+
if (!videoTrack) return [];
|
|
2713
|
+
const out = [];
|
|
2714
|
+
for (const clip of videoTrack.clips) {
|
|
2715
|
+
if (clip.disabled) continue;
|
|
2716
|
+
try {
|
|
2717
|
+
const fh = await resolveBoardFile(state.currentBoard.handle, clip.file);
|
|
2718
|
+
const file = await fh.getFile();
|
|
2719
|
+
const path = window.nativePlayer.pathForFile(file);
|
|
2720
|
+
if (!path) continue;
|
|
2721
|
+
out.push({
|
|
2722
|
+
path,
|
|
2723
|
+
kind: clip.type === 'image' ? 'image' : 'video',
|
|
2724
|
+
trimStart: +clip.trimStart || 0,
|
|
2725
|
+
duration: +clip.duration || 1,
|
|
2726
|
+
});
|
|
2727
|
+
} catch (e) { console.warn('[native] resolve clip', clip.file, e); }
|
|
2728
|
+
}
|
|
2729
|
+
return out;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
async function syncTimeline(force) {
|
|
2733
|
+
if (!enabled || !ws || ws.readyState !== 1) return;
|
|
2734
|
+
const clips = await buildClips();
|
|
2735
|
+
const sig = JSON.stringify(clips);
|
|
2736
|
+
if (!force && sig === lastClipsSig) return;
|
|
2737
|
+
lastClipsSig = sig;
|
|
2738
|
+
ws.send(JSON.stringify({ type: 'setTimeline', clips }));
|
|
2739
|
+
ws.send(JSON.stringify({ type: 'seek', time: state.playheadTime || 0 }));
|
|
2740
|
+
lastSentTime = state.playheadTime || 0;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function muteWebPreview() {
|
|
2744
|
+
// Нативный плеер активен → веб-превью не должно ни играть, ни звучать.
|
|
2745
|
+
const v = document.getElementById('timelinePreview');
|
|
2746
|
+
if (v) {
|
|
2747
|
+
v.classList.add('hidden');
|
|
2748
|
+
v.muted = true;
|
|
2749
|
+
try { v.pause(); } catch {}
|
|
2750
|
+
}
|
|
2751
|
+
document.getElementById('timelinePreviewImg')?.classList.add('hidden');
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
function startPoll() {
|
|
2755
|
+
if (pollT) return;
|
|
2756
|
+
pollT = setInterval(() => {
|
|
2757
|
+
if (!enabled || !ws || ws.readyState !== 1) return;
|
|
2758
|
+
muteWebPreview();
|
|
2759
|
+
const playing = !!(typeof _playStop !== 'undefined' && _playStop);
|
|
2760
|
+
const t = state.playheadTime || 0;
|
|
2761
|
+
if (playing !== lastSentPlaying) {
|
|
2762
|
+
ws.send(JSON.stringify({ type: playing ? 'play' : 'pause', time: t }));
|
|
2763
|
+
lastSentPlaying = playing;
|
|
2764
|
+
lastSentTime = t;
|
|
2765
|
+
} else if (Math.abs(t - lastSentTime) > 0.05) {
|
|
2766
|
+
ws.send(JSON.stringify({ type: playing ? 'sync' : 'seek', time: t }));
|
|
2767
|
+
lastSentTime = t;
|
|
2768
|
+
}
|
|
2769
|
+
}, 100);
|
|
2770
|
+
}
|
|
2771
|
+
function stopPoll() { if (pollT) { clearInterval(pollT); pollT = null; } }
|
|
2772
|
+
|
|
2773
|
+
async function enable() {
|
|
2774
|
+
if (enabled) return;
|
|
2775
|
+
btn.disabled = true;
|
|
2776
|
+
try {
|
|
2777
|
+
const { url } = await window.nativePlayer.start();
|
|
2778
|
+
ws = new WebSocket(url);
|
|
2779
|
+
await new Promise((res, rej) => {
|
|
2780
|
+
const ok = () => { cleanup(); res(); };
|
|
2781
|
+
const bad = () => { cleanup(); rej(new Error('WebSocket failed')); };
|
|
2782
|
+
const cleanup = () => {
|
|
2783
|
+
ws.removeEventListener('open', ok);
|
|
2784
|
+
ws.removeEventListener('error', bad);
|
|
2785
|
+
};
|
|
2786
|
+
ws.addEventListener('open', ok);
|
|
2787
|
+
ws.addEventListener('error', bad);
|
|
2788
|
+
});
|
|
2789
|
+
ws.addEventListener('message', e => {
|
|
2790
|
+
try {
|
|
2791
|
+
const m = JSON.parse(e.data);
|
|
2792
|
+
if (m.type === 'error') console.error('[native]', m.message);
|
|
2793
|
+
} catch {}
|
|
2794
|
+
});
|
|
2795
|
+
ws.addEventListener('close', () => { if (enabled) disable(true); });
|
|
2796
|
+
enabled = true;
|
|
2797
|
+
lastClipsSig = ''; lastSentTime = -1; lastSentPlaying = false;
|
|
2798
|
+
muteWebPreview();
|
|
2799
|
+
await syncTimeline(true);
|
|
2800
|
+
startPoll();
|
|
2801
|
+
// Пересинк на изменения DOM трэков (клипы добавили/двигнули/обрезали).
|
|
2802
|
+
const tracksEl = document.getElementById('timelineTracks');
|
|
2803
|
+
if (tracksEl) {
|
|
2804
|
+
mo = new MutationObserver(() => {
|
|
2805
|
+
syncTimeline().catch(err => console.warn('[native]', err));
|
|
2806
|
+
});
|
|
2807
|
+
mo.observe(tracksEl, { childList: true, subtree: true, attributes: true });
|
|
2808
|
+
}
|
|
2809
|
+
} catch (e) {
|
|
2810
|
+
alert('Нативный плеер не запустился:\n' + (e?.message || e));
|
|
2811
|
+
if (ws) { try { ws.close(); } catch {} }
|
|
2812
|
+
ws = null; enabled = false;
|
|
2813
|
+
} finally {
|
|
2814
|
+
btn.disabled = false;
|
|
2815
|
+
setBtnState();
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function disable(silent) {
|
|
2820
|
+
enabled = false;
|
|
2821
|
+
stopPoll();
|
|
2822
|
+
if (mo) { mo.disconnect(); mo = null; }
|
|
2823
|
+
if (ws) { try { ws.close(); } catch {} ws = null; }
|
|
2824
|
+
window.nativePlayer.stop().catch(() => {});
|
|
2825
|
+
setBtnState();
|
|
2826
|
+
if (!silent && typeof scheduleUpdatePreview === 'function') {
|
|
2827
|
+
// Вернуть веб-превью к жизни.
|
|
2828
|
+
const v = document.getElementById('timelinePreview');
|
|
2829
|
+
if (v) v.muted = false;
|
|
2830
|
+
scheduleUpdatePreview();
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
btn.addEventListener('click', () => enabled ? disable() : enable());
|
|
2835
|
+
setBtnState();
|
|
2836
|
+
})();
|