kingkont 0.20.15 → 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 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);
@@ -1207,6 +1297,29 @@ async function deleteProjectText(id, s) {
1207
1297
  return true;
1208
1298
  }
1209
1299
 
1300
+ // Batch-операции: один HTTP к chatium вместо N. Используются client'ом
1301
+ // при open (download всех .md) и save (upload всех .md).
1302
+ async function batchGetProjectTexts(body, s) {
1303
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
1304
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~batch-get`;
1305
+ logCall('POST', 'Chatium', url, `n=${body?.ids?.length || 0}`);
1306
+ const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
1307
+ const text = await r.text();
1308
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1309
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1310
+ return d;
1311
+ }
1312
+ async function batchSaveProjectTexts(body, s) {
1313
+ const headers = { ...chatiumAuthHeaders(s), 'Content-Type': 'application/json' };
1314
+ const url = `${chatiumBase(s)}${CHATIUM_PATHS.projectTexts}~batch-save`;
1315
+ logCall('POST', 'Chatium', url, `n=${body?.items?.length || 0}`);
1316
+ const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
1317
+ const text = await r.text();
1318
+ let d; try { d = JSON.parse(text); } catch { d = { raw: text }; }
1319
+ if (!r.ok) throw new Error(d?.error || d?.reason || `HTTP ${r.status}`);
1320
+ return d;
1321
+ }
1322
+
1210
1323
  async function listElevenVoices() {
1211
1324
  const key = process.env.ELEVENLABS_API_KEY;
1212
1325
  if (!key) throw new Error('ELEVENLABS_API_KEY не задан');
@@ -1257,6 +1370,8 @@ module.exports = {
1257
1370
  createProjectText,
1258
1371
  getProjectText,
1259
1372
  updateProjectText,
1373
+ batchGetProjectTexts,
1374
+ batchSaveProjectTexts,
1260
1375
  deleteProjectText,
1261
1376
  // Constants (для server.js / тестов)
1262
1377
  KIE_IMAGE_MODELS,
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.15",
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 + '/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) {