kingkont 0.20.16 → 0.20.17
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/index.html +1 -0
- package/lib/providers.js +98 -8
- package/main.js +44 -0
- package/package.json +1 -1
- package/preload.js +6 -0
- package/renderer/board.js +142 -39
- package/renderer/cloudProjects.js +65 -0
- package/renderer/drawings.js +15 -3
- package/renderer/generate.js +5 -1
- package/renderer/media.js +103 -8
- package/renderer/settings.js +64 -19
- package/renderer/state.js +26 -0
- package/renderer/styles.css +77 -10
- package/renderer/templates.js +10 -1
- package/server.js +8 -1
package/index.html
CHANGED
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
|
|
127
127
|
<main class="main">
|
|
128
128
|
<div class="toolbar">
|
|
129
|
+
<button id="sidebarToggle" class="kk-mobile-burger" title="Меню сцен" aria-label="Меню сцен">☰</button>
|
|
129
130
|
<button id="addText" class="kk-edit-only" disabled title="Создать пустую текстовую ноду">✏️ Написать</button>
|
|
130
131
|
<button id="genText" class="kk-edit-only" disabled title="Сгенерировать текст">📝 Текст</button>
|
|
131
132
|
<button id="genAudio" class="kk-edit-only" disabled title="Сгенерировать голос">🎙 Голос</button>
|
package/lib/providers.js
CHANGED
|
@@ -110,11 +110,27 @@ const CHATIUM_VIDEO_MODELS = {
|
|
|
110
110
|
'grok-t2v': 'grok-imagine/text-to-video',
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
-
// OpenRouter image-gen
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
113
|
+
// OpenRouter image-gen модели — приоритетный путь для nano-banana
|
|
114
|
+
// (см. startGeneration: orAvailable && orSupportsModel → OpenRouter).
|
|
115
|
+
// OpenRouter регистрирует их буквально как "Nano Banana ..." —
|
|
116
|
+
// сопоставление по их official-названиям:
|
|
117
|
+
// nano-banana-2 (Gemini 3.1 Flash Image Preview) → preview, дешевле
|
|
118
|
+
// nano-banana-pro (Gemini 3 Pro Image Preview) → выше качество
|
|
119
|
+
// (Slug google/gemini-2.5-flash-image-preview из старого кода устарел —
|
|
120
|
+
// OpenRouter возвращал «No endpoints found».)
|
|
121
|
+
const OPENROUTER_IMAGE_MODELS = {
|
|
122
|
+
'nano-banana-2': 'google/gemini-3.1-flash-image-preview',
|
|
123
|
+
'nano-banana-pro': 'google/gemini-3-pro-image-preview',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// OpenRouter video-gen модели — отдельный API endpoint POST /api/v1/videos
|
|
127
|
+
// (не /chat/completions). Подтверждено: bytedance/seedance-2.0 и -fast
|
|
128
|
+
// доступны (см. /api/v1/videos/models). duration: 4-15s, resolution: 480p/720p
|
|
129
|
+
// (для -2.0 ещё 1080p), aspects: 1:1, 3:4, 9:16, 4:3, 16:9, 21:9, 9:21.
|
|
130
|
+
const OPENROUTER_VIDEO_MODELS = {
|
|
131
|
+
'seedance-2': 'bytedance/seedance-2.0',
|
|
132
|
+
'seedance-2-fast': 'bytedance/seedance-2.0-fast',
|
|
133
|
+
};
|
|
118
134
|
|
|
119
135
|
function resolveModel(map, key, fallback) {
|
|
120
136
|
if (!key) return fallback;
|
|
@@ -302,12 +318,18 @@ async function startGeneration(args) {
|
|
|
302
318
|
// 1) KIE — если useKie=true И KIE_API_KEY И модель поддерживается KIE.
|
|
303
319
|
// 2) Chatium — fallback, либо если модель не входит в KIE.
|
|
304
320
|
// 3) Если ни один не доступен — ошибка.
|
|
305
|
-
const orKey = modelKey || (kind === 'image' ? 'nano-banana-2' : null);
|
|
321
|
+
const orKey = modelKey || (kind === 'image' ? 'nano-banana-2' : (kind === 'video' ? 'seedance-2' : null));
|
|
306
322
|
const orAvailable = s.useOpenrouter && process.env.OPENROUTER_API_KEY;
|
|
307
|
-
const
|
|
308
|
-
|
|
323
|
+
const orImgModel = kind === 'image' && OPENROUTER_IMAGE_MODELS[orKey];
|
|
324
|
+
const orVidModel = kind === 'video' && OPENROUTER_VIDEO_MODELS[orKey];
|
|
325
|
+
if (orAvailable && orImgModel) {
|
|
309
326
|
return await _startGenerationViaOpenRouter({ key: orKey, prompt, imageInputs, aspectRatio });
|
|
310
327
|
}
|
|
328
|
+
if (orAvailable && orVidModel) {
|
|
329
|
+
return await _startGenerationViaOpenRouterVideo({
|
|
330
|
+
key: orKey, prompt, imageInputs, firstFrame, aspectRatio, resolution, duration,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
311
333
|
|
|
312
334
|
const kieMap = kind === 'video' ? KIE_VIDEO_MODELS : KIE_IMAGE_MODELS;
|
|
313
335
|
const kieKey = modelKey || (kind === 'video' ? 'seedance-2' : 'nano-banana-2');
|
|
@@ -470,6 +492,48 @@ async function _startGenerationViaOpenRouter({ key, prompt, imageInputs, aspectR
|
|
|
470
492
|
return { taskId, provider: 'openrouter' };
|
|
471
493
|
}
|
|
472
494
|
|
|
495
|
+
// OpenRouter video API (отдельный POST /api/v1/videos).
|
|
496
|
+
// Response shape:
|
|
497
|
+
// POST {prompt, model, duration, aspect_ratio, resolution, image_input?}
|
|
498
|
+
// → { id, polling_url, status: 'pending' }
|
|
499
|
+
// GET /videos/<id> → status 'pending' | 'completed' с unsigned_urls
|
|
500
|
+
// taskId формата 'openrouter-vid:<openrouter-id>' — pollGeneration ниже
|
|
501
|
+
// делает GET к polling_url (требует Bearer), при completed возвращает
|
|
502
|
+
// первый из unsigned_urls (тоже с auth — клиент идёт через /api/proxy,
|
|
503
|
+
// proxy-handler в server.js добавит Authorization для openrouter.ai/...).
|
|
504
|
+
async function _startGenerationViaOpenRouterVideo({ key, prompt, imageInputs, firstFrame, aspectRatio, resolution, duration }) {
|
|
505
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
506
|
+
const model = OPENROUTER_VIDEO_MODELS[key];
|
|
507
|
+
if (!model) throw new Error(`OpenRouter не поддерживает video-модель "${key}"`);
|
|
508
|
+
const body = { prompt, model };
|
|
509
|
+
if (duration) body.duration = +duration;
|
|
510
|
+
if (aspectRatio) body.aspect_ratio = aspectRatio;
|
|
511
|
+
if (resolution) body.resolution = resolution;
|
|
512
|
+
// image-to-video: первый кадр или первая ref-картинка.
|
|
513
|
+
const startImage = firstFrame || (Array.isArray(imageInputs) && imageInputs[0]) || null;
|
|
514
|
+
if (startImage) body.image_input = startImage;
|
|
515
|
+
logCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/videos',
|
|
516
|
+
`model=${model} dur=${duration || '-'}s asp=${aspectRatio || '-'} res=${resolution || '-'}`);
|
|
517
|
+
const r = await fetch('https://openrouter.ai/api/v1/videos', {
|
|
518
|
+
method: 'POST',
|
|
519
|
+
headers: {
|
|
520
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
521
|
+
'Content-Type': 'application/json',
|
|
522
|
+
'HTTP-Referer': 'http://localhost',
|
|
523
|
+
'X-Title': 'KingKont',
|
|
524
|
+
},
|
|
525
|
+
body: JSON.stringify(body),
|
|
526
|
+
});
|
|
527
|
+
const text = await r.text();
|
|
528
|
+
let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
|
529
|
+
if (!r.ok) {
|
|
530
|
+
const baseMsg = data?.error?.message || data?.raw || `HTTP ${r.status}`;
|
|
531
|
+
throw new Error(`OpenRouter /videos: ${baseMsg}`);
|
|
532
|
+
}
|
|
533
|
+
if (!data.id) throw new Error(`OpenRouter не вернул id: ${text.slice(0, 200)}`);
|
|
534
|
+
return { taskId: 'openrouter-vid:' + data.id, provider: 'openrouter' };
|
|
535
|
+
}
|
|
536
|
+
|
|
473
537
|
// Внутренний helper: KIE-путь startGeneration. Вынесен чтобы не дублировать
|
|
474
538
|
// логику между «KIE первичный» и старым «KIE fallback».
|
|
475
539
|
async function _startGenerationViaKie({ kind, prompt, key, imageInputs, videoInputs, aspectRatio, resolution, duration, quality }) {
|
|
@@ -557,6 +621,32 @@ async function pollGeneration(taskId, settings) {
|
|
|
557
621
|
if (r.status === 'error') return { status: 'error', error: r.error, provider: 'openrouter' };
|
|
558
622
|
return { status: 'pending', state: 'generating', provider: 'openrouter' };
|
|
559
623
|
}
|
|
624
|
+
// OpenRouter video-gen — async, polling-API.
|
|
625
|
+
if (taskId.startsWith('openrouter-vid:')) {
|
|
626
|
+
const id = taskId.slice('openrouter-vid:'.length);
|
|
627
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
628
|
+
if (!apiKey) return { status: 'error', error: 'OPENROUTER_API_KEY не задан', provider: 'openrouter' };
|
|
629
|
+
try {
|
|
630
|
+
const r = await fetch(`https://openrouter.ai/api/v1/videos/${encodeURIComponent(id)}`, {
|
|
631
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
632
|
+
});
|
|
633
|
+
const text = await r.text();
|
|
634
|
+
let d; try { d = JSON.parse(text); } catch { d = {}; }
|
|
635
|
+
if (!r.ok) return { status: 'error', error: d?.error?.message || `HTTP ${r.status}`, provider: 'openrouter' };
|
|
636
|
+
if (d.status === 'completed') {
|
|
637
|
+
const url = Array.isArray(d.unsigned_urls) && d.unsigned_urls[0];
|
|
638
|
+
if (!url) return { status: 'error', error: 'OpenRouter video без unsigned_urls', provider: 'openrouter' };
|
|
639
|
+
const cost = d.usage?.cost ?? null;
|
|
640
|
+
return { status: 'done', url, cost, provider: 'openrouter' };
|
|
641
|
+
}
|
|
642
|
+
if (d.status === 'failed' || d.status === 'error') {
|
|
643
|
+
return { status: 'error', error: d.error || d.error?.message || 'failed', provider: 'openrouter' };
|
|
644
|
+
}
|
|
645
|
+
return { status: 'pending', state: d.status || 'generating', provider: 'openrouter' };
|
|
646
|
+
} catch (e) {
|
|
647
|
+
return { status: 'error', error: 'OpenRouter video poll: ' + (e?.message || e), provider: 'openrouter' };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
560
650
|
|
|
561
651
|
if (taskId.startsWith('chatium:')) {
|
|
562
652
|
const realId = taskId.slice('chatium:'.length);
|
package/main.js
CHANGED
|
@@ -15,6 +15,50 @@ const { start } = require('./server');
|
|
|
15
15
|
let win = null;
|
|
16
16
|
let port = null;
|
|
17
17
|
|
|
18
|
+
// ===== File-логгер для агента-помощника =====
|
|
19
|
+
// Все console.log/warn/error из main + renderer пишутся в один файл,
|
|
20
|
+
// который Claude (помощник разработки) может читать и анализировать.
|
|
21
|
+
// Юзер: «давай ты сам будешь анализировать логи. я использую electron».
|
|
22
|
+
const LOG_FILE_PATH = () => path.join(app.getPath('userData'), 'app.log');
|
|
23
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024; // 5MB → ротация в .log.1
|
|
24
|
+
function _rotateLogIfNeeded(p) {
|
|
25
|
+
try {
|
|
26
|
+
const st = fs.statSync(p);
|
|
27
|
+
if (st.size > LOG_MAX_BYTES) {
|
|
28
|
+
const bak = p + '.1';
|
|
29
|
+
try { fs.unlinkSync(bak); } catch {}
|
|
30
|
+
fs.renameSync(p, bak);
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
function appLog(prefix, ...args) {
|
|
35
|
+
const ts = new Date().toISOString();
|
|
36
|
+
const msg = args.map(a => {
|
|
37
|
+
if (a instanceof Error) return a.stack || a.message || String(a);
|
|
38
|
+
if (typeof a === 'string') return a;
|
|
39
|
+
try { return JSON.stringify(a); } catch { return String(a); }
|
|
40
|
+
}).join(' ');
|
|
41
|
+
const line = `${ts} [${prefix}] ${msg}\n`;
|
|
42
|
+
try {
|
|
43
|
+
const p = LOG_FILE_PATH();
|
|
44
|
+
_rotateLogIfNeeded(p);
|
|
45
|
+
fs.appendFileSync(p, line);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
// Hook console.* — origin'ы оставляем, чтобы видеть в stdout тоже.
|
|
49
|
+
const _origLog = console.log.bind(console);
|
|
50
|
+
const _origWarn = console.warn.bind(console);
|
|
51
|
+
const _origErr = console.error.bind(console);
|
|
52
|
+
console.log = (...a) => { _origLog(...a); appLog('main', ...a); };
|
|
53
|
+
console.warn = (...a) => { _origWarn(...a); appLog('warn', ...a); };
|
|
54
|
+
console.error = (...a) => { _origErr(...a); appLog('error', ...a); };
|
|
55
|
+
process.on('uncaughtException', e => appLog('uncaught', e));
|
|
56
|
+
process.on('unhandledRejection', e => appLog('unhandled', e));
|
|
57
|
+
// IPC из renderer'а — пишем под префиксом renderer:<level>.
|
|
58
|
+
ipcMain.on('app-log:write', (_evt, level, msg) => {
|
|
59
|
+
appLog('renderer:' + (level || 'log'), msg);
|
|
60
|
+
});
|
|
61
|
+
|
|
18
62
|
// ===== Native player (опциональный, питон-процесс с mpv) =====
|
|
19
63
|
let nativeProc = null;
|
|
20
64
|
let nativeWsUrl = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kingkont",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.17",
|
|
4
4
|
"description": "KingKont \u00b7 Chatium \u2014 \u043d\u043e\u0434-\u0440\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0441\u0446\u0435\u043d \u0441 AI-\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0435\u0439 (\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438/\u0432\u0438\u0434\u0435\u043e/\u0433\u043e\u043b\u043e\u0441/SFX/\u043c\u0443\u0437\u044b\u043a\u0430/\u0442\u0435\u043a\u0441\u0442)",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
package/preload.js
CHANGED
|
@@ -112,6 +112,12 @@ contextBridge.exposeInMainWorld('appPath', {
|
|
|
112
112
|
// Используется renderer'ом в режиме «облачного проекта» (без showDirectoryPicker).
|
|
113
113
|
// На веб-версии (без preload) — undefined; web-shell использует in-memory модель
|
|
114
114
|
// + сразу-на-сервер (см. renderer/cloudFs.js fallback).
|
|
115
|
+
// Renderer → main file-логгер. Используется для проброса console.* в файл,
|
|
116
|
+
// который агент-помощник (Claude) может читать. См. main.js appLog.
|
|
117
|
+
contextBridge.exposeInMainWorld('appLog', {
|
|
118
|
+
write: (level, msg) => ipcRenderer.send('app-log:write', level, msg),
|
|
119
|
+
});
|
|
120
|
+
|
|
115
121
|
contextBridge.exposeInMainWorld('cloudFs', {
|
|
116
122
|
ensureProject: (id) => ipcRenderer.invoke('cloudFs:ensureProject', id),
|
|
117
123
|
list: (id, rel) => ipcRenderer.invoke('cloudFs:list', id, rel),
|
package/renderer/board.js
CHANGED
|
@@ -256,6 +256,32 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|
|
256
256
|
e.stopPropagation();
|
|
257
257
|
showProjectContextMenu(e.clientX, e.clientY);
|
|
258
258
|
});
|
|
259
|
+
// Mobile-burger toggle: открыть/закрыть sidebar в template-просмотре
|
|
260
|
+
// на узких экранах. CSS показывает кнопку только в view-only-mode @media
|
|
261
|
+
// (max-width:768px). Клик добавляет/убирает body.sidebar-open → slide-in.
|
|
262
|
+
// Клик по backdrop'у (::before) тоже закрывает — wire'им через body click.
|
|
263
|
+
const burger = document.getElementById('sidebarToggle');
|
|
264
|
+
if (burger) {
|
|
265
|
+
burger.addEventListener('click', e => {
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
document.body.classList.toggle('sidebar-open');
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
document.body.addEventListener('click', e => {
|
|
271
|
+
if (!document.body.classList.contains('sidebar-open')) return;
|
|
272
|
+
// Клик в любом месте ВНЕ sidebar/toolbar — закрываем.
|
|
273
|
+
if (e.target.closest('.sidebar') || e.target.closest('.toolbar')) return;
|
|
274
|
+
document.body.classList.remove('sidebar-open');
|
|
275
|
+
});
|
|
276
|
+
// Клик по элементу sidebar-list (board-item) — закрываем шторку, чтобы
|
|
277
|
+
// юзер сразу увидел выбранную сцену.
|
|
278
|
+
document.querySelectorAll('.sidebar-list').forEach(list => {
|
|
279
|
+
list.addEventListener('click', e => {
|
|
280
|
+
if (e.target.closest('.item')) {
|
|
281
|
+
document.body.classList.remove('sidebar-open');
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
259
285
|
// Версия приложения на welcome-экране и в шапке проекта (после слова
|
|
260
286
|
// "KingKont"). appInfo.version() — IPC к main → app.getVersion().
|
|
261
287
|
// На веб-версии (без preload) — пропускаем, версия не показывается.
|
|
@@ -1643,18 +1669,17 @@ async function openShareModal(p) {
|
|
|
1643
1669
|
// В Electron location.origin === http://localhost:17893 — кому
|
|
1644
1670
|
// отправлять такую ссылку? Принимающий открывает в обычном браузере,
|
|
1645
1671
|
// там приложение живёт на kingkont.ru. В web используем origin.
|
|
1646
|
-
// Через /app/spaces/client/index.html (auth-aware stub) — он редиректит
|
|
1647
|
-
// на свежую версионированную /static/web-<sha>.html. /static/web.html
|
|
1648
|
-
// напрямую chatium edge-cache намертво держит stale, шарить эту ссылку
|
|
1649
|
-
// нельзя — получатель увидит старую версию.
|
|
1650
1672
|
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1)/.test(location.origin);
|
|
1651
1673
|
const base = isLocal ? 'https://kingkont.ru' : location.origin;
|
|
1652
|
-
return base + '/
|
|
1674
|
+
return base + '/static/web.html#template=' + proj.id;
|
|
1653
1675
|
}
|
|
1654
1676
|
function render() {
|
|
1655
1677
|
const isPub = !!proj.isPublic;
|
|
1656
1678
|
box.innerHTML = `
|
|
1657
|
-
<
|
|
1679
|
+
<div style="display:flex;align-items:center;gap:10px;margin:0 0 16px;">
|
|
1680
|
+
<h2 style="margin:0;font-size:18px;flex:1;">🤝 Расшарить «${escapeHtml(proj.name || p.name || '')}»</h2>
|
|
1681
|
+
<button id="shareGoto" title="Открыть проект как зритель (template-view)" style="padding:6px 12px;background:#2a3854;color:#aac8e6;border:1px solid #4a6a9a;border-radius:4px;cursor:pointer;font-size:12px;white-space:nowrap;">↗ Перейти</button>
|
|
1682
|
+
</div>
|
|
1658
1683
|
|
|
1659
1684
|
<div style="background:#1a1a1a;padding:14px;border-radius:6px;margin-bottom:16px;">
|
|
1660
1685
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px;">
|
|
@@ -1730,6 +1755,14 @@ async function openShareModal(p) {
|
|
|
1730
1755
|
pubT.disabled = false;
|
|
1731
1756
|
}
|
|
1732
1757
|
});
|
|
1758
|
+
// «Перейти» — открыть зрительскую template-ссылку в новой вкладке.
|
|
1759
|
+
// Полезно автору проверить как проект выглядит для получателя.
|
|
1760
|
+
box.querySelector('#shareGoto')?.addEventListener('click', () => {
|
|
1761
|
+
const url = templateUrl();
|
|
1762
|
+
// В Electron setWindowOpenHandler → shell.openExternal (системный
|
|
1763
|
+
// браузер). В web — обычная новая вкладка.
|
|
1764
|
+
window.open(url, '_blank', 'noopener');
|
|
1765
|
+
});
|
|
1733
1766
|
// Copy URL
|
|
1734
1767
|
box.querySelector('#pubCopy')?.addEventListener('click', async () => {
|
|
1735
1768
|
const inp = box.querySelector('#pubUrl');
|
|
@@ -2946,12 +2979,18 @@ function askName(title, placeholder = '', initialValue = '', opts = {}) {
|
|
|
2946
2979
|
const h = document.createElement('h3');
|
|
2947
2980
|
h.textContent = title;
|
|
2948
2981
|
h.style.cssText = 'margin:0 0 12px; font-size:14px; color:#e0e0e0;';
|
|
2949
|
-
|
|
2950
|
-
inp
|
|
2982
|
+
// multiline=true → textarea вместо input (для длинных описаний).
|
|
2983
|
+
const inp = document.createElement(opts.multiline ? 'textarea' : 'input');
|
|
2984
|
+
if (!opts.multiline) inp.type = 'text';
|
|
2951
2985
|
inp.placeholder = placeholder;
|
|
2952
2986
|
if (initialValue)
|
|
2953
2987
|
inp.value = initialValue;
|
|
2954
|
-
|
|
2988
|
+
if (opts.multiline) {
|
|
2989
|
+
inp.rows = 6;
|
|
2990
|
+
inp.style.cssText = 'width:100%; padding:10px; background:#1a1a1a; color:#e0e0e0; border:1px solid #444; border-radius:4px; font-size:13px; line-height:1.5; resize:vertical; min-height:120px; margin-bottom:14px; font-family:inherit;';
|
|
2991
|
+
} else {
|
|
2992
|
+
inp.style.cssText = 'width:100%; padding:8px 10px; background:#1a1a1a; color:#e0e0e0; border:1px solid #444; border-radius:4px; font-size:14px; margin-bottom:14px;';
|
|
2993
|
+
}
|
|
2955
2994
|
const row = document.createElement('div');
|
|
2956
2995
|
row.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
|
|
2957
2996
|
const cancel = document.createElement('button');
|
|
@@ -2976,6 +3015,8 @@ function askName(title, placeholder = '', initialValue = '', opts = {}) {
|
|
|
2976
3015
|
ok.addEventListener('click', () => close(inp.value.trim()));
|
|
2977
3016
|
inp.addEventListener('keydown', e => {
|
|
2978
3017
|
if (e.key === 'Enter') {
|
|
3018
|
+
// В textarea Enter добавляет перенос; Cmd/Ctrl+Enter — submit.
|
|
3019
|
+
if (opts.multiline && !(e.metaKey || e.ctrlKey)) return;
|
|
2979
3020
|
e.preventDefault();
|
|
2980
3021
|
close(inp.value.trim());
|
|
2981
3022
|
}
|
|
@@ -3154,14 +3195,16 @@ $('newLocation').addEventListener('click', async () => {
|
|
|
3154
3195
|
// handle резолвится в момент перехода (через listEpisodes/listCharacters
|
|
3155
3196
|
// /listLocations), хранить нельзя — FSAH-handle не сериализуется в JSON.
|
|
3156
3197
|
function refreshNodeLinkBadge(el, node) {
|
|
3157
|
-
const
|
|
3158
|
-
|
|
3159
|
-
if (
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
}
|
|
3163
|
-
|
|
3164
|
-
|
|
3198
|
+
const goBtn = el.querySelector('.link-go-btn');
|
|
3199
|
+
const has = !!node.linkedBoard?.name;
|
|
3200
|
+
if (goBtn) {
|
|
3201
|
+
goBtn.style.display = has ? '' : 'none';
|
|
3202
|
+
if (has) goBtn.textContent = `Перейти → ${node.linkedBoard.name}`;
|
|
3203
|
+
}
|
|
3204
|
+
// Legacy: удаляем .link-badge из старых нод (если она была вставлена
|
|
3205
|
+
// прежней версией кода). Юзер просил убрать иконку из угла.
|
|
3206
|
+
const legacy = el.querySelector('.link-badge');
|
|
3207
|
+
if (legacy) legacy.remove();
|
|
3165
3208
|
}
|
|
3166
3209
|
async function _resolveLinkedBoardHandle(target) {
|
|
3167
3210
|
if (!state.filmHandle || !target?.name) return null;
|
|
@@ -3380,26 +3423,55 @@ async function selectBoard(board) {
|
|
|
3380
3423
|
const z = state.zoom || 1;
|
|
3381
3424
|
document.querySelector('.canvas-frame').style.width = (6000 * z + 2 * padX) + 'px';
|
|
3382
3425
|
document.querySelector('.canvas-frame').style.height = (4000 * z + 2 * padY) + 'px';
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3426
|
+
// Восстановление scroll-позиции:
|
|
3427
|
+
// 1) Сохранённый view (юзер сам прокрутил и сохранил) → восстанавливаем.
|
|
3428
|
+
// 2) Иначе — скроллим к верхней-левой ноде доски (с 20px gap).
|
|
3429
|
+
// Юзер: «любую сцену для которой нет сохранённой позиции —
|
|
3430
|
+
// открывать с верхнего-левого угла». Покрывает и view-only
|
|
3431
|
+
// template-link, и любые свежесозданные сцены без view.
|
|
3432
|
+
// 3) Если нод нет — fallback на канвас-origin (padX, padY).
|
|
3433
|
+
// Хелпер: scroll к верхней-левой ноде доски (с 20px gap).
|
|
3434
|
+
const scrollToTopLeftNode = () => {
|
|
3435
|
+
const nodes = state.currentBoard.metadata?.nodes || [];
|
|
3436
|
+
if (nodes.length) {
|
|
3437
|
+
const minX = Math.min(...nodes.map(n => n.x ?? 0));
|
|
3438
|
+
const minY = Math.min(...nodes.map(n => n.y ?? 0));
|
|
3439
|
+
const z = state.zoom || 1;
|
|
3440
|
+
canvasWrap.scrollLeft = (minX - 20) * z + padX;
|
|
3441
|
+
canvasWrap.scrollTop = (minY - 20) * z + padY;
|
|
3442
|
+
} else {
|
|
3389
3443
|
canvasWrap.scrollLeft = padX;
|
|
3390
|
-
|
|
3391
|
-
canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
|
|
3444
|
+
canvasWrap.scrollTop = padY;
|
|
3392
3445
|
}
|
|
3393
|
-
|
|
3394
|
-
|
|
3446
|
+
};
|
|
3447
|
+
// В view-only / #template= ВСЕГДА скроллим к top-left, независимо от
|
|
3448
|
+
// сохранённого автором view. Юзер: «при показе template любую сцену
|
|
3449
|
+
// нужно грузить с верхнего левого угла».
|
|
3450
|
+
const isViewOnly = document.body.classList.contains('view-only-mode')
|
|
3451
|
+
|| /^#template=/.test(location.hash || '');
|
|
3452
|
+
const hasSavedView = view && (typeof view.scrollLeft === 'number' || typeof view.scrollTop === 'number');
|
|
3453
|
+
if (isViewOnly) {
|
|
3454
|
+
scrollToTopLeftNode();
|
|
3455
|
+
} else if (hasSavedView) {
|
|
3456
|
+
// view.scrollLeft/Top — это снэпшот canvasWrap.scrollLeft/Top на
|
|
3457
|
+
// момент сохранения, в координатах viewport'а (с учётом padX/padY).
|
|
3458
|
+
// Раньше тут была heuristic-compat: `value >= padX ? value : value+padX`
|
|
3459
|
+
// — предполагалось что старые сохранения без padding'а маленькие.
|
|
3460
|
+
// Но юзер может валидно скроллить В padding-зону (negative canvas-
|
|
3461
|
+
// coords) → новый save имеет scrollLeft < padX → heuristic считал
|
|
3462
|
+
// это legacy и добавлял padX → восстановление в неправильное место.
|
|
3463
|
+
// Юзер: «сохранение не происходит когда мы сдвигаемся в область
|
|
3464
|
+
// отрицательных значений». Теперь восстанавливаем как-есть.
|
|
3465
|
+
if (typeof view.scrollLeft === 'number') canvasWrap.scrollLeft = view.scrollLeft;
|
|
3466
|
+
else canvasWrap.scrollLeft = padX;
|
|
3467
|
+
if (typeof view.scrollTop === 'number') canvasWrap.scrollTop = view.scrollTop;
|
|
3468
|
+
else canvasWrap.scrollTop = padY;
|
|
3395
3469
|
}
|
|
3396
3470
|
else {
|
|
3397
|
-
|
|
3398
|
-
canvasWrap.scrollTop = padY;
|
|
3471
|
+
scrollToTopLeftNode();
|
|
3399
3472
|
}
|
|
3400
|
-
// Auto-scroll к bbox нод если ни одна не попадает в viewport
|
|
3401
|
-
//
|
|
3402
|
-
// в next frame _autoScrollToNodesIfHidden имеет валидную картинку.
|
|
3473
|
+
// Auto-scroll к bbox нод если ни одна не попадает в viewport
|
|
3474
|
+
// (защита для legacy-проектов с сохранённым view далеко от нод).
|
|
3403
3475
|
requestAnimationFrame(() => _autoScrollToNodesIfHidden(padX, padY));
|
|
3404
3476
|
// Возобновить незавершённые джобы текущей доски
|
|
3405
3477
|
for (const n of state.currentBoard.metadata.nodes) {
|
|
@@ -3837,16 +3909,20 @@ async function createNodeEl(node) {
|
|
|
3837
3909
|
el.appendChild(anchor);
|
|
3838
3910
|
attachAnchor(node, el, anchor);
|
|
3839
3911
|
attachDrag(el, node);
|
|
3840
|
-
//
|
|
3841
|
-
//
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3912
|
+
// Большая явная кнопка «Перейти →» снизу ноды (юзер: «когда есть ссылка
|
|
3913
|
+
// на сцену нужна явная кнопка прям снизу ноды, чуть выше нижней границы»).
|
|
3914
|
+
// Видна только когда linkedBoard.name есть — refreshNodeLinkBadge ниже.
|
|
3915
|
+
// Маленького 🔗-badge в углу нет — юзер: «иконку ссылки сверху-справа
|
|
3916
|
+
// нужно убрать», кнопка снизу — достаточно явный индикатор.
|
|
3917
|
+
const linkGo = document.createElement('button');
|
|
3918
|
+
linkGo.className = 'link-go-btn';
|
|
3919
|
+
linkGo.type = 'button';
|
|
3920
|
+
el.appendChild(linkGo);
|
|
3921
|
+
linkGo.addEventListener('click', e => {
|
|
3847
3922
|
e.stopPropagation();
|
|
3848
3923
|
if (node.linkedBoard?.name) goToLinkedBoard(node);
|
|
3849
3924
|
});
|
|
3925
|
+
linkGo.addEventListener('mousedown', e => e.stopPropagation());
|
|
3850
3926
|
refreshNodeLinkBadge(el, node);
|
|
3851
3927
|
el.addEventListener('dblclick', e => {
|
|
3852
3928
|
if (e.target.closest('textarea, input, video, button, .delete, .anchor, .resize-handle, [contenteditable]'))
|
|
@@ -3883,6 +3959,11 @@ async function createNodeEl(node) {
|
|
|
3883
3959
|
e.stopPropagation();
|
|
3884
3960
|
showNodeContextMenu(node, e.clientX, e.clientY);
|
|
3885
3961
|
});
|
|
3962
|
+
// Sidecar-описание для image/video — отдельный элемент на canvas'е
|
|
3963
|
+
// (ensureNodeDescriptionEl идемпотентно создаёт/обновляет/удаляет).
|
|
3964
|
+
// Вызывается ЗДЕСЬ, чтобы покрыть все вызовы createNodeEl: renderCanvas
|
|
3965
|
+
// / generate.js (новые ноды) / timeline.js / drawings.js / chat.js.
|
|
3966
|
+
if (typeof ensureNodeDescriptionEl === 'function') ensureNodeDescriptionEl(node);
|
|
3886
3967
|
return el;
|
|
3887
3968
|
}
|
|
3888
3969
|
// =================== Контекстное меню ноды (ПКМ) ===================
|
|
@@ -4027,6 +4108,28 @@ function showNodeContextMenu(node, clientX, clientY) {
|
|
|
4027
4108
|
if (node.type === 'image' || node.type === 'video' || node.type === 'audio') {
|
|
4028
4109
|
add('➕ В таймлайн', () => addToTimeline(node));
|
|
4029
4110
|
}
|
|
4111
|
+
// Описание ноды: текст-аннотация привязанная к медиа (вместо отдельной
|
|
4112
|
+
// text-ноды). Юзер: «чтобы вместо текстовой ноды можно было использовать
|
|
4113
|
+
// описание, приложенное к картинке». Inline-textarea под медиа уже есть
|
|
4114
|
+
// (см. renderNodeBody), это удобный picker для крупного редактирования.
|
|
4115
|
+
if (node.type === 'image' || node.type === 'video') {
|
|
4116
|
+
const hasDesc = !!(node.description && node.description.trim());
|
|
4117
|
+
add(hasDesc ? '📝 Изменить описание…' : '📝 Добавить описание…', async () => {
|
|
4118
|
+
const next = await askName(
|
|
4119
|
+
'Описание ноды (Cmd+Enter — сохранить):',
|
|
4120
|
+
'Например: лес ночью, луна за облаками, тишина',
|
|
4121
|
+
node.description || '',
|
|
4122
|
+
{ multiline: true, okText: 'Сохранить' },
|
|
4123
|
+
);
|
|
4124
|
+
if (next == null) return;
|
|
4125
|
+
node.description = next;
|
|
4126
|
+
scheduleSave();
|
|
4127
|
+
// Sidecar-описание — отдельный элемент на canvas'е, см.
|
|
4128
|
+
// ensureNodeDescriptionEl в settings.js. Идемпотентно: пересоздаёт/
|
|
4129
|
+
// удаляет/обновляет в зависимости от того, есть ли текст.
|
|
4130
|
+
if (typeof ensureNodeDescriptionEl === 'function') ensureNodeDescriptionEl(node);
|
|
4131
|
+
});
|
|
4132
|
+
}
|
|
4030
4133
|
// Image-нода как обложка проекта — копируется в `<project>/.cover.<ext>`,
|
|
4031
4134
|
// оттуда подхватывается generateProjectThumb (для recents и шаблонов).
|
|
4032
4135
|
if (node.type === 'image' && node.file && state.filmHandle) {
|
|
@@ -367,6 +367,12 @@
|
|
|
367
367
|
await _downloadAllTexts(projectId, bgBoards).catch(() => {});
|
|
368
368
|
// Перечитываем тексты ТЕКУЩЕЙ доски (другие — при switchBoard).
|
|
369
369
|
await _refreshCurrentBoardTexts().catch(() => {});
|
|
370
|
+
// Проверяем — изменился ли проект на сервере с момента нашего
|
|
371
|
+
// последнего sync'а. Юзер: «когда заходим в облачный проект —
|
|
372
|
+
// в фоне проверяем есть ли изменения на сервере; если есть —
|
|
373
|
+
// показываем модалку Проект изменён на сервере. Загрузить?».
|
|
374
|
+
// Сравниваем server.updatedAt vs local meta.syncedAt.
|
|
375
|
+
await _maybeOfferReloadFromServer(projectId, p, meta).catch(() => {});
|
|
370
376
|
}).catch(() => {});
|
|
371
377
|
return;
|
|
372
378
|
}
|
|
@@ -526,6 +532,44 @@
|
|
|
526
532
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
527
533
|
}
|
|
528
534
|
|
|
535
|
+
// Проверка: проект изменился на сервере с момента нашего последнего sync'а?
|
|
536
|
+
// Если да — показываем модалку «Загрузить свежую версию?».
|
|
537
|
+
// • Только Electron (window.cloudFs) — в вебе локальной копии нет,
|
|
538
|
+
// все данные всегда свежие с сервера, проверка бессмысленна.
|
|
539
|
+
// • Только edit-режим (canModify=true) — в template/view-only юзер
|
|
540
|
+
// просматривает чужой проект, его не должно дёргать предложение
|
|
541
|
+
// «загрузить свежую версию» (нечего перезаписывать локально).
|
|
542
|
+
async function _maybeOfferReloadFromServer(projectId, serverProj, localMeta) {
|
|
543
|
+
// ВРЕМЕННО ОТКЛЮЧЕНО. Юзер: «отключи проверку 'что что-то изменено',
|
|
544
|
+
// а то она сейчас неправильно работает в электроне». Причины ложных
|
|
545
|
+
// срабатываний (предположительно): localMeta.syncedAt не обновляется
|
|
546
|
+
// надёжно после save'а из Electron'а → server.updatedAt всегда выглядит
|
|
547
|
+
// «новее», и юзера дёргает модалка после каждого открытия проекта.
|
|
548
|
+
// Нужно сначала разобраться с syncedAt-bookkeeping, потом включать.
|
|
549
|
+
return;
|
|
550
|
+
// eslint-disable-next-line no-unreachable
|
|
551
|
+
if (!window.cloudFs) return; // только Electron
|
|
552
|
+
if (!serverProj?.canModify) return; // только edit-mode (не template)
|
|
553
|
+
if (!state.cloudProjectId || state.cloudProjectId !== projectId) return;
|
|
554
|
+
const serverUpdatedAt = +serverProj?.updatedAt || 0;
|
|
555
|
+
const localSyncedAt = +localMeta?.syncedAt || 0;
|
|
556
|
+
// 5-секундный grace — на случай небольшого drift'а часов между нашим
|
|
557
|
+
// save'ом и server-side updatedAt (записывается БД при апдейте).
|
|
558
|
+
if (serverUpdatedAt <= localSyncedAt + 5000) return;
|
|
559
|
+
// Уже спрашивали для этого ts — не докучаем повторно.
|
|
560
|
+
if (state._cloudReloadAskedAt === serverUpdatedAt) return;
|
|
561
|
+
state._cloudReloadAskedAt = serverUpdatedAt;
|
|
562
|
+
const dirtyWarn = state.cloudDirty
|
|
563
|
+
? '\n\n⚠ У вас есть локальные несохранённые изменения — они потеряются.'
|
|
564
|
+
: '';
|
|
565
|
+
const ok = confirm(
|
|
566
|
+
`Проект изменён на сервере (после вашего последнего открытия).\n` +
|
|
567
|
+
`Загрузить свежую версию?${dirtyWarn}`
|
|
568
|
+
);
|
|
569
|
+
if (!ok) return;
|
|
570
|
+
await openCloudProject(projectId, serverProj?.name, { forceRefresh: true });
|
|
571
|
+
}
|
|
572
|
+
|
|
529
573
|
// Перечитать .md-контент text-нод текущей доски из FS и обновить DOM.
|
|
530
574
|
// Используется после фонового _downloadAllTexts — если тексты пришли
|
|
531
575
|
// ПОСЛЕ openFilm, ноды показывают пустые textarea, пока этот хук не
|
|
@@ -955,6 +999,27 @@
|
|
|
955
999
|
}
|
|
956
1000
|
|
|
957
1001
|
// 3) Update server record.
|
|
1002
|
+
// Лог для диагностики (юзер: «откуда там 8 файлов? на экране 4»):
|
|
1003
|
+
// считает разрыв между тем что на доске визуально и что отправляется.
|
|
1004
|
+
const _binFilesCount = Object.keys(allFilesIndex).length;
|
|
1005
|
+
const _textsByBoard = manifestBoards.map(b => ({
|
|
1006
|
+
board: `${b.kind}/${b.name}`,
|
|
1007
|
+
files: Object.keys(b.files || {}),
|
|
1008
|
+
texts: Object.keys(b.texts || {}),
|
|
1009
|
+
nodes: (b.scene?.nodes || []).length,
|
|
1010
|
+
nodeTypes: (b.scene?.nodes || []).reduce((acc, n) => {
|
|
1011
|
+
acc[n.type] = (acc[n.type] || 0) + 1; return acc;
|
|
1012
|
+
}, {}),
|
|
1013
|
+
}));
|
|
1014
|
+
console.log('[cloudProjects] save → chatium:', {
|
|
1015
|
+
projectId,
|
|
1016
|
+
binFilesTotal: _binFilesCount,
|
|
1017
|
+
textsTotal: manifestBoards.reduce((s, b) => s + Object.keys(b.texts || {}).length, 0),
|
|
1018
|
+
uploadedCount: uploaded,
|
|
1019
|
+
reusedCount: reused,
|
|
1020
|
+
boards: _textsByBoard,
|
|
1021
|
+
allFilesKeys: Object.keys(allFilesIndex),
|
|
1022
|
+
});
|
|
958
1023
|
PROGRESS.update(uploaded, total, 'Сохранение на сервере…');
|
|
959
1024
|
const updateR = await fetch('/api/projects/' + encodeURIComponent(projectId), {
|
|
960
1025
|
method: 'POST',
|
package/renderer/drawings.js
CHANGED
|
@@ -130,7 +130,10 @@
|
|
|
130
130
|
svg.setAttribute('width', '6000');
|
|
131
131
|
svg.setAttribute('height', '4000');
|
|
132
132
|
svg.setAttribute('class', 'drawing-preview');
|
|
133
|
-
|
|
133
|
+
// overflow:visible — иначе SVG клипит preview-path в отрицательной зоне
|
|
134
|
+
// (когда юзер рисует в canvas-frame-padding). _canvasCoord возвращает
|
|
135
|
+
// отрицательные x/y; без overflow:visible эти точки не отрисуются.
|
|
136
|
+
svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:1000;overflow:visible;';
|
|
134
137
|
c.appendChild(svg);
|
|
135
138
|
return svg;
|
|
136
139
|
}
|
|
@@ -249,8 +252,17 @@
|
|
|
249
252
|
window.boardDrawings = { setTool, renderInto, smoothPath, arrowHead, toolStyle };
|
|
250
253
|
|
|
251
254
|
document.addEventListener('DOMContentLoaded', () => {
|
|
252
|
-
|
|
253
|
-
|
|
255
|
+
// Слушаем mousedown НА canvas-frame, а не на .canvas. canvas-frame
|
|
256
|
+
// имеет padding (padX/padY) вокруг .canvas — это «отрицательная зона»
|
|
257
|
+
// (canvas-coord < 0 или > 6000), которую юзер видит и в которой хочет
|
|
258
|
+
// рисовать. mousedown на .canvas не срабатывает за пределами 6000×4000,
|
|
259
|
+
// даже если внутри padding. Юзер: «рисовать в отрицательной области
|
|
260
|
+
// тоже не получается». _canvasCoord возвращает координаты от .canvas-
|
|
261
|
+
// bounding-rect — значения становятся отрицательными для кликов слева/
|
|
262
|
+
// сверху от canvas. minX/minY в bbox-расчёте это переживают (нода с
|
|
263
|
+
// x<0 валидна, см. drag-handler без Math.max(0,...) клампа).
|
|
264
|
+
const frame = document.getElementById('canvasFrame');
|
|
265
|
+
if (frame) frame.addEventListener('mousedown', _onMouseDown, true);
|
|
254
266
|
document.addEventListener('keydown', _onKeyDown);
|
|
255
267
|
document.querySelectorAll('[data-draw-tool]').forEach(b => {
|
|
256
268
|
b.addEventListener('click', () => setTool(b.dataset.drawTool));
|
package/renderer/generate.js
CHANGED
|
@@ -1974,9 +1974,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1974
1974
|
// Так per-board настройка перебивает глобальный фоллбэк.
|
|
1975
1975
|
const boardAspect = state.currentBoard?.metadata?.settings?.aspectRatio || null;
|
|
1976
1976
|
if (kind === 'video') {
|
|
1977
|
-
|
|
1977
|
+
// Явное приведение к Number — иначе если state.videoDuration хранится
|
|
1978
|
+
// строкой ("8") chatium-side ALLOWED_PARAMS пропустит, но Replicate
|
|
1979
|
+
// может молча проигнорировать и взять дефолт.
|
|
1980
|
+
submitBody.duration = +(node.generated?.duration ?? state.videoDuration) || 5;
|
|
1978
1981
|
submitBody.resolution = node.generated?.resolution ?? state.videoResolution;
|
|
1979
1982
|
submitBody.aspectRatio = node.generated?.aspectRatio ?? boardAspect ?? state.videoAspect;
|
|
1983
|
+
logJob(node.id, `video params: duration=${submitBody.duration}s resolution=${submitBody.resolution} aspect=${submitBody.aspectRatio} model=${modelKey}`);
|
|
1980
1984
|
} else if (kind === 'image') {
|
|
1981
1985
|
// Grok-imagine требует aspect_ratio из {2:3, 3:2, 1:1, 9:16, 16:9}.
|
|
1982
1986
|
// Остальные модели (nano-banana-2, seedream и др.) тоже принимают.
|
package/renderer/media.js
CHANGED
|
@@ -94,22 +94,117 @@ $('zoomIn').addEventListener('click', () => applyZoom(state.zoom * 1.25));
|
|
|
94
94
|
$('zoomOut').addEventListener('click', () => applyZoom(state.zoom / 1.25));
|
|
95
95
|
$('zoomLabel').addEventListener('click', () => applyZoom(1));
|
|
96
96
|
|
|
97
|
-
//
|
|
97
|
+
// =================== Pinch-zoom (touch) ===================
|
|
98
|
+
// Мобильный/таблет: pinch двумя пальцами → zoom вокруг midpoint двух тачей.
|
|
99
|
+
// Браузерный native pinch-zoom (visualViewport) масштабирует ВЕСЬ layout
|
|
100
|
+
// и ломает координаты — поэтому touch-action: pan-x pan-y отключает его в
|
|
101
|
+
// view-only-mode (см. styles.css), а мы делаем canvas-zoom вручную через
|
|
102
|
+
// applyZoom() с координатным якорем посередине pinch'а.
|
|
103
|
+
// Якорь = midpoint двух touch'ей в client-coords (как у wheel-ctrl-zoom).
|
|
104
|
+
// Ratio = currentDist / startDist → applyZoom(startZoom * ratio, midX, midY).
|
|
105
|
+
let _pinchStartDist = 0;
|
|
106
|
+
let _pinchStartZoom = 1;
|
|
107
|
+
let _pinchActive = false;
|
|
108
|
+
let _pinchRaf = null;
|
|
109
|
+
let _pinchPendingMid = null;
|
|
110
|
+
function _touchDist(t1, t2) {
|
|
111
|
+
const dx = t2.clientX - t1.clientX;
|
|
112
|
+
const dy = t2.clientY - t1.clientY;
|
|
113
|
+
return Math.hypot(dx, dy);
|
|
114
|
+
}
|
|
115
|
+
function _touchMid(t1, t2) {
|
|
116
|
+
return { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
117
|
+
}
|
|
118
|
+
canvasWrap.addEventListener('touchstart', e => {
|
|
119
|
+
if (e.touches.length === 2) {
|
|
120
|
+
_pinchActive = true;
|
|
121
|
+
_pinchStartDist = _touchDist(e.touches[0], e.touches[1]);
|
|
122
|
+
_pinchStartZoom = state.zoom;
|
|
123
|
+
// Предотвращаем native pinch только если у нас 2-touch жест.
|
|
124
|
+
// 1-touch (pan) пропускаем — native scroll работает.
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
}
|
|
127
|
+
}, { passive: false });
|
|
128
|
+
canvasWrap.addEventListener('touchmove', e => {
|
|
129
|
+
if (!_pinchActive || e.touches.length !== 2) return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const dist = _touchDist(e.touches[0], e.touches[1]);
|
|
132
|
+
const mid = _touchMid(e.touches[0], e.touches[1]);
|
|
133
|
+
if (_pinchStartDist <= 0) return;
|
|
134
|
+
const ratio = dist / _pinchStartDist;
|
|
135
|
+
_pinchPendingMid = { mid, target: _pinchStartZoom * ratio };
|
|
136
|
+
if (_pinchRaf) return;
|
|
137
|
+
_pinchRaf = requestAnimationFrame(() => {
|
|
138
|
+
_pinchRaf = null;
|
|
139
|
+
const p = _pinchPendingMid;
|
|
140
|
+
if (!p) return;
|
|
141
|
+
applyZoom(p.target, p.mid.x, p.mid.y);
|
|
142
|
+
});
|
|
143
|
+
}, { passive: false });
|
|
144
|
+
function _endPinch() {
|
|
145
|
+
_pinchActive = false;
|
|
146
|
+
_pinchStartDist = 0;
|
|
147
|
+
if (_pinchRaf) { cancelAnimationFrame(_pinchRaf); _pinchRaf = null; }
|
|
148
|
+
_pinchPendingMid = null;
|
|
149
|
+
}
|
|
150
|
+
canvasWrap.addEventListener('touchend', e => {
|
|
151
|
+
if (e.touches.length < 2) _endPinch();
|
|
152
|
+
});
|
|
153
|
+
canvasWrap.addEventListener('touchcancel', _endPinch);
|
|
154
|
+
|
|
155
|
+
// Сохранение pan/zoom для текущей доски.
|
|
156
|
+
// Раньше 400ms debounce: scroll → 400ms wait → set view → scheduleSave 300ms
|
|
157
|
+
// → write. Юзер закрывал таб быстрее → save не успевал → позиция терялась.
|
|
158
|
+
// Теперь 100ms debounce + явный flush на pagehide/beforeunload.
|
|
98
159
|
let viewSaveTimer = null;
|
|
160
|
+
function _updateViewFromScroll() {
|
|
161
|
+
if (!state.currentBoard) return;
|
|
162
|
+
state.currentBoard.metadata.view = {
|
|
163
|
+
scrollLeft: canvasWrap.scrollLeft,
|
|
164
|
+
scrollTop: canvasWrap.scrollTop,
|
|
165
|
+
zoom: state.zoom,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
99
168
|
function scheduleViewSave() {
|
|
100
169
|
if (!state.currentBoard) return;
|
|
101
170
|
clearTimeout(viewSaveTimer);
|
|
102
171
|
viewSaveTimer = setTimeout(() => {
|
|
103
|
-
|
|
104
|
-
state.currentBoard.metadata.view = {
|
|
105
|
-
scrollLeft: canvasWrap.scrollLeft,
|
|
106
|
-
scrollTop: canvasWrap.scrollTop,
|
|
107
|
-
zoom: state.zoom,
|
|
108
|
-
};
|
|
172
|
+
_updateViewFromScroll();
|
|
109
173
|
scheduleSave();
|
|
110
|
-
},
|
|
174
|
+
}, 100);
|
|
111
175
|
}
|
|
112
176
|
canvasWrap.addEventListener('scroll', scheduleViewSave);
|
|
177
|
+
// На выгрузке страницы: немедленно перенесём scroll в metadata + flush
|
|
178
|
+
// save СРАЗУ (минуя debounce). Без этого юзер скроллил, закрывал таб через
|
|
179
|
+
// 200мс — viewSaveTimer не успевал отстреляться. Используем 'pagehide'
|
|
180
|
+
// (срабатывает на close-tab/navigate AND back-forward-cache), плюс
|
|
181
|
+
// 'visibilitychange' с 'hidden' (более надёжно на mobile/system-sleep).
|
|
182
|
+
function _flushViewSave() {
|
|
183
|
+
if (!state.currentBoard) return;
|
|
184
|
+
clearTimeout(viewSaveTimer);
|
|
185
|
+
_updateViewFromScroll();
|
|
186
|
+
// Прямой синхронный вызов сохранения вместо scheduleSave (которое
|
|
187
|
+
// ставит ещё один 300ms timer и тоже может не успеть на unload).
|
|
188
|
+
clearTimeout(saveTimer); saveTimer = null;
|
|
189
|
+
_runSaveNow().catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
window.addEventListener('pagehide', _flushViewSave);
|
|
192
|
+
window.addEventListener('beforeunload', _flushViewSave);
|
|
193
|
+
document.addEventListener('visibilitychange', () => {
|
|
194
|
+
if (document.visibilityState === 'hidden') _flushViewSave();
|
|
195
|
+
});
|
|
196
|
+
// Глобальный flush — зовётся из closeProject ПЕРЕД обнулением state.currentBoard.
|
|
197
|
+
// Без этого pending viewSaveTimer/saveTimer срабатывали уже после того как
|
|
198
|
+
// state.currentBoard стал null → write скипался → позиция терялась.
|
|
199
|
+
// (closeProject уже вызывал `window.flushScheduledSave` через typeof-guard,
|
|
200
|
+
// но функция нигде не была определена — guard скрывал баг.)
|
|
201
|
+
window.flushScheduledSave = async function () {
|
|
202
|
+
if (!state.currentBoard) return;
|
|
203
|
+
clearTimeout(viewSaveTimer);
|
|
204
|
+
clearTimeout(saveTimer); saveTimer = null;
|
|
205
|
+
_updateViewFromScroll();
|
|
206
|
+
await _runSaveNow();
|
|
207
|
+
};
|
|
113
208
|
|
|
114
209
|
// =================== Скачивание аудио на выбранной скорости (ffmpeg.wasm atempo) ===================
|
|
115
210
|
let ffmpegInstance = null;
|
package/renderer/settings.js
CHANGED
|
@@ -552,23 +552,15 @@ async function renderNodeBody(node, body) {
|
|
|
552
552
|
playRow.appendChild(playBtn);
|
|
553
553
|
body.appendChild(playRow);
|
|
554
554
|
}
|
|
555
|
-
// Описание (опц.) —
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
desc.rows = 1;
|
|
565
|
-
desc.addEventListener('mousedown', e => e.stopPropagation());
|
|
566
|
-
desc.addEventListener('input', () => {
|
|
567
|
-
node.description = desc.value;
|
|
568
|
-
scheduleSave();
|
|
569
|
-
});
|
|
570
|
-
body.appendChild(desc);
|
|
571
|
-
}
|
|
555
|
+
// Описание (опц.) — read-only текст под медиа. Редактирование через
|
|
556
|
+
// ПКМ «📝 Изменить описание…» (открывает большую textarea-модалку).
|
|
557
|
+
// ВАЖНО: рендерится НЕ внутри .node (image/video задают фиксированный
|
|
558
|
+
// aspect-ratio высоты, всё ниже обрезается content-visibility:auto
|
|
559
|
+
// и paint-containment). Юзер: «давай попробуем показать описание
|
|
560
|
+
// ВНЕ этой ноды, но так чтобы оно с ней перемещалось». Описание —
|
|
561
|
+
// отдельный sibling-элемент `.node-description-outside` на canvas'е,
|
|
562
|
+
// позиционируется в (node.x, node.y+node.height+4), width=node.width.
|
|
563
|
+
// Перемещается через ensureNodeDescriptionEl (вызывается из drag/resize).
|
|
572
564
|
} else if (node.type === 'image' && !node.file && (node.generated?.rawPrompt || node.generated?.prompt)) {
|
|
573
565
|
// Image-нода с промптом, но без файла (ещё не генерилась).
|
|
574
566
|
// Кнопка ▶ Запустить — сверху (без декоративной 🖼-иконки и
|
|
@@ -589,6 +581,48 @@ async function renderNodeBody(node, body) {
|
|
|
589
581
|
}
|
|
590
582
|
}
|
|
591
583
|
|
|
584
|
+
// Описание image/video-ноды отрисовывается ВНЕ ноды (sibling на canvas'е).
|
|
585
|
+
// Раньше пробовали инлайн внутри .node — но image/video задают высоту через
|
|
586
|
+
// aspect-ratio + paint-containment у .node клипит всё что вне bbox. Юзер:
|
|
587
|
+
// «попробуем показать описание ВНЕ этой ноды, но так чтобы оно с ней
|
|
588
|
+
// перемещалось… совпадало с ней по ширине, а высота — сколько займет текст».
|
|
589
|
+
// Идемпотентно: пересоздаёт DOM, если описание изменилось/добавилось/удалилось.
|
|
590
|
+
function ensureNodeDescriptionEl(node) {
|
|
591
|
+
if (!node) return null;
|
|
592
|
+
const canvasEl = document.getElementById('canvas');
|
|
593
|
+
if (!canvasEl) return null;
|
|
594
|
+
const text = (node.type === 'image' || node.type === 'video') ? (node.description || '').trim() : '';
|
|
595
|
+
const sel = `.node-description-outside[data-desc-for="${node.id}"]`;
|
|
596
|
+
let el = canvasEl.querySelector(sel);
|
|
597
|
+
if (!text) {
|
|
598
|
+
// Описания нет — удаляем sidecar.
|
|
599
|
+
if (el) el.remove();
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
if (!el) {
|
|
603
|
+
el = document.createElement('div');
|
|
604
|
+
el.className = 'node-description-outside';
|
|
605
|
+
el.dataset.descFor = node.id;
|
|
606
|
+
canvasEl.appendChild(el);
|
|
607
|
+
}
|
|
608
|
+
if (el.textContent !== text) el.textContent = text;
|
|
609
|
+
positionNodeDescriptionEl(node, el);
|
|
610
|
+
return el;
|
|
611
|
+
}
|
|
612
|
+
function positionNodeDescriptionEl(node, el) {
|
|
613
|
+
if (!el) el = document.querySelector(`.node-description-outside[data-desc-for="${node.id}"]`);
|
|
614
|
+
if (!el) return;
|
|
615
|
+
const w = node.width || 280;
|
|
616
|
+
const h = node.height || 200;
|
|
617
|
+
const GAP = 4;
|
|
618
|
+
el.style.left = node.x + 'px';
|
|
619
|
+
el.style.top = (node.y + h + GAP) + 'px';
|
|
620
|
+
el.style.width = w + 'px';
|
|
621
|
+
}
|
|
622
|
+
function removeNodeDescriptionEl(nodeId) {
|
|
623
|
+
const el = document.querySelector(`.node-description-outside[data-desc-for="${nodeId}"]`);
|
|
624
|
+
if (el) el.remove();
|
|
625
|
+
}
|
|
592
626
|
// Подгоняет node.height под natural-aspect медиа (image/video). Сохраняет
|
|
593
627
|
// текущую node.width, пересчитывает height = width * (natH/natW) + chrome.
|
|
594
628
|
// Без этого после генерации (или history-undo на ноду с другим aspect'ом)
|
|
@@ -612,6 +646,7 @@ function fitNodeHeightToMedia(node, nodeEl, mediaEl) {
|
|
|
612
646
|
node.height = newH;
|
|
613
647
|
nodeEl.style.height = newH + 'px';
|
|
614
648
|
if (typeof renderConnections === 'function') renderConnections();
|
|
649
|
+
positionNodeDescriptionEl(node);
|
|
615
650
|
scheduleSave();
|
|
616
651
|
}
|
|
617
652
|
|
|
@@ -916,6 +951,8 @@ function attachResize(el, node, handle) {
|
|
|
916
951
|
// out, левая для in) считаются от node.x/y/width/height, поэтому при
|
|
917
952
|
// resize bezier должен следовать за новым размером.
|
|
918
953
|
renderConnections();
|
|
954
|
+
// Sidecar-описание подстраивается под новую ширину/высоту.
|
|
955
|
+
positionNodeDescriptionEl(node);
|
|
919
956
|
};
|
|
920
957
|
const onUp = () => {
|
|
921
958
|
document.removeEventListener('mousemove', onMove);
|
|
@@ -1055,10 +1092,15 @@ function makeDragHandler(el, node) {
|
|
|
1055
1092
|
const dxNode = (ev.clientX - startX) / state.zoom;
|
|
1056
1093
|
const dyNode = (ev.clientY - startY) / state.zoom;
|
|
1057
1094
|
for (const t of dragTargets) {
|
|
1058
|
-
|
|
1059
|
-
|
|
1095
|
+
// Раньше клампали в Math.max(0,...) — но canvas-frame отрисовывает
|
|
1096
|
+
// отрицательную зону вокруг canvas (padX=2000), и юзер видит её.
|
|
1097
|
+
// Юзер: «нужно позволить сдвигать выше и левее нуля».
|
|
1098
|
+
t.node.x = t.origX + dxNode;
|
|
1099
|
+
t.node.y = t.origY + dyNode;
|
|
1060
1100
|
t.el.style.left = t.node.x + 'px';
|
|
1061
1101
|
t.el.style.top = t.node.y + 'px';
|
|
1102
|
+
// Sidecar-описание движется вместе с нодой (тот же x, top чуть ниже).
|
|
1103
|
+
positionNodeDescriptionEl(t.node);
|
|
1062
1104
|
}
|
|
1063
1105
|
renderConnections();
|
|
1064
1106
|
};
|
|
@@ -1198,6 +1240,8 @@ async function deleteNode(node, el) {
|
|
|
1198
1240
|
renderConnections();
|
|
1199
1241
|
}
|
|
1200
1242
|
if (el) el.remove();
|
|
1243
|
+
// Sidecar-описание (если было) — уезжает вместе с нодой.
|
|
1244
|
+
removeNodeDescriptionEl(node.id);
|
|
1201
1245
|
// Записываем history ПОСЛЕ — но snapshot был снят ДО мутации
|
|
1202
1246
|
const h = _getHistory();
|
|
1203
1247
|
if (h) {
|
|
@@ -1286,6 +1330,7 @@ async function deleteSelectedNodes() {
|
|
|
1286
1330
|
for (const id of ids) {
|
|
1287
1331
|
const el = canvas.querySelector(`.node[data-id="${id}"]`);
|
|
1288
1332
|
if (el) el.remove();
|
|
1333
|
+
removeNodeDescriptionEl(id);
|
|
1289
1334
|
}
|
|
1290
1335
|
state.selectedNodeIds.clear();
|
|
1291
1336
|
renderConnections();
|
package/renderer/state.js
CHANGED
|
@@ -6,6 +6,32 @@
|
|
|
6
6
|
// видят друг друга по именам, без import/export. Порядок загрузки
|
|
7
7
|
// важен: см. <script> теги внизу index.html.
|
|
8
8
|
|
|
9
|
+
// === File-логгер (Electron only): дублируем console.* в userData/app.log,
|
|
10
|
+
// чтобы агент-помощник (Claude) мог анализировать историю. На web (без
|
|
11
|
+
// window.appLog) — noop. ====================================================
|
|
12
|
+
(function _hookConsoleToAppLog() {
|
|
13
|
+
if (!window.appLog?.write) return;
|
|
14
|
+
const fmt = a => {
|
|
15
|
+
if (a instanceof Error) return a.stack || a.message || String(a);
|
|
16
|
+
if (typeof a === 'string') return a;
|
|
17
|
+
try { return JSON.stringify(a); } catch { return String(a); }
|
|
18
|
+
};
|
|
19
|
+
const wrap = (orig, level) => (...args) => {
|
|
20
|
+
try { window.appLog.write(level, args.map(fmt).join(' ')); } catch {}
|
|
21
|
+
orig.apply(console, args);
|
|
22
|
+
};
|
|
23
|
+
console.log = wrap(console.log, 'log');
|
|
24
|
+
console.warn = wrap(console.warn, 'warn');
|
|
25
|
+
console.error = wrap(console.error, 'error');
|
|
26
|
+
// Подхватываем uncaught/unhandled — стек туда же.
|
|
27
|
+
window.addEventListener('error', e => {
|
|
28
|
+
try { window.appLog.write('uncaught', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}\n${e.error?.stack || ''}`); } catch {}
|
|
29
|
+
});
|
|
30
|
+
window.addEventListener('unhandledrejection', e => {
|
|
31
|
+
try { window.appLog.write('unhandled', `${e.reason?.message || e.reason}\n${e.reason?.stack || ''}`); } catch {}
|
|
32
|
+
});
|
|
33
|
+
})();
|
|
34
|
+
|
|
9
35
|
// =================== IndexedDB (хэндл папки фильма) ===================
|
|
10
36
|
const DB_NAME = 'video-editor';
|
|
11
37
|
const STORE = 'handles';
|
package/renderer/styles.css
CHANGED
|
@@ -110,18 +110,47 @@
|
|
|
110
110
|
width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
|
|
111
111
|
background: #1a1a1a; border-radius: 8px; padding: 4px;
|
|
112
112
|
}
|
|
113
|
-
/* Описание под image/video нодой.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
/* Описание под image/video нодой. Раньше пробовали рендерить ВНУТРИ .node,
|
|
114
|
+
но image/video задают фиксированный aspect-ratio высоту, а .node клипит
|
|
115
|
+
overflow (content-visibility:auto + paint-containment) — текст ниже не
|
|
116
|
+
был виден. Теперь описание — отдельный sibling-элемент на canvas'е
|
|
117
|
+
(`.node-description-outside`), позиционируется в (node.x, node.y+h+gap),
|
|
118
|
+
ширина = node.width, высота = auto по контенту. См. ensureNodeDescriptionEl
|
|
119
|
+
в settings.js (вызывается из renderCanvas / drag / resize / ПКМ-меню). */
|
|
120
|
+
.node-description-outside {
|
|
121
|
+
position: absolute;
|
|
122
|
+
padding: 6px 8px;
|
|
117
123
|
background: #1e1e1e; color: #ccc;
|
|
118
|
-
border:
|
|
119
|
-
font-
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
border-left: 2px solid #4a6a9a; border-radius: 2px;
|
|
125
|
+
font-size: 12px; line-height: 1.4;
|
|
126
|
+
white-space: pre-wrap; word-wrap: break-word;
|
|
127
|
+
box-sizing: border-box;
|
|
128
|
+
user-select: text; -webkit-user-select: text; cursor: text;
|
|
129
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
|
130
|
+
z-index: 2;
|
|
131
|
+
pointer-events: auto;
|
|
132
|
+
}
|
|
133
|
+
/* Legacy: оставляем класс для совместимости (вдруг где-то ещё ссылается). */
|
|
134
|
+
.node .node-description { display: none; }
|
|
135
|
+
|
|
136
|
+
/* Большая явная кнопка «Перейти → <имя сцены>» снизу ноды. Видна когда
|
|
137
|
+
node.linkedBoard.name задан (см. refreshNodeLinkBadge). Position:
|
|
138
|
+
absolute, чуть выше нижней границы ноды; перекрывает контент только
|
|
139
|
+
визуально (pointer-events:auto только на кнопке). */
|
|
140
|
+
.node .link-go-btn {
|
|
141
|
+
position: absolute; left: 8px; right: 8px; bottom: 6px;
|
|
142
|
+
padding: 5px 10px; font-size: 11px; font-weight: 500;
|
|
143
|
+
background: #2a3854; color: #aac8e6;
|
|
144
|
+
border: 1px solid #4a6a9a; border-radius: 4px;
|
|
145
|
+
cursor: pointer; text-align: center;
|
|
146
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
147
|
+
transition: background 0.12s;
|
|
148
|
+
z-index: 4;
|
|
149
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
|
122
150
|
}
|
|
123
|
-
.node .
|
|
124
|
-
|
|
151
|
+
.node .link-go-btn:hover { background: #3a5a8a; color: #fff; }
|
|
152
|
+
/* В drawing-нодах (тонкая стрелка) кнопка визуально странная — не показываем. */
|
|
153
|
+
.node.drawing-node .link-go-btn { display: none !important; }
|
|
125
154
|
|
|
126
155
|
/* Link-badge: значок 🔗 на ноде когда у неё есть linkedBoard. Лежит в
|
|
127
156
|
правом-верхнем углу, кликабельный (= dblclick: открыть target-сцену).
|
|
@@ -141,6 +170,44 @@
|
|
|
141
170
|
к левому-верху самой bbox'ы. */
|
|
142
171
|
.node.drawing-node .link-badge { top: 4px; right: 4px; }
|
|
143
172
|
|
|
173
|
+
/* Мобильный бургер для template/view-only. По умолчанию скрыт — показывается
|
|
174
|
+
только на узких экранах в view-only-mode (template-просмотр). В edit-режиме
|
|
175
|
+
юзер обычно на десктопе, sidebar нужен постоянно. */
|
|
176
|
+
.kk-mobile-burger {
|
|
177
|
+
display: none; padding: 6px 10px; font-size: 18px; line-height: 1;
|
|
178
|
+
background: transparent; color: #ccc; border: 1px solid #444;
|
|
179
|
+
border-radius: 4px; cursor: pointer;
|
|
180
|
+
}
|
|
181
|
+
.kk-mobile-burger:hover { background: #2a2a2a; color: #fff; }
|
|
182
|
+
@media (max-width: 768px) {
|
|
183
|
+
body.view-only-mode .kk-mobile-burger { display: inline-flex; }
|
|
184
|
+
/* Sidebar в шторку (slide-in). Position:fixed чтобы перекрывать canvas. */
|
|
185
|
+
body.view-only-mode .sidebar {
|
|
186
|
+
position: fixed; left: 0; top: 0; height: 100vh; z-index: 90;
|
|
187
|
+
transform: translateX(-100%); transition: transform 0.2s ease;
|
|
188
|
+
box-shadow: 4px 0 16px rgba(0,0,0,0.5);
|
|
189
|
+
}
|
|
190
|
+
body.view-only-mode.sidebar-open .sidebar { transform: translateX(0); }
|
|
191
|
+
/* Backdrop (затемнение) когда sidebar открыт. */
|
|
192
|
+
body.view-only-mode.sidebar-open::before {
|
|
193
|
+
content: ''; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
|
194
|
+
z-index: 89; cursor: pointer;
|
|
195
|
+
}
|
|
196
|
+
/* В template/mobile прячем ВСЁ из toolbar'а кроме бургера —
|
|
197
|
+
у юзера маленький экран, кнопки управления (zoom, banner) занимают
|
|
198
|
+
половину viewport'а. Юзер: «оставь только бургер». */
|
|
199
|
+
body.view-only-mode .toolbar > *:not(.kk-mobile-burger) { display: none !important; }
|
|
200
|
+
/* Сам toolbar тоже минимизируем — без растягивания и фона, чтобы canvas
|
|
201
|
+
занял максимум места. */
|
|
202
|
+
body.view-only-mode .toolbar {
|
|
203
|
+
padding: 4px; min-height: auto; border-bottom: none;
|
|
204
|
+
background: transparent;
|
|
205
|
+
}
|
|
206
|
+
/* Канвас должен принимать pinch-zoom через JS, native scroll и pan
|
|
207
|
+
по 1 пальцу остаются. */
|
|
208
|
+
body.view-only-mode .canvas-wrap { touch-action: pan-x pan-y; }
|
|
209
|
+
}
|
|
210
|
+
|
|
144
211
|
/* Brand-logo и welcome-logo всегда кликабельны (на главной → ничего,
|
|
145
212
|
в проекте → возврат на welcome, dblclick → настройки). cursor:pointer
|
|
146
213
|
ставится и в HTML inline, но в web chatium-минифайер иногда срывает
|
package/renderer/templates.js
CHANGED
|
@@ -528,8 +528,17 @@ async function collectProjectBoards(filmHandle) {
|
|
|
528
528
|
await b.handle.getFileHandle('scene.json');
|
|
529
529
|
// Исключаем scene.json и .thumbnails/ из mediaFiles — они либо
|
|
530
530
|
// manifest, либо генерятся клиентом.
|
|
531
|
+
// scene.json — manifest, отдельный канал.
|
|
532
|
+
// .thumbnails/ — старая папка миниатюр (legacy).
|
|
533
|
+
// thumbs/ — текущая папка миниатюр (генерятся клиентом
|
|
534
|
+
// через generateThumbnailIfMissing; их НЕ нужно
|
|
535
|
+
// заливать на сервер — получатель сам сгенерит).
|
|
536
|
+
// Без этого фильтра save отправлял 4 frames + 4 thumbs
|
|
537
|
+
// = 8 файлов вместо 4. См. логи.
|
|
531
538
|
b.mediaFiles = b.mediaFiles.filter(f =>
|
|
532
|
-
f.relPath !== 'scene.json' &&
|
|
539
|
+
f.relPath !== 'scene.json' &&
|
|
540
|
+
!f.relPath.startsWith('.thumbnails/') &&
|
|
541
|
+
!f.relPath.startsWith('thumbs/'));
|
|
533
542
|
validBoards.push(b);
|
|
534
543
|
} catch {}
|
|
535
544
|
}
|
package/server.js
CHANGED
|
@@ -139,7 +139,14 @@ async function handleProxy(res, url) {
|
|
|
139
139
|
try { u = new URL(target); } catch { return send(res, 400, { error: 'битый url' }); }
|
|
140
140
|
if (!/^https?:$/.test(u.protocol)) return send(res, 400, { error: 'разрешены только http/https' });
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// Auth-aware прокси: некоторые upstream-URL'ы (OpenRouter video content)
|
|
143
|
+
// требуют Bearer-токен. Добавляем здесь чтобы клиентский <video src="...">
|
|
144
|
+
// мог их подгрузить через /api/proxy без обхода CORS+auth в renderer'е.
|
|
145
|
+
const fetchHeaders = {};
|
|
146
|
+
if (u.hostname === 'openrouter.ai' && process.env.OPENROUTER_API_KEY) {
|
|
147
|
+
fetchHeaders.Authorization = 'Bearer ' + process.env.OPENROUTER_API_KEY;
|
|
148
|
+
}
|
|
149
|
+
const r = await fetch(target, { headers: fetchHeaders });
|
|
143
150
|
if (!r.ok) return send(res, r.status, { error: `upstream ${r.status}` });
|
|
144
151
|
const headers = {
|
|
145
152
|
'Content-Type': r.headers.get('content-type') || 'application/octet-stream',
|