kingkont 0.7.39 → 0.7.41

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.
@@ -0,0 +1,1413 @@
1
+ // renderer/media.js — Zoom, ffmpeg.wasm audio playback, thumbnails, history versioning, drag&drop, text+@-mentions, character/location settings
2
+ //
3
+ // Этот модуль был выделен из index.html (раньше всё было в одном <script>
4
+ // блоке на 9123 строки). Все модули загружаются как plain <script>
5
+ // в одном глобальном scope — поэтому функции/переменные между файлами
6
+ // видят друг друга по именам, без import/export. Порядок загрузки
7
+ // важен: см. <script> теги внизу index.html.
8
+
9
+ // =================== Zoom ===================
10
+ // Прямые стили вместо CSS-variable: var(--zoom) каскадился через .canvas-frame и .canvas,
11
+ // заставляя Chrome делать full layout (partialLayout=false) на каждый wheel-tick.
12
+ const _canvasFrame = $('canvasFrame');
13
+ // Frame-size меняется ОТЛОЖЕННО (через debounce 200ms): width на canvasFrame —
14
+ // layout-affecting свойство, его смена на каждом wheel-tick форсила 751ms Layout.
15
+ // Транформ на canvas идёт через GPU (will-change: transform → composited),
16
+ // scroll-bounds во время burst могут быть слегка off, на idle подстраиваются.
17
+ let _frameSizeTimer = null;
18
+ function applyZoomStyles(z) {
19
+ canvas.style.transform = `scale(${z})`;
20
+ clearTimeout(_frameSizeTimer);
21
+ _frameSizeTimer = setTimeout(() => {
22
+ _frameSizeTimer = null;
23
+ _canvasFrame.style.width = (6000 * z) + 'px';
24
+ _canvasFrame.style.height = (4000 * z) + 'px';
25
+ }, 200);
26
+ }
27
+ function applyZoom(nextZoom, anchorClientX, anchorClientY) {
28
+ nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom));
29
+ if (nextZoom === state.zoom) return;
30
+ const rect = canvasWrap.getBoundingClientRect();
31
+ // Если якорь не задан — берём центр видимой области
32
+ const ax = anchorClientX ?? (rect.left + rect.width / 2);
33
+ const ay = anchorClientY ?? (rect.top + rect.height / 2);
34
+ const visualX = canvasWrap.scrollLeft + (ax - rect.left);
35
+ const visualY = canvasWrap.scrollTop + (ay - rect.top);
36
+ const contentX = visualX / state.zoom;
37
+ const contentY = visualY / state.zoom;
38
+ state.zoom = nextZoom;
39
+ applyZoomStyles(nextZoom);
40
+ canvasWrap.scrollLeft = contentX * nextZoom - (ax - rect.left);
41
+ canvasWrap.scrollTop = contentY * nextZoom - (ay - rect.top);
42
+ $('zoomLabel').textContent = Math.round(nextZoom * 100) + '%';
43
+ scheduleViewSave();
44
+ }
45
+
46
+ // rAF-троттлинг zoom-через-колесо: трекпад/Magic Mouse шлют wheel @ 60-120Hz,
47
+ // мы коалесцируем дельты в один applyZoom за кадр — иначе GPU-коммит не успевает.
48
+ let _pendingZoom = null;
49
+ canvasWrap.addEventListener('wheel', e => {
50
+ // Ctrl/Cmd + wheel ИЛИ тачпад-pinch (Chrome выставляет ctrlKey=true для pinch)
51
+ if (!(e.ctrlKey || e.metaKey)) return;
52
+ e.preventDefault();
53
+ if (_pendingZoom) {
54
+ _pendingZoom.factor *= Math.exp(-e.deltaY * 0.01);
55
+ _pendingZoom.x = e.clientX; _pendingZoom.y = e.clientY;
56
+ return;
57
+ }
58
+ _pendingZoom = { factor: Math.exp(-e.deltaY * 0.01), x: e.clientX, y: e.clientY };
59
+ requestAnimationFrame(() => {
60
+ const z = _pendingZoom; _pendingZoom = null;
61
+ applyZoom(state.zoom * z.factor, z.x, z.y);
62
+ });
63
+ }, { passive: false });
64
+
65
+ $('zoomIn').addEventListener('click', () => applyZoom(state.zoom * 1.25));
66
+ $('zoomOut').addEventListener('click', () => applyZoom(state.zoom / 1.25));
67
+ $('zoomLabel').addEventListener('click', () => applyZoom(1));
68
+
69
+ // Сохранение pan/zoom для текущей доски
70
+ let viewSaveTimer = null;
71
+ function scheduleViewSave() {
72
+ if (!state.currentBoard) return;
73
+ clearTimeout(viewSaveTimer);
74
+ viewSaveTimer = setTimeout(() => {
75
+ if (!state.currentBoard) return;
76
+ state.currentBoard.metadata.view = {
77
+ scrollLeft: canvasWrap.scrollLeft,
78
+ scrollTop: canvasWrap.scrollTop,
79
+ zoom: state.zoom,
80
+ };
81
+ scheduleSave();
82
+ }, 400);
83
+ }
84
+ canvasWrap.addEventListener('scroll', scheduleViewSave);
85
+
86
+ // =================== Скачивание аудио на выбранной скорости (ffmpeg.wasm atempo) ===================
87
+ let ffmpegInstance = null;
88
+ let ffmpegLoadingPromise = null;
89
+
90
+ function loadScript(url) {
91
+ return new Promise((resolve, reject) => {
92
+ const s = document.createElement('script');
93
+ s.src = url; s.onload = resolve; s.onerror = () => reject(new Error('Не удалось загрузить ' + url));
94
+ document.head.appendChild(s);
95
+ });
96
+ }
97
+
98
+ async function ensureFFmpeg(onProgress) {
99
+ if (ffmpegInstance) return ffmpegInstance;
100
+ if (ffmpegLoadingPromise) return ffmpegLoadingPromise;
101
+ ffmpegLoadingPromise = (async () => {
102
+ onProgress?.('Загружаю ffmpeg...');
103
+ if (!window.FFmpegWASM) {
104
+ await loadScript('/vendor/ffmpeg/ffmpeg.js');
105
+ }
106
+ const ff = new window.FFmpegWASM.FFmpeg();
107
+ await ff.load({
108
+ coreURL: '/vendor/ffmpeg/ffmpeg-core.js',
109
+ wasmURL: '/vendor/ffmpeg/ffmpeg-core.wasm',
110
+ });
111
+ ffmpegInstance = ff;
112
+ return ff;
113
+ })();
114
+ return ffmpegLoadingPromise;
115
+ }
116
+
117
+ // atempo поддерживает 0.5..100; для значений вне диапазона цепляем
118
+ function buildAtempoChain(speed) {
119
+ let s = speed;
120
+ const filters = [];
121
+ while (s < 0.5) { filters.push('atempo=0.5'); s /= 0.5; }
122
+ while (s > 2) { filters.push('atempo=2.0'); s /= 2; }
123
+ filters.push(`atempo=${s.toFixed(4)}`);
124
+ return filters.join(',');
125
+ }
126
+
127
+ async function downloadAudioAtSpeed(file, filename, speed, onProgress) {
128
+ if (Math.abs(speed - 1) < 1e-6) {
129
+ triggerDownload(file, filename);
130
+ return;
131
+ }
132
+ const ff = await ensureFFmpeg(onProgress);
133
+ onProgress?.('Перекодирую...');
134
+ const inExt = (filename.split('.').pop() || 'mp3').toLowerCase();
135
+ const inName = 'in.' + inExt;
136
+ const outName = 'out.mp3';
137
+ await ff.writeFile(inName, new Uint8Array(await file.arrayBuffer()));
138
+ await ff.exec([
139
+ '-i', inName,
140
+ '-filter:a', buildAtempoChain(speed),
141
+ '-vn',
142
+ '-c:a', 'libmp3lame',
143
+ '-q:a', '2',
144
+ outName,
145
+ ]);
146
+ const data = await ff.readFile(outName);
147
+ const blob = new Blob([data.buffer], { type: 'audio/mpeg' });
148
+ const base = filename.replace(/\.[^.]+$/, '');
149
+ const speedStr = String(speed).replace('.', '_');
150
+ triggerDownload(blob, `${base}_${speedStr}x.mp3`);
151
+ await ff.deleteFile(inName).catch(() => {});
152
+ await ff.deleteFile(outName).catch(() => {});
153
+ }
154
+
155
+ function triggerDownload(blob, filename) {
156
+ const url = URL.createObjectURL(blob);
157
+ const a = document.createElement('a');
158
+ a.href = url; a.download = filename;
159
+ document.body.appendChild(a);
160
+ a.click();
161
+ a.remove();
162
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
163
+ }
164
+
165
+ function findFreeSpot(w = 280, h = 320) {
166
+ const nodes = state.currentBoard?.metadata?.nodes || [];
167
+ const margin = 24;
168
+ const startX = canvasWrap.scrollLeft / state.zoom + 40;
169
+ const startY = canvasWrap.scrollTop / state.zoom + 40;
170
+ const step = 60;
171
+ for (let y = startY; y < startY + 3000; y += step) {
172
+ for (let x = startX; x < startX + 3000; x += step) {
173
+ const overlaps = nodes.some(n => {
174
+ const nw = 320, nh = 360; // приближённая граница для произвольной ноды
175
+ return x < n.x + nw + margin && x + w + margin > n.x &&
176
+ y < n.y + nh + margin && y + h + margin > n.y;
177
+ });
178
+ if (!overlaps) return { x, y };
179
+ }
180
+ }
181
+ return { x: startX, y: startY };
182
+ }
183
+
184
+ // =================== Thumbnails ===================
185
+ // Маленькая jpg-thumbnail для картинки/видео ноды. Хранится в <scene>/thumbs/.
186
+ // Имя совпадает с исходным (frames/foo.png → thumbs/foo.png.jpg).
187
+ // При selectBoard сразу показываем thumb как placeholder (мгновенно), full media
188
+ // догружается lazy. На повторном открытии проекта эффект сильнее всего —
189
+ // 50 нод × 5KB jpg декодятся миллисекунды против 50 × 2MB исходных.
190
+
191
+ const THUMB_W = 320, THUMB_H = 240, THUMB_QUALITY = 0.7;
192
+
193
+ // state: пути thumbnails, разрезолвенных в blob URL
194
+ const _thumbUrls = new Map(); // `${boardKey}::${relPath}` -> objectURL
195
+
196
+ function _thumbName(relPath) {
197
+ // frames/foo.png → foo.png.jpg
198
+ return relPath.split('/').pop() + '.jpg';
199
+ }
200
+
201
+ async function loadThumbnailURL(boardHandle, boardKey, relPath) {
202
+ const cacheKey = `${boardKey}::${relPath}`;
203
+ const cached = _thumbUrls.get(cacheKey);
204
+ if (cached) return cached;
205
+ try {
206
+ const dir = await boardHandle.getDirectoryHandle('thumbs');
207
+ const fh = await dir.getFileHandle(_thumbName(relPath));
208
+ const url = URL.createObjectURL(await fh.getFile());
209
+ _thumbUrls.set(cacheKey, url);
210
+ return url;
211
+ } catch { return null; }
212
+ }
213
+
214
+ // Генерирует и сохраняет thumbnail (если ещё нет). Вызывается ПОСЛЕ того как
215
+ // full-media уже отображается — фоновая работа, не блокирует UI.
216
+ async function generateThumbnailIfMissing(boardHandle, boardKey, node, mediaEl) {
217
+ if (!node.file || (node.type !== 'image' && node.type !== 'video')) return;
218
+ // Уже есть на диске?
219
+ try {
220
+ const dir = await boardHandle.getDirectoryHandle('thumbs');
221
+ await dir.getFileHandle(_thumbName(node.file));
222
+ return; // уже есть
223
+ } catch {}
224
+ try {
225
+ const c = document.createElement('canvas');
226
+ c.width = THUMB_W; c.height = THUMB_H;
227
+ const ctx = c.getContext('2d');
228
+ ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, THUMB_W, THUMB_H);
229
+ let srcW, srcH, drawable;
230
+ if (node.type === 'image') {
231
+ if (!mediaEl.complete || !mediaEl.naturalWidth) {
232
+ await new Promise((r, rej) => { mediaEl.addEventListener('load', r, { once: true }); mediaEl.addEventListener('error', rej, { once: true }); });
233
+ }
234
+ drawable = mediaEl; srcW = mediaEl.naturalWidth; srcH = mediaEl.naturalHeight;
235
+ } else {
236
+ // video: ждём seek на ~0.3s для выбора симпатичного кадра
237
+ if (mediaEl.readyState < 2) {
238
+ mediaEl.preload = 'auto';
239
+ await new Promise((r, rej) => { mediaEl.addEventListener('loadeddata', r, { once: true }); mediaEl.addEventListener('error', rej, { once: true }); setTimeout(rej, 5000); });
240
+ }
241
+ try { mediaEl.currentTime = Math.min(0.3, (mediaEl.duration || 1) * 0.1); } catch {}
242
+ await new Promise(r => mediaEl.addEventListener('seeked', r, { once: true }));
243
+ drawable = mediaEl; srcW = mediaEl.videoWidth; srcH = mediaEl.videoHeight;
244
+ }
245
+ if (!srcW || !srcH) return;
246
+ // Letterbox fit
247
+ const scale = Math.min(THUMB_W / srcW, THUMB_H / srcH);
248
+ const dw = srcW * scale, dh = srcH * scale;
249
+ ctx.drawImage(drawable, (THUMB_W - dw) / 2, (THUMB_H - dh) / 2, dw, dh);
250
+ const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', THUMB_QUALITY));
251
+ if (!blob) return;
252
+ const dir = await getOrCreateBoardSubdir(boardHandle, 'thumbs');
253
+ await writeFile(dir, _thumbName(node.file), blob);
254
+ } catch (e) { /* silent — это best-effort */ }
255
+ }
256
+
257
+ // Lazy media hydration: когда .node заходит в viewport (+ rootMargin запас на
258
+ // плавный preload), вызывает свой __hydrate() — обычно ставит src на <img>/<video>/<audio>.
259
+ // Без этого все 50+ медиа-файлов проекта читались с диска синхронно при selectBoard.
260
+ //
261
+ // Сериализация через rAF-очередь: если IntersectionObserver сообщит о 12 нодах
262
+ // одновременно (например, при selectBoard все видимые сразу), мы не запускаем 12
263
+ // параллельных hydrate'ов — браузер бы выпустил 12× loadedmetadata/canplay events
264
+ // в один тик, и PrePaint walks paint-tree для всех сразу (видели 454ms в трейсе).
265
+ // Гидрируем по 2 за rAF-кадр — нагрузка размазывается, паузы заметно меньше.
266
+ const _hydrationQueue = [];
267
+ let _hydrationRunning = false;
268
+ function _drainHydration() {
269
+ _hydrationRunning = true;
270
+ // 2 hydrate-задачи за кадр — баланс между скоростью и плавностью.
271
+ for (let i = 0; i < 2 && _hydrationQueue.length; i++) {
272
+ const fn = _hydrationQueue.shift();
273
+ try { fn(); } catch (e) { console.warn("hydrate fail", e); }
274
+ }
275
+ if (_hydrationQueue.length) requestAnimationFrame(_drainHydration);
276
+ else _hydrationRunning = false;
277
+ }
278
+ function _enqueueHydration(fn) {
279
+ _hydrationQueue.push(fn);
280
+ if (!_hydrationRunning) requestAnimationFrame(_drainHydration);
281
+ }
282
+ const _mediaHydrationObserver = new IntersectionObserver((entries) => {
283
+ for (const e of entries) {
284
+ if (!e.isIntersecting) continue;
285
+ _mediaHydrationObserver.unobserve(e.target);
286
+ const target = e.target;
287
+ _enqueueHydration(() => target.__hydrate?.());
288
+ }
289
+ }, { rootMargin: '400px' });
290
+
291
+ let saveTimer = null;
292
+ function scheduleSave() {
293
+ if (!state.currentBoard) return;
294
+ clearTimeout(saveTimer);
295
+ saveTimer = setTimeout(async () => {
296
+ saveTimer = null;
297
+ try { await saveBoardMetadata(state.currentBoard.handle, state.currentBoard.metadata); }
298
+ catch (e) { console.error('save failed', e); }
299
+ }, 300);
300
+ }
301
+
302
+ async function refreshNodeDOM(nodeId) {
303
+ if (!state.currentBoard) return;
304
+ const node = state.currentBoard.metadata.nodes.find(n => n.id === nodeId);
305
+ if (!node) return;
306
+ const el = canvas.querySelector(`.node[data-id="${nodeId}"]`);
307
+ if (!el) return;
308
+ const body = el.querySelector('.node-body');
309
+ await renderNodeBody(node, body);
310
+ const footer = el.querySelector('.node-footer');
311
+ if (footer) updateNodeFooter(node, footer);
312
+ }
313
+
314
+ // Если на таймлайне есть клипы, ссылающиеся на ноду — перерисовать таймлайн
315
+ function refreshTimelineForNode(nodeId) {
316
+ const tl = state.currentBoard?.metadata?.timeline;
317
+ if (!tl?.tracks) return;
318
+ if ($('timelinePanel').classList.contains('hidden')) return;
319
+ const has = tl.tracks.some(t => t.clips?.some(c => c.nodeId === nodeId));
320
+ if (has) renderTimeline();
321
+ }
322
+
323
+ // Синхронизировать клипы таймлайна с актуальным файлом ноды (после регена)
324
+ function syncTimelineClipsForNode(node) {
325
+ const tl = state.currentBoard?.metadata?.timeline;
326
+ if (!tl?.tracks || !node?.file) return false;
327
+ let changed = false;
328
+ for (const t of tl.tracks) {
329
+ for (const c of t.clips || []) {
330
+ if (c.nodeId !== node.id) continue;
331
+ c.file = node.file;
332
+ c.type = node.type;
333
+ c.name = node.name || node.file.split('/').pop();
334
+ c.trimStart = 0;
335
+ c.sourceDuration = undefined;
336
+ c._durationLoaded = false;
337
+ c.duration = undefined; // подхватится из metadata нового файла
338
+ changed = true;
339
+ }
340
+ }
341
+ return changed;
342
+ }
343
+
344
+ // =================== История версий + перегенерация ===================
345
+ function syncHistorySlot(node) {
346
+ if (!Array.isArray(node.history) || !node.history.length) return;
347
+ const idx = Math.max(0, Math.min(node.history.length - 1, node.historyIndex ?? node.history.length - 1));
348
+ node.history[idx] = {
349
+ file: node.file, generated: node.generated,
350
+ status: node.status, error: node.error,
351
+ };
352
+ }
353
+
354
+ function updateNodeFooter(node, footer) {
355
+ footer.innerHTML = '';
356
+ const total = node.history?.length || 0;
357
+ const idx = total ? (node.historyIndex ?? total - 1) : 0;
358
+
359
+ const prev = document.createElement('button');
360
+ prev.textContent = '←'; prev.title = 'Предыдущая версия';
361
+ prev.disabled = total <= 1 || idx <= 0;
362
+ prev.addEventListener('click', e => { e.stopPropagation(); navigateHistory(node, -1); });
363
+ prev.addEventListener('mousedown', e => e.stopPropagation());
364
+
365
+ const regen = document.createElement('button');
366
+ regen.className = 'regen-btn'; regen.textContent = '↻'; regen.title = 'Перегенерировать';
367
+ regen.disabled = node.status === 'generating';
368
+ regen.addEventListener('click', e => { e.stopPropagation(); regenerateNode(node); });
369
+ regen.addEventListener('mousedown', e => e.stopPropagation());
370
+
371
+ const next = document.createElement('button');
372
+ next.textContent = '→'; next.title = 'Следующая версия';
373
+ next.disabled = !total || idx >= total - 1;
374
+ next.addEventListener('click', e => { e.stopPropagation(); navigateHistory(node, +1); });
375
+ next.addEventListener('mousedown', e => e.stopPropagation());
376
+
377
+ footer.appendChild(prev);
378
+ footer.appendChild(regen);
379
+ footer.appendChild(next);
380
+ if (total > 1) {
381
+ const pos = document.createElement('span');
382
+ pos.className = 'pos';
383
+ pos.textContent = `${idx + 1} / ${total}`;
384
+ footer.appendChild(pos);
385
+ }
386
+ }
387
+
388
+ async function navigateHistory(node, delta) {
389
+ if (!Array.isArray(node.history) || node.history.length < 2) return;
390
+ const idx = Math.max(0, Math.min(node.history.length - 1, (node.historyIndex ?? node.history.length - 1) + delta));
391
+ if (idx === node.historyIndex) return;
392
+ node.historyIndex = idx;
393
+ const v = node.history[idx];
394
+ node.file = v.file;
395
+ node.generated = v.generated;
396
+ node.status = v.status;
397
+ node.error = v.error;
398
+ await refreshNodeDOM(node.id);
399
+ scheduleSave();
400
+ }
401
+
402
+ async function stopJob(nodeId) {
403
+ logJob(nodeId, `stopped manually`);
404
+ const job = state.jobs.get(nodeId);
405
+ if (job) {
406
+ state.jobs.delete(nodeId);
407
+ updateJobsBadge();
408
+ }
409
+ const bKey = job?.boardKey || state.currentBoard?.key;
410
+ const bHandle = job?.boardHandle || state.currentBoard?.handle;
411
+ if (!bKey || !bHandle) return;
412
+ await mutateNode(bKey, bHandle, nodeId, n => {
413
+ n.status = 'error';
414
+ n.error = 'Остановлено вручную';
415
+ });
416
+ }
417
+
418
+ async function restartJob(nodeId) {
419
+ logJob(nodeId, `restart requested`);
420
+ // Сначала отменяем текущий поллинг
421
+ const job = state.jobs.get(nodeId);
422
+ const bKey = job?.boardKey || state.currentBoard?.key;
423
+ const bHandle = job?.boardHandle || state.currentBoard?.handle;
424
+ if (job) {
425
+ state.jobs.delete(nodeId);
426
+ updateJobsBadge();
427
+ }
428
+ if (!bKey || !bHandle) return;
429
+ // Найти ноду
430
+ let node;
431
+ if (state.currentBoard?.key === bKey) {
432
+ node = state.currentBoard.metadata.nodes.find(n => n.id === nodeId);
433
+ }
434
+ if (!node || !node.generated) return;
435
+ // Зафиксировать размеры ноды, чтобы spinner не схлопнул её
436
+ const elNode = canvas.querySelector(`.node[data-id="${nodeId}"]`);
437
+ if (elNode) {
438
+ if (!node.width) node.width = elNode.offsetWidth;
439
+ if (!node.height) node.height = elNode.offsetHeight;
440
+ elNode.style.width = node.width + 'px';
441
+ elNode.style.height = node.height + 'px';
442
+ }
443
+ // Сбрасываем taskId (чтобы прошёл заново через upload+submit) и стейт
444
+ node.generated = { ...node.generated, taskId: undefined, state: 'submitting' };
445
+ node.status = 'generating';
446
+ node.error = undefined;
447
+ syncHistorySlot(node);
448
+ scheduleSave();
449
+ await refreshNodeDOM(node.id);
450
+ // Запуск
451
+ const kind = node.generated.kind;
452
+ if (kind === 'audio') {
453
+ runTTSJob(node, node.generated.prompt, bHandle, bKey, node.generated.voiceId);
454
+ } else if (kind === 'text') {
455
+ // text → /api/text (OpenRouter / Chatium), а не /api/generate (KIE).
456
+ const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
457
+ const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
458
+ runTextJob(node, node.generated.prompt, model, bHandle, bKey, imageRefs);
459
+ } else {
460
+ const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
461
+ startGenerationJob(node, kind, node.generated.prompt, refs, bHandle, bKey, node.generated.modelKey);
462
+ }
463
+ }
464
+
465
+ async function regenerateNode(node) {
466
+ if (node.status === 'generating') return;
467
+ const g = node.generated || {};
468
+ // SFX / Music — отдельный flow, чтобы не путать с TTS-голосом.
469
+ if (g.subKind === 'sfx') {
470
+ state.sfxRegenerateTarget = node;
471
+ $('sfxPrompt').value = g.prompt || '';
472
+ $('sfxDuration').value = g.durationSeconds || '';
473
+ $('sfxStatus').textContent = ''; $('sfxStatus').className = 'status';
474
+ $('sfxSubmit').disabled = false;
475
+ $('sfxModal').classList.remove('hidden');
476
+ setTimeout(() => $('sfxPrompt').focus(), 30);
477
+ return;
478
+ }
479
+ if (g.subKind === 'music') {
480
+ state.musicRegenerateTarget = node;
481
+ $('musicPrompt').value = g.prompt || '';
482
+ $('musicDuration').value = g.durationMs ? (g.durationMs / 1000) : '';
483
+ $('musicStatus').textContent = ''; $('musicStatus').className = 'status';
484
+ $('musicSubmit').disabled = false;
485
+ $('musicModal').classList.remove('hidden');
486
+ setTimeout(() => $('musicPrompt').focus(), 30);
487
+ return;
488
+ }
489
+ // Открываем Generate-модалку с предзаполненными параметрами
490
+ // (или дефолтами, если нода была загружена, а не сгенерирована — текущий файл уйдёт в history)
491
+ state.regenerateTarget = node;
492
+ state.genKind = g.kind || node.type;
493
+
494
+ resetPicks();
495
+ presetPicksForBoard();
496
+ renderCharsPickChips();
497
+ renderLocPickSelect();
498
+ syncCharLocRows();
499
+
500
+ // Исходный кадр для regenerate:
501
+ // 1. Если нода была ВЫВЕДЕНА из другой ноды (node.generated.sourceRef есть)
502
+ // — это связь с родительской нодой, её надо сохранить через regenerate
503
+ // иначе модель сгенерирует не «вариацию из X», а просто новый по промпту.
504
+ // 2. Иначе video: текущий файл как референс (вариация первого кадра).
505
+ // 3. Иначе image: НЕ берём свою же картинку — модель просто скопирует.
506
+ if (node.generated?.sourceRef && node.generated.sourceRef.file) {
507
+ const sr = node.generated.sourceRef;
508
+ state.sourceRef = {
509
+ file: sr.file,
510
+ type: sr.type,
511
+ boardHandle: state.currentBoard.handle,
512
+ use: true,
513
+ };
514
+ if (sr.type === 'image') {
515
+ let thumbUrl = state.currentBoard.urls[sr.file];
516
+ if (!thumbUrl) {
517
+ try {
518
+ const fh = await resolveBoardFile(state.currentBoard.handle, sr.file);
519
+ thumbUrl = URL.createObjectURL(await fh.getFile());
520
+ state.currentBoard.urls[sr.file] = thumbUrl;
521
+ } catch {}
522
+ }
523
+ $('sourceRefThumb').src = thumbUrl || '';
524
+ $('sourceRefThumb').style.display = '';
525
+ } else {
526
+ $('sourceRefThumb').src = '';
527
+ $('sourceRefThumb').style.display = 'none';
528
+ }
529
+ $('sourceRefName').textContent = sr.file.split('/').pop();
530
+ applySourceRefVisuals();
531
+ } else if (node.file && node.type === 'video') {
532
+ state.sourceRef = {
533
+ file: node.file,
534
+ type: 'video',
535
+ boardHandle: state.currentBoard.handle,
536
+ use: true,
537
+ };
538
+ $('sourceRefThumb').src = '';
539
+ $('sourceRefThumb').style.display = 'none';
540
+ $('sourceRefName').textContent = node.name || node.file.split('/').pop();
541
+ applySourceRefVisuals();
542
+ } else {
543
+ state.sourceRef = null;
544
+ }
545
+ syncSourceRefRow();
546
+
547
+ document.querySelectorAll('#genModal [data-kind]').forEach(b =>
548
+ b.classList.toggle('active', b.dataset.kind === state.genKind));
549
+ $('imageModelRow').style.display = state.genKind === 'image' ? '' : 'none';
550
+
551
+ $('imageOptionsRow').style.display = state.genKind === 'image' ? '' : 'none';
552
+ $('videoOptionsRow').style.display = state.genKind === 'video' ? '' : 'none';
553
+
554
+ $('videoModelRow').style.display = state.genKind === 'video' ? '' : 'none';
555
+ $('voiceRow').style.display = state.genKind === 'audio' ? '' : 'none';
556
+
557
+ $('ttsModelRow').style.display = state.genKind === 'audio' ? '' : 'none';
558
+
559
+ if (g.modelKey && state.genKind === 'image') {
560
+ state.imageModel = g.modelKey;
561
+ document.querySelectorAll('#genModal [data-img-model]').forEach(b =>
562
+ b.classList.toggle('active', b.dataset.imgModel === g.modelKey));
563
+ }
564
+ if (state.genKind === 'image') {
565
+ if (g.aspectRatio) state.imageAspect = g.aspectRatio;
566
+ syncImageAspectActive();
567
+ }
568
+ // Видео: подставляем сохранённые duration/resolution/aspect для regenerate
569
+ if (state.genKind === 'video') {
570
+ if (g.duration) state.videoDuration = g.duration;
571
+ if (g.resolution) state.videoResolution = g.resolution;
572
+ if (g.aspectRatio) state.videoAspect = g.aspectRatio;
573
+ if (g.modelKey) state.videoModel = g.modelKey;
574
+ syncVideoOptionsActive();
575
+ syncVideoModelActive();
576
+ }
577
+ if (state.genKind === 'audio') {
578
+ if (g.ttsModel) state.ttsModel = g.ttsModel;
579
+ syncTtsModelActive();
580
+ if (state.ttsModel === 'elevenlabs/v3') await loadVoices();
581
+ if (g.voiceId) $('genVoice').value = g.voiceId;
582
+ state.activeTones = (g.tones || []).slice();
583
+ state.toneSuggestions = (g.tones || []).slice();
584
+ renderTones();
585
+ $('tonesRow').style.display = '';
586
+ }
587
+
588
+ $('genPrompt').value = g.rawPrompt || g.prompt || '';
589
+ $('genStatus').textContent = '';
590
+ $('genStatus').className = 'status';
591
+ $('genSubmit').disabled = false;
592
+ closeMentionPopup();
593
+ $('genModal').classList.remove('hidden');
594
+ setTimeout(() => {
595
+ const ta = $('genPrompt');
596
+ ta.focus();
597
+ ta.setSelectionRange(ta.value.length, ta.value.length);
598
+ }, 50);
599
+ }
600
+
601
+ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
602
+ // Зафиксировать текущие размеры ноды, чтобы spinner не схлопнул её
603
+ const el = canvas.querySelector(`.node[data-id="${node.id}"]`);
604
+ if (el) {
605
+ if (!node.width) node.width = el.offsetWidth;
606
+ if (!node.height) node.height = el.offsetHeight;
607
+ el.style.width = node.width + 'px';
608
+ el.style.height = node.height + 'px';
609
+ }
610
+
611
+ // Сохраняем текущее в history
612
+ if (!Array.isArray(node.history) || !node.history.length) {
613
+ node.history = [{
614
+ file: node.file, generated: { ...node.generated },
615
+ status: undefined, error: undefined,
616
+ }];
617
+ node.historyIndex = 0;
618
+ } else {
619
+ syncHistorySlot(node);
620
+ }
621
+
622
+ let voiceId, voiceName, modelKey, refs;
623
+ let resolvedPrompt;
624
+ if (kind === 'audio') {
625
+ voiceId = $('genVoice').value || node.generated?.voiceId;
626
+ voiceName = $('genVoice').selectedOptions[0]?.textContent || '';
627
+ if (voiceId) localStorage.setItem('lastVoiceId', voiceId);
628
+ const resolvedRaw = (typeof resolveMentions === 'function')
629
+ ? resolveMentions(rawPrompt, []).trim()
630
+ : rawPrompt;
631
+ if (!resolvedRaw) {
632
+ alert('После раскрытия @-ссылок текст пустой. Проверь исходные ноды.');
633
+ return;
634
+ }
635
+ const tonePrefix = state.activeTones.map(t => `[${t}]`).join(' ');
636
+ resolvedPrompt = tonePrefix ? `${tonePrefix} ${resolvedRaw}` : resolvedRaw;
637
+ } else if (kind === 'text') {
638
+ // Текстовая модель: имя берём как есть (полный slug OpenRouter,
639
+ // напр. anthropic/claude-sonnet-4). Нет state.textModel — fallback
640
+ // на сохранённое в ноде или дефолт.
641
+ modelKey = node.generated?.modelKey || node.generated?.model || 'anthropic/claude-sonnet-4';
642
+ refs = gatherMediaRefs(rawPrompt);
643
+ if (opts.sourceRef && opts.sourceRef.file && opts.sourceRef.type === 'image') {
644
+ const sr = opts.sourceRef;
645
+ const dup = refs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
646
+ if (!dup) {
647
+ refs.unshift({
648
+ key: '__source__',
649
+ name: 'исходный кадр',
650
+ type: 'image',
651
+ file: sr.file,
652
+ boardHandle: sr.boardHandle,
653
+ });
654
+ }
655
+ }
656
+ for (const pr of (opts.pickedSheets || [])) {
657
+ if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
658
+ refs.push(pr);
659
+ }
660
+ const missing = refs.filter(r => !r.file || r.status === 'generating');
661
+ if (missing.length) {
662
+ alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
663
+ return;
664
+ }
665
+ resolvedPrompt = resolveMentions(rawPrompt, refs);
666
+ } else {
667
+ modelKey = kind === 'image' ? state.imageModel : (state.videoModel || 'seedance-2');
668
+ refs = gatherMediaRefs(rawPrompt);
669
+ // Подмешиваем "исходный кадр" — текущий файл ноды как референс при редактировании
670
+ let sourceMarker = '';
671
+ if (opts.sourceRef && opts.sourceRef.file) {
672
+ const sr = opts.sourceRef;
673
+ const dup = refs.some(r => r.file === sr.file && r.boardHandle === sr.boardHandle);
674
+ if (!dup) {
675
+ const refType = sr.type === 'video' ? 'video' : 'image';
676
+ refs.unshift({
677
+ key: '__source__',
678
+ name: 'исходный кадр',
679
+ type: refType,
680
+ file: sr.file,
681
+ boardHandle: sr.boardHandle,
682
+ });
683
+ // Источник — первый реф своего типа: добавим маркер в промпт, чтобы модель учла его
684
+ sourceMarker = refType === 'video' ? '[video 1] ' : '[image 1] ';
685
+ }
686
+ }
687
+ // Подмешиваем sheet'ы выбранных персонажей и локации (если они ещё не в рефах)
688
+ for (const pr of (opts.pickedSheets || [])) {
689
+ if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
690
+ refs.push(pr);
691
+ }
692
+ const missing = refs.filter(r => !r.file || r.status === 'generating');
693
+ if (missing.length) {
694
+ alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
695
+ return;
696
+ }
697
+ resolvedPrompt = resolveMentions(rawPrompt, refs);
698
+ if (sourceMarker) resolvedPrompt = sourceMarker + resolvedPrompt;
699
+ }
700
+
701
+ const modelId = kind === 'image'
702
+ ? ({ 'grok': 'grok-imagine/text-to-image',
703
+ 'seedream': 'seedream/4.5-text-to-image',
704
+ 'seedream-5-lite': 'seedream/5-lite-text-to-image',
705
+ 'flux-schnell': 'flux/schnell',
706
+ 'sdxl-lightning': 'sdxl/lightning',
707
+ 'nano-banana-2': 'nano-banana-2' }[modelKey] || 'nano-banana-2')
708
+ : kind === 'video' ? ({
709
+ 'seedance-2': 'bytedance/seedance-2',
710
+ 'seedance-2-fast': 'bytedance/seedance-2-fast',
711
+ 'kling-o1': 'kwaivgi/kling-o1',
712
+ 'kling-3.0': 'kling-3.0/video',
713
+ }[modelKey] || 'bytedance/seedance-2')
714
+ : kind === 'text' ? modelKey // полный slug OpenRouter
715
+ : 'eleven_v3'; // audio
716
+
717
+ // sourceRef нужно сохранить через regenerate, иначе при следующем
718
+ // открытии modal'а связь с родительской нодой потеряется.
719
+ // Приоритет: то что юзер активно выбрал в UI (opts.sourceRef);
720
+ // если не выбирал — оставляем то что было раньше в node.generated.sourceRef.
721
+ const carryoverSourceRef = opts.sourceRef && opts.sourceRef.file
722
+ ? { file: opts.sourceRef.file, type: opts.sourceRef.type }
723
+ : (node.generated?.sourceRef || null);
724
+
725
+ const seedGen = kind === 'audio'
726
+ ? { kind, prompt: resolvedPrompt, rawPrompt, model: modelId, voiceId, voiceName,
727
+ ttsModel: state.ttsModel || node.generated?.ttsModel || 'qwen/qwen3-tts',
728
+ tones: [...state.activeTones], state: 'submitting' }
729
+ : { kind, prompt: resolvedPrompt, rawPrompt, modelKey, model: modelId,
730
+ refs: refs ? refs.map(r => ({ name: r.name, type: r.type, file: r.file })) : [],
731
+ ...(carryoverSourceRef ? { sourceRef: carryoverSourceRef } : {}),
732
+ state: 'submitting' };
733
+
734
+ // Для text-ноды сохраняем существующий .md-файл (runTextJob перезапишет
735
+ // его содержимое). Если файла ещё нет — создаём пустой texts/text_X.md
736
+ // здесь, иначе writeBoardFile упадёт на split('/') от undefined.
737
+ let preservedFile;
738
+ if (kind === 'text') {
739
+ if (node.file) {
740
+ preservedFile = node.file;
741
+ } else {
742
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
743
+ const mdName = await uniqueName(dir, 'text.md');
744
+ await writeFile(dir, mdName, '');
745
+ preservedFile = `texts/${mdName}`;
746
+ }
747
+ }
748
+
749
+ const newSlot = { file: preservedFile, generated: seedGen, status: 'generating', error: undefined };
750
+ node.history.push(newSlot);
751
+ node.historyIndex = node.history.length - 1;
752
+ node.type = kind;
753
+ node.file = preservedFile; // для не-text undefined; для text — путь к .md
754
+ node.generated = newSlot.generated;
755
+ node.status = 'generating';
756
+ node.error = undefined;
757
+
758
+ scheduleSave();
759
+ await refreshNodeDOM(node.id);
760
+
761
+ const bHandle = state.currentBoard.handle;
762
+ const bKey = state.currentBoard.key;
763
+ if (kind === 'audio') {
764
+ runTTSJob(node, resolvedPrompt, bHandle, bKey, voiceId);
765
+ } else if (kind === 'text') {
766
+ runTextJob(node, resolvedPrompt, modelId, bHandle, bKey, refs);
767
+ } else {
768
+ startGenerationJob(node, kind, resolvedPrompt, refs, bHandle, bKey, modelKey);
769
+ }
770
+ }
771
+
772
+ // =================== Drag & drop ===================
773
+ let dragCounter = 0;
774
+ canvasWrap.addEventListener('dragenter', e => {
775
+ if (!state.currentBoard) return;
776
+ if (![...e.dataTransfer.types].includes('Files')) return;
777
+ dragCounter++;
778
+ canvasWrap.classList.add('drag-over');
779
+ });
780
+ canvasWrap.addEventListener('dragover', e => {
781
+ if (!state.currentBoard) return;
782
+ if (![...e.dataTransfer.types].includes('Files')) return;
783
+ e.preventDefault();
784
+ });
785
+ canvasWrap.addEventListener('dragleave', () => {
786
+ dragCounter--;
787
+ if (dragCounter <= 0) { dragCounter = 0; canvasWrap.classList.remove('drag-over'); }
788
+ });
789
+ canvasWrap.addEventListener('drop', async e => {
790
+ e.preventDefault();
791
+ dragCounter = 0;
792
+ canvasWrap.classList.remove('drag-over');
793
+ if (!state.currentBoard) return;
794
+ const rect = canvas.getBoundingClientRect();
795
+ const dropX = (e.clientX - rect.left) / state.zoom;
796
+ const dropY = (e.clientY - rect.top) / state.zoom;
797
+ let i = 0;
798
+ for (const file of e.dataTransfer.files) {
799
+ const type = getFileType(file);
800
+ if (!type) continue;
801
+ try {
802
+ const filename = await importToBoard(state.currentBoard.handle, file, type);
803
+ const node = {
804
+ id: crypto.randomUUID(),
805
+ type,
806
+ file: filename,
807
+ x: dropX + i*24, y: dropY + i*24,
808
+ };
809
+ if (type === 'text') {
810
+ try { node.text = await file.text(); } catch { node.text = ''; }
811
+ }
812
+ state.currentBoard.metadata.nodes.push(node);
813
+ canvas.appendChild(await createNodeEl(node));
814
+ i++;
815
+ } catch (err) { console.error(err); alert(`Не удалось импортировать ${file.name}: ${err.message}`); }
816
+ }
817
+ scheduleSave();
818
+ });
819
+
820
+ // =================== Текстовая нода ===================
821
+ $('addText').addEventListener('click', async () => {
822
+ if (!state.currentBoard) return;
823
+ const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
824
+ const mdName = await uniqueName(dir, 'text.md');
825
+ await writeFile(dir, mdName, '');
826
+ const node = {
827
+ id: crypto.randomUUID(),
828
+ type: 'text',
829
+ file: `texts/${mdName}`,
830
+ text: '',
831
+ x: canvasWrap.scrollLeft / state.zoom + 80, y: canvasWrap.scrollTop / state.zoom + 80,
832
+ };
833
+ state.currentBoard.metadata.nodes.push(node);
834
+ canvas.appendChild(await createNodeEl(node));
835
+ scheduleSave();
836
+ });
837
+
838
+ // =================== @-mentions: автокомплит и резолвер ===================
839
+ // Ключ для @-ссылки: имя если есть, иначе uuid ноды
840
+ function nodeRefKey(n) { return n.name || n.id; }
841
+
842
+ // Только названные ноды — попадают в автокомплит
843
+ function getReferenceableNodes() {
844
+ if (!state.currentBoard) return [];
845
+ return state.currentBoard.metadata.nodes.filter(n => n.name && n.id);
846
+ }
847
+
848
+ // Все варианты для @-popup с учётом kind генерации:
849
+ // - локальные именованные ноды (фильтруются по типу)
850
+ // - @<персонаж> (если у него есть characterSheet)
851
+ // - @<персонаж>.<имя_картинки>
852
+ function getMentionSuggestions(kind) {
853
+ if (!state.currentBoard) return [];
854
+ // Текстовые ноды разрешены везде: resolveMentions инлайнит их .md прямо в промпт.
855
+ const allowed = kind === 'image' ? ['image', 'text']
856
+ : kind === 'video' ? ['image', 'video', 'audio', 'text']
857
+ : ['text', 'image', 'video', 'audio'];
858
+ const out = [];
859
+ for (const n of state.currentBoard.metadata.nodes) {
860
+ if (!n.name || !n.id) continue;
861
+ if (!allowed.includes(n.type)) continue;
862
+ out.push({ key: n.name, label: n.name, type: n.type, scope: 'board' });
863
+ }
864
+ if (kind === 'image' || kind === 'video') {
865
+ for (const c of state.charactersInfo) {
866
+ // Чтобы не дублировать на собственной доске
867
+ if (state.currentBoard.kind === 'character' && state.currentBoard.name === c.name) continue;
868
+ if (c.characterSheet) {
869
+ out.push({ key: c.name, label: c.name + ' · sheet', type: 'image', scope: 'char-sheet' });
870
+ }
871
+ for (const img of (c.imageNodes || [])) {
872
+ out.push({
873
+ key: `${c.name}.${img.name}`,
874
+ label: `${c.name}.${img.name}`,
875
+ type: 'image', scope: 'char-image',
876
+ });
877
+ }
878
+ }
879
+ }
880
+ return out;
881
+ }
882
+
883
+ // Все ноды на доске, но искать в промпте можно и по id (для drag-drop безымянных)
884
+ function getAllRefNodes() {
885
+ if (!state.currentBoard) return [];
886
+ return state.currentBoard.metadata.nodes.filter(n => n.id);
887
+ }
888
+
889
+ // Найти все @media-ноды в промпте — упорядоченные по первому появлению.
890
+ // Возвращает [{key, name, type, file, boardHandle, charName?, firstIdx}, ...]
891
+ function gatherMediaRefs(rawPrompt) {
892
+ const candidates = [];
893
+ for (const n of getAllRefNodes()) {
894
+ if (!['image','video','audio'].includes(n.type)) continue;
895
+ if (!n.file) continue;
896
+ candidates.push({
897
+ key: nodeRefKey(n),
898
+ name: n.name || n.id,
899
+ type: n.type,
900
+ file: n.file,
901
+ boardHandle: state.currentBoard.handle,
902
+ });
903
+ }
904
+ for (const c of state.charactersInfo) {
905
+ if (state.currentBoard?.kind === 'character' && state.currentBoard.name === c.name) continue;
906
+ if (c.characterSheet) {
907
+ candidates.push({
908
+ key: c.name, name: c.name, type: 'image',
909
+ file: c.characterSheet, boardHandle: c.handle, charName: c.name,
910
+ });
911
+ }
912
+ for (const img of (c.imageNodes || [])) {
913
+ candidates.push({
914
+ key: `${c.name}.${img.name}`, name: img.name, type: 'image',
915
+ file: img.file, boardHandle: c.handle, charName: c.name,
916
+ });
917
+ }
918
+ }
919
+ // Длинные ключи раньше — на этапе матчинга
920
+ candidates.sort((a, b) => b.key.length - a.key.length);
921
+ // Маскируем уже найденные позиции, чтобы @алиса.happy не «находил» @алиса повторно
922
+ let masked = rawPrompt;
923
+ const matched = [];
924
+ for (const c of candidates) {
925
+ // Поддерживаем обе формы: [@key] (новая) и @key (легаси)
926
+ const bracketed = '[@' + c.key + ']';
927
+ const bare = '@' + c.key;
928
+ let idx = masked.indexOf(bracketed);
929
+ if (idx < 0) idx = masked.indexOf(bare);
930
+ if (idx < 0) continue;
931
+ matched.push({ ...c, firstIdx: idx });
932
+ masked = masked.split(bracketed).join(''.repeat(bracketed.length));
933
+ masked = masked.split(bare).join(''.repeat(bare.length));
934
+ }
935
+ // Сортируем по позиции в исходном промпте
936
+ matched.sort((a, b) => a.firstIdx - b.firstIdx);
937
+ // Дедуп по ключу (на случай если дважды попало)
938
+ const seen = new Set();
939
+ const result = matched.filter(m => seen.has(m.key) ? false : (seen.add(m.key), true));
940
+ // Лог: сколько кандидатов и что попало в refs
941
+ console.log('[gatherMediaRefs]',
942
+ '\n prompt:', JSON.stringify(rawPrompt),
943
+ '\n candidates(', candidates.length, '):',
944
+ candidates.map(c => ({ key: c.key, type: c.type, file: c.file, char: c.charName || null, board: c.boardHandle?.name })),
945
+ '\n matched(', result.length, '):',
946
+ result.map(r => ({ key: r.key, type: r.type, file: r.file, char: r.charName || null, board: r.boardHandle?.name })));
947
+ return result;
948
+ }
949
+
950
+ // Загрузка файла из папки доски на KIE -> публичный URL.
951
+ // Кэшируем по filename+size+mtime (fingerprint).
952
+ // Кэш живёт в файле <film>/_uploads.json (общий между сессиями и машинами).
953
+ const uploadCache = new Map(); // key -> { url, expires }
954
+ async function loadUploadCache() {
955
+ uploadCache.clear();
956
+ if (!state.filmHandle) return;
957
+ try {
958
+ const fh = await state.filmHandle.getFileHandle('_uploads.json');
959
+ const data = JSON.parse(await (await fh.getFile()).text());
960
+ const now = Date.now();
961
+ for (const [k, v] of Object.entries(data || {})) {
962
+ if (v && v.expires > now) uploadCache.set(k, v);
963
+ }
964
+ } catch {}
965
+ }
966
+ let _saveUploadTimer = null;
967
+ function scheduleSaveUploadCache() {
968
+ if (_saveUploadTimer) clearTimeout(_saveUploadTimer);
969
+ _saveUploadTimer = setTimeout(saveUploadCache, 500);
970
+ }
971
+ async function saveUploadCache() {
972
+ _saveUploadTimer = null;
973
+ if (!state.filmHandle) return;
974
+ try {
975
+ const obj = {};
976
+ const now = Date.now();
977
+ for (const [k, v] of uploadCache.entries()) {
978
+ if (v.expires > now) obj[k] = v;
979
+ }
980
+ const fh = await state.filmHandle.getFileHandle('_uploads.json', { create: true });
981
+ const w = await fh.createWritable();
982
+ await w.write(JSON.stringify(obj, null, 2));
983
+ await w.close();
984
+ } catch (e) { console.error('save upload cache failed', e); }
985
+ }
986
+
987
+ async function uploadBoardFile(boardHandle, boardKey, filename) {
988
+ const t0 = Date.now();
989
+ console.log('[uploadBoardFile] →', { board: boardKey, file: filename, boardName: boardHandle?.name });
990
+ const fh = await resolveBoardFile(boardHandle, filename);
991
+ const file = await fh.getFile();
992
+ const fingerprint = `${file.size}.${file.lastModified || 0}`;
993
+ const cacheKey = `${boardKey}::${filename}::${fingerprint}`;
994
+ const now = Date.now();
995
+ const cached = uploadCache.get(cacheKey);
996
+ if (cached && cached.expires > now) {
997
+ console.log('[uploadBoardFile] ✓ cache HIT', { file: filename, size: file.size, mime: file.type, url: cached.url });
998
+ return cached.url;
999
+ }
1000
+ console.log('[uploadBoardFile] cache MISS — uploading', { file: filename, size: file.size, mime: file.type });
1001
+ const r = await fetch('/api/upload', {
1002
+ method: 'POST',
1003
+ headers: {
1004
+ 'Content-Type': file.type || 'application/octet-stream',
1005
+ 'X-File-Name': encodeURIComponent(file.name),
1006
+ },
1007
+ body: file,
1008
+ });
1009
+ const data = await r.json();
1010
+ if (!r.ok || data.error) {
1011
+ console.log('[uploadBoardFile] ✗ FAIL', { status: r.status, error: data.error });
1012
+ throw new Error(data.error || `upload ${r.status}`);
1013
+ }
1014
+ uploadCache.set(cacheKey, { url: data.url, expires: now + 2.5 * 24 * 3600 * 1000 });
1015
+ scheduleSaveUploadCache();
1016
+ console.log('[uploadBoardFile] ✓ uploaded in', Date.now() - t0, 'ms', { file: filename, url: data.url, kieFilename: data.fileName, kieSize: data.size });
1017
+ return data.url;
1018
+ }
1019
+
1020
+ // Превращает @-ссылки в семантически нейтральные позиционные маркеры,
1021
+ // привязанные к индексам image_input/reference_*_urls.
1022
+ // mediaRefs — результат gatherMediaRefs (в порядке появления).
1023
+ function resolveMentions(text, mediaRefs) {
1024
+ const ops = [];
1025
+ // Текстовые ноды: инлайним содержимое .md
1026
+ for (const n of (state.currentBoard?.metadata?.nodes || [])) {
1027
+ if (n.type === 'text' && n.id) {
1028
+ ops.push({ key: nodeRefKey(n), replace: n.text || '' });
1029
+ }
1030
+ }
1031
+ // Медиа: маркер [image N] / [video N] / [audio N], где N — позиция в массиве рефов того же типа
1032
+ let imgI = 1, vidI = 1, audI = 1;
1033
+ for (const r of (mediaRefs || [])) {
1034
+ let marker;
1035
+ if (r.type === 'image') marker = `[image ${imgI++}]`;
1036
+ else if (r.type === 'video') marker = `[video ${vidI++}]`;
1037
+ else if (r.type === 'audio') marker = `[audio ${audI++}]`;
1038
+ else marker = '';
1039
+ ops.push({ key: r.key, replace: marker });
1040
+ }
1041
+ // Длинные ключи раньше
1042
+ ops.sort((a, b) => b.key.length - a.key.length);
1043
+ let out = text;
1044
+ // Сначала заменяем форму [@key] (новую), потом легаси @key
1045
+ for (const op of ops) {
1046
+ out = out.split('[@' + op.key + ']').join(op.replace);
1047
+ }
1048
+ for (const op of ops) {
1049
+ out = out.split('@' + op.key).join(op.replace);
1050
+ }
1051
+ out = out.replace(/[ \t]+/g, ' ').replace(/\s+([.,!?;:])/g, '$1').trim();
1052
+ return out;
1053
+ }
1054
+
1055
+ const mentionState = { open: false, anchor: 0, items: [], selected: 0 };
1056
+
1057
+ function updateMentionPopup() {
1058
+ const ta = $('genPrompt');
1059
+ const cursor = ta.selectionStart;
1060
+ const before = ta.value.slice(0, cursor);
1061
+ // @@ — только персонажи; @... — обычный popup; [@... — обычный popup
1062
+ const mDouble = before.match(/@@([^\s@\]]*)$/);
1063
+ const m = mDouble || before.match(/(\[?)@([^\s@\]]*)$/);
1064
+ const popup = $('mentionPopup');
1065
+ if (!m) { closeMentionPopup(); return; }
1066
+ const isCharOnly = !!mDouble;
1067
+ const query = (mDouble ? mDouble[1] : m[2]).toLowerCase();
1068
+
1069
+ const dotIdx = query.indexOf('.');
1070
+ let items = [];
1071
+
1072
+ if (dotIdx >= 0) {
1073
+ // Уже выбран персонаж — показываем только его картинки
1074
+ const charName = query.slice(0, dotIdx);
1075
+ const imgQuery = query.slice(dotIdx + 1);
1076
+ const c = state.charactersInfo.find(x => x.name.toLowerCase() === charName);
1077
+ if (c) {
1078
+ for (const img of (c.imageNodes || [])) {
1079
+ if (!imgQuery || img.name.toLowerCase().includes(imgQuery)) {
1080
+ items.push({
1081
+ key: `${c.name}.${img.name}`,
1082
+ label: img.name,
1083
+ type: 'image',
1084
+ scope: 'char-image',
1085
+ charName: c.name,
1086
+ });
1087
+ }
1088
+ }
1089
+ }
1090
+ } else if (isCharOnly) {
1091
+ // @@ — только персонажи
1092
+ for (const c of state.charactersInfo) {
1093
+ if (state.currentBoard?.kind === 'character' && state.currentBoard.name === c.name) continue;
1094
+ const hasImages = (c.imageNodes || []).length > 0;
1095
+ items.push({
1096
+ key: c.name,
1097
+ label: c.name + (hasImages && c.characterSheet ? '' : (hasImages ? ' …' : (c.characterSheet ? ' · sheet' : ''))),
1098
+ type: 'image',
1099
+ scope: 'char',
1100
+ hasImages, hasSheet: !!c.characterSheet,
1101
+ });
1102
+ }
1103
+ items = items.filter(s => s.key.toLowerCase().includes(query)).slice(0, 16);
1104
+ } else {
1105
+ // Верхний уровень: локальные именованные ноды + персонажи (одна запись на персонажа)
1106
+ // Текстовые ноды разрешены везде — resolveMentions инлайнит их .md в промпт.
1107
+ const allowed = state.genKind === 'image' ? ['image', 'text']
1108
+ : state.genKind === 'video' ? ['image', 'video', 'audio', 'text']
1109
+ : ['text', 'image', 'video', 'audio'];
1110
+ for (const n of state.currentBoard.metadata.nodes) {
1111
+ if (!n.name || !n.id) continue;
1112
+ if (!allowed.includes(n.type)) continue;
1113
+ items.push({ key: n.name, label: n.name, type: n.type, scope: 'board' });
1114
+ }
1115
+ if (state.genKind === 'image' || state.genKind === 'video') {
1116
+ for (const c of state.charactersInfo) {
1117
+ if (state.currentBoard?.kind === 'character' && state.currentBoard.name === c.name) continue;
1118
+ const hasImages = (c.imageNodes || []).length > 0;
1119
+ if (c.characterSheet || hasImages) {
1120
+ items.push({
1121
+ key: c.name,
1122
+ label: c.name + (hasImages && c.characterSheet ? '' : (hasImages ? ' …' : ' · sheet')),
1123
+ type: 'image',
1124
+ scope: 'char',
1125
+ hasImages, hasSheet: !!c.characterSheet,
1126
+ });
1127
+ }
1128
+ }
1129
+ }
1130
+ items = items.filter(s => s.key.toLowerCase().includes(query)).slice(0, 16);
1131
+ }
1132
+
1133
+ mentionState.open = true;
1134
+ mentionState.anchor = cursor - m[0].length;
1135
+ mentionState.items = items;
1136
+ mentionState.selected = 0;
1137
+ popup.innerHTML = '';
1138
+ if (!items.length) {
1139
+ const e = document.createElement('div');
1140
+ e.className = 'empty-msg';
1141
+ e.textContent = dotIdx >= 0 ? 'У персонажа нет картинок с таким именем' : 'Нет совпадений';
1142
+ popup.appendChild(e);
1143
+ } else {
1144
+ items.forEach((s, i) => {
1145
+ const it = document.createElement('div');
1146
+ it.className = 'mit' + (i === 0 ? ' selected' : '');
1147
+ it.dataset.idx = i;
1148
+ const t = document.createElement('span');
1149
+ t.className = `mtype ${s.type}`;
1150
+ t.textContent = s.scope === 'char' ? 'персонаж'
1151
+ : s.scope === 'char-image' ? `персонаж·${s.charName}`
1152
+ : s.type;
1153
+ const nm = document.createElement('span');
1154
+ nm.textContent = s.label;
1155
+ it.append(t, nm);
1156
+ it.addEventListener('mousedown', e => { e.preventDefault(); selectMention(i); });
1157
+ popup.appendChild(it);
1158
+ });
1159
+ }
1160
+ popup.classList.remove('hidden');
1161
+ }
1162
+
1163
+ function closeMentionPopup() {
1164
+ mentionState.open = false;
1165
+ $('mentionPopup').classList.add('hidden');
1166
+ }
1167
+
1168
+ function selectMention(idx = mentionState.selected) {
1169
+ if (!mentionState.open) return;
1170
+ const item = mentionState.items[idx];
1171
+ if (!item) { closeMentionPopup(); return; }
1172
+ const ta = $('genPrompt');
1173
+ const cursor = ta.selectionStart;
1174
+ const before = ta.value.slice(0, mentionState.anchor);
1175
+ const after = ta.value.slice(cursor);
1176
+ // Если выбрали персонажа и у него есть картинки — авто-drill: оставляем @name. для дальнейшего ввода
1177
+ let insert;
1178
+ let reopen = false;
1179
+ if (item.scope === 'char' && item.hasImages) {
1180
+ insert = '[@' + item.key + '.';
1181
+ reopen = true;
1182
+ } else {
1183
+ insert = '[@' + (item.key || item.name) + '] ';
1184
+ }
1185
+ ta.value = before + insert + after;
1186
+ const newPos = (before + insert).length;
1187
+ ta.setSelectionRange(newPos, newPos);
1188
+ ta.focus();
1189
+ if (reopen) setTimeout(updateMentionPopup, 0);
1190
+ else closeMentionPopup();
1191
+ }
1192
+
1193
+ function highlightMention(idx) {
1194
+ const popup = $('mentionPopup');
1195
+ popup.querySelectorAll('.mit').forEach(el => el.classList.remove('selected'));
1196
+ const target = popup.querySelector(`.mit[data-idx="${idx}"]`);
1197
+ if (target) {
1198
+ target.classList.add('selected');
1199
+ target.scrollIntoView({ block: 'nearest' });
1200
+ }
1201
+ mentionState.selected = idx;
1202
+ }
1203
+
1204
+ $('genPrompt').addEventListener('input', updateMentionPopup);
1205
+ $('genPrompt').addEventListener('keyup', e => {
1206
+ if (['ArrowUp','ArrowDown','Enter','Tab','Escape'].includes(e.key)) return;
1207
+ updateMentionPopup();
1208
+ });
1209
+ $('genPrompt').addEventListener('keydown', e => {
1210
+ if (!mentionState.open) return;
1211
+ if (e.key === 'Escape') { e.preventDefault(); closeMentionPopup(); }
1212
+ else if (e.key === 'ArrowDown') { e.preventDefault(); highlightMention(Math.min(mentionState.selected + 1, mentionState.items.length - 1)); }
1213
+ else if (e.key === 'ArrowUp') { e.preventDefault(); highlightMention(Math.max(mentionState.selected - 1, 0)); }
1214
+ else if (e.key === 'Enter' || e.key === 'Tab') {
1215
+ if (mentionState.items.length) { e.preventDefault(); selectMention(); }
1216
+ }
1217
+ });
1218
+
1219
+ // =================== Character settings ===================
1220
+ async function loadAllCharactersInfo() {
1221
+ if (!state.filmHandle) return;
1222
+ const chars = await listCharacters(state.filmHandle);
1223
+ const info = [];
1224
+ for (const c of chars) {
1225
+ try {
1226
+ const meta = await loadBoardMetadata(c.handle);
1227
+ const imageNodes = meta.nodes
1228
+ .filter(n => n.type === 'image' && n.file && n.name)
1229
+ .map(n => ({ name: n.name, file: n.file, id: n.id }));
1230
+ info.push({
1231
+ name: c.name,
1232
+ handle: c.handle,
1233
+ ...(meta.character || {}),
1234
+ imageNodes,
1235
+ });
1236
+ } catch {}
1237
+ }
1238
+ state.charactersInfo = info;
1239
+ }
1240
+
1241
+ // Запомнить тоны для персонажа: дополнить commonTones новыми + сохранить lastTones
1242
+ async function rememberCharTones(charNameOrVoiceId, tones) {
1243
+ if (!Array.isArray(tones) || !tones.length || !state.filmHandle) return;
1244
+ const cleaned = tones.map(t => (t || '').trim()).filter(Boolean);
1245
+ if (!cleaned.length) return;
1246
+ let charInfo = state.charactersInfo.find(c => c.name === charNameOrVoiceId);
1247
+ if (!charInfo) charInfo = state.charactersInfo.find(c => c.voice === charNameOrVoiceId);
1248
+ if (!charInfo) return;
1249
+ try {
1250
+ const meta = await loadBoardMetadata(charInfo.handle);
1251
+ const ch = meta.character || {};
1252
+ const existing = Array.isArray(ch.commonTones) ? [...ch.commonTones] : [];
1253
+ const seen = new Set(existing);
1254
+ let added = 0;
1255
+ for (const t of cleaned) if (!seen.has(t)) { existing.push(t); seen.add(t); added++; }
1256
+ ch.commonTones = existing;
1257
+ ch.lastTones = cleaned;
1258
+ meta.character = ch;
1259
+ await saveBoardMetadata(charInfo.handle, {
1260
+ nodes: meta.nodes, connections: meta.connections,
1261
+ view: meta.view, character: meta.character,
1262
+ location: meta.location, timeline: meta.timeline,
1263
+ });
1264
+ charInfo.commonTones = existing;
1265
+ charInfo.lastTones = cleaned;
1266
+ if (added) console.log(`[tones] +${added} → ${charInfo.name}: ${cleaned.join(', ')}`);
1267
+ } catch (e) { console.warn('rememberCharTones failed', e); }
1268
+ }
1269
+
1270
+ // =================== Locations ===================
1271
+ async function loadAllLocationsInfo() {
1272
+ if (!state.filmHandle) return;
1273
+ const locs = await listLocations(state.filmHandle);
1274
+ const info = [];
1275
+ for (const l of locs) {
1276
+ try {
1277
+ const meta = await loadBoardMetadata(l.handle);
1278
+ const imageNodes = meta.nodes
1279
+ .filter(n => n.type === 'image' && n.file && n.name)
1280
+ .map(n => ({ name: n.name, file: n.file, id: n.id }));
1281
+ info.push({
1282
+ name: l.name,
1283
+ handle: l.handle,
1284
+ sheet: meta.location?.sheet || null,
1285
+ imageNodes,
1286
+ });
1287
+ } catch {}
1288
+ }
1289
+ state.locationsInfo = info;
1290
+ }
1291
+
1292
+ function getImageNodesOnBoard() {
1293
+ if (!state.currentBoard) return [];
1294
+ return state.currentBoard.metadata.nodes
1295
+ .filter(n => n.type === 'image' && n.file)
1296
+ .map(n => ({ name: n.name || n.file, file: n.file, id: n.id }));
1297
+ }
1298
+
1299
+ async function openCharacterSettings() {
1300
+ if (!state.currentBoard || state.currentBoard.kind !== 'character') return;
1301
+ await loadVoices();
1302
+ const ch = state.currentBoard.metadata.character || {};
1303
+ $('charName').value = state.currentBoard.name;
1304
+ // Voices
1305
+ const voiceSel = $('charVoice');
1306
+ voiceSel.innerHTML = '<option value="">— не выбран —</option>';
1307
+ const genVoiceOpts = $('genVoice').options;
1308
+ for (let i = 0; i < genVoiceOpts.length; i++) {
1309
+ const opt = document.createElement('option');
1310
+ opt.value = genVoiceOpts[i].value;
1311
+ opt.textContent = genVoiceOpts[i].textContent;
1312
+ voiceSel.appendChild(opt);
1313
+ }
1314
+ voiceSel.value = ch.voice || '';
1315
+ // Tones
1316
+ $('charTone').value = ch.tone || '';
1317
+ $('charCommonTones').value = (ch.commonTones || []).join('\n');
1318
+ // Image dropdowns
1319
+ const imgs = getImageNodesOnBoard();
1320
+ for (const selId of ['charAvatar', 'charSheet']) {
1321
+ const sel = $(selId);
1322
+ sel.innerHTML = '<option value="">— не выбран —</option>';
1323
+ for (const im of imgs) {
1324
+ const opt = document.createElement('option');
1325
+ opt.value = im.file;
1326
+ opt.textContent = im.name;
1327
+ sel.appendChild(opt);
1328
+ }
1329
+ }
1330
+ $('charAvatar').value = ch.avatar || '';
1331
+ $('charSheet').value = ch.characterSheet || '';
1332
+ $('characterModal').classList.remove('hidden');
1333
+ }
1334
+
1335
+ $('charSettingsBtn').addEventListener('click', openCharacterSettings);
1336
+ $('charClose').addEventListener('click', () => $('characterModal').classList.add('hidden'));
1337
+
1338
+ $('charSettingsSave').addEventListener('click', async () => {
1339
+ if (!state.currentBoard) return;
1340
+ const voiceSel = $('charVoice');
1341
+ const voiceName = voiceSel.selectedOptions[0]?.textContent || '';
1342
+ const ch = {
1343
+ voice: voiceSel.value || null,
1344
+ voiceName: voiceSel.value ? voiceName : null,
1345
+ tone: $('charTone').value.trim() || null,
1346
+ commonTones: $('charCommonTones').value.split('\n').map(s => s.trim()).filter(Boolean),
1347
+ avatar: $('charAvatar').value || null,
1348
+ characterSheet: $('charSheet').value || null,
1349
+ };
1350
+ state.currentBoard.metadata.character = ch;
1351
+
1352
+ // Переименование папки персонажа если имя поменяли
1353
+ const oldName = state.currentBoard.name;
1354
+ const newName = $('charName').value.trim();
1355
+ if (newName && newName !== oldName) {
1356
+ try {
1357
+ // Сначала сохранить текущие метаданные в старую папку
1358
+ await saveBoardMetadata(state.currentBoard.handle, state.currentBoard.metadata);
1359
+ const charsRoot = await state.filmHandle.getDirectoryHandle(CHAR_DIR);
1360
+ const finalName = await uniqueSubdirName(charsRoot, newName);
1361
+ await moveDirectory(charsRoot, oldName, charsRoot, finalName);
1362
+ const newHandle = await charsRoot.getDirectoryHandle(finalName);
1363
+ state.currentBoard.name = finalName;
1364
+ state.currentBoard.handle = newHandle;
1365
+ state.currentBoard.key = boardKey('character', finalName);
1366
+ $('path').textContent = `${state.filmHandle.name} / ${finalName}`;
1367
+ localStorage.setItem(`lastBoard:${state.filmHandle.name}`,
1368
+ JSON.stringify({ kind: 'character', name: finalName }));
1369
+ await refreshSidebar();
1370
+ } catch (e) {
1371
+ alert('Не удалось переименовать: ' + e.message);
1372
+ }
1373
+ } else {
1374
+ scheduleSave();
1375
+ }
1376
+
1377
+ $('characterModal').classList.add('hidden');
1378
+ await loadAllCharactersInfo();
1379
+ });
1380
+
1381
+ $('charSheetGenerate').addEventListener('click', async () => {
1382
+ if (!state.currentBoard) return;
1383
+ let imgs = getImageNodesOnBoard();
1384
+ // Если есть выделенные image-ноды — берём только их
1385
+ if (state.selectedNodeIds.size) {
1386
+ const selectedImgs = imgs.filter(im => state.selectedNodeIds.has(im.id));
1387
+ if (selectedImgs.length) imgs = selectedImgs;
1388
+ }
1389
+ if (!imgs.length) { alert('На доске нет картинок для референса'); return; }
1390
+ const refs = imgs.map(im => ({
1391
+ name: im.name, type: 'image', file: im.file, id: im.id,
1392
+ }));
1393
+ const prompt = 'Character sheet of the character: front view, side view, three-quarter view, back view, key facial expressions and full-body poses, neutral background, consistent style and proportions';
1394
+ const spot = findFreeSpot();
1395
+ const node = {
1396
+ id: crypto.randomUUID(),
1397
+ type: 'image',
1398
+ name: 'character-sheet',
1399
+ x: spot.x, y: spot.y,
1400
+ status: 'generating',
1401
+ generated: {
1402
+ kind: 'image', prompt, rawPrompt: prompt,
1403
+ modelKey: 'nano-banana-2', model: 'nano-banana-2',
1404
+ refs: refs.map(r => ({ name: r.name, type: r.type, file: r.file })),
1405
+ },
1406
+ };
1407
+ state.currentBoard.metadata.nodes.push(node);
1408
+ canvas.appendChild(await createNodeEl(node));
1409
+ scheduleSave();
1410
+ $('characterModal').classList.add('hidden');
1411
+ startGenerationJob(node, 'image', prompt, refs, state.currentBoard.handle, state.currentBoard.key, 'nano-banana-2');
1412
+ });
1413
+