kingkont 0.20.16 → 0.20.18
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 +77 -1
- package/renderer/media.js +125 -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/skill/SKILL.md +102 -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.18",
|
|
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));
|