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 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 модели. Раньше пробовали google/gemini-2.5-flash-image-preview,
114
- // но OpenRouter отдаёт 404 «No endpoints found» — slug устарел / модель за paywall.
115
- // Пока пусто = роутинг через OpenRouter не активен fall through на KIE / Chatium.
116
- // Если найдётся рабочий slug — вернём.
117
- const OPENROUTER_IMAGE_MODELS = {};
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 orSupportsModel = kind === 'image' && !!OPENROUTER_IMAGE_MODELS[orKey];
308
- if (orAvailable && orSupportsModel) {
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.16",
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 + '/app/spaces/client/index.html#template=' + proj.id;
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
- <h2 style="margin:0 0 16px;font-size:18px;">🤝 Расшарить «${escapeHtml(proj.name || p.name || '')}»</h2>
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
- const inp = document.createElement('input');
2950
- inp.type = 'text';
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
- 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;';
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 badge = el.querySelector('.link-badge');
3158
- if (!badge) return;
3159
- if (node.linkedBoard?.name) {
3160
- badge.style.display = '';
3161
- badge.title = `Дабл-клик / клик: открыть → ${node.linkedBoard.name}`;
3162
- } else {
3163
- badge.style.display = 'none';
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
- if (view) {
3384
- // Backward-compat старых view (без padding) добавляем.
3385
- if (typeof view.scrollLeft === 'number') {
3386
- canvasWrap.scrollLeft = view.scrollLeft >= padX ? view.scrollLeft : view.scrollLeft + padX;
3387
- }
3388
- else
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
- if (typeof view.scrollTop === 'number') {
3391
- canvasWrap.scrollTop = view.scrollTop >= padY ? view.scrollTop : view.scrollTop + padY;
3444
+ canvasWrap.scrollTop = padY;
3392
3445
  }
3393
- else
3394
- canvasWrap.scrollTop = padY;
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
- canvasWrap.scrollLeft = padX;
3398
- canvasWrap.scrollTop = padY;
3471
+ scrollToTopLeftNode();
3399
3472
  }
3400
- // Auto-scroll к bbox нод если ни одна не попадает в viewport.
3401
- // Frame уже ресайзнут синхронно выше, scroll проставлен корректно
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
- // Link-badge: показывает 🔗 если у ноды есть linkedBoard (ссылка на сцену).
3841
- // Сам badge кликабельный переход выполняется также через dblclick по ноде.
3842
- const linkBadge = document.createElement('div');
3843
- linkBadge.className = 'link-badge';
3844
- linkBadge.textContent = '🔗';
3845
- el.appendChild(linkBadge);
3846
- linkBadge.addEventListener('click', e => {
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',
@@ -130,7 +130,10 @@
130
130
  svg.setAttribute('width', '6000');
131
131
  svg.setAttribute('height', '4000');
132
132
  svg.setAttribute('class', 'drawing-preview');
133
- svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:1000;';
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
- const canvas = document.getElementById('canvas');
253
- if (canvas) canvas.addEventListener('mousedown', _onMouseDown, true);
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));