kingkont 0.8.7 → 0.9.0

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.
@@ -1129,6 +1129,10 @@ async function renderTimeline() {
1129
1129
  tracksEl.appendChild(trackEl);
1130
1130
  }
1131
1131
  $('timelineDuration').textContent = videoTotal > 0 ? `${videoTotal.toFixed(1)} сек` : '';
1132
+ // Запоминаем total для preview-overlay (его updatePreviewControls читает).
1133
+ _lastTimelineTotal = videoTotal;
1134
+ updatePreviewControls();
1135
+ updateTimelineAspectLabel();
1132
1136
 
1133
1137
  // Playhead
1134
1138
  const ph = document.createElement('div');
@@ -1324,7 +1328,11 @@ function applyPreviewState() {
1324
1328
 
1325
1329
  $('timelineBtn').addEventListener('click', () => {
1326
1330
  const willShow = $('timelinePanel').classList.contains('hidden');
1327
- setPanelsVisible(willShow, willShow);
1331
+ // Открываем ТОЛЬКО таймлайн (preview не разворачиваем — появится автоматически
1332
+ // при ▶ Play). Закрываем — закрываем таймлайн, preview оставляем как есть
1333
+ // (юзер мог свернуть/развернуть его сам).
1334
+ $('timelinePanel').classList.toggle('hidden', !willShow);
1335
+ localStorage.setItem('timelineOpen', willShow ? '1' : '0');
1328
1336
  if (willShow && state.currentBoard) {
1329
1337
  renderTimeline();
1330
1338
  scheduleUpdatePreview();
@@ -1333,15 +1341,40 @@ $('timelineBtn').addEventListener('click', () => {
1333
1341
 
1334
1342
  // Кнопка × на самом таймлайне — закрывает панель (то же что повторный
1335
1343
  // клик на #timelineBtn). Дублирует чтобы не приходилось целиться в
1336
- // маленькую кнопку в верхнем тулбаре.
1344
+ // маленькую кнопку в верхнем тулбаре. Также останавливает playback и
1345
+ // сворачивает preview (юзер ожидает что закрытие таймлайна = закрытие
1346
+ // всего связанного UI).
1337
1347
  $('timelineClose')?.addEventListener('click', () => {
1338
1348
  if (!$('timelinePanel').classList.contains('hidden')) {
1339
1349
  $('timelineBtn').click();
1340
1350
  }
1351
+ // Stop playback и свернуть preview-панель тоже.
1352
+ stopTimelinePlayback();
1353
+ setPreviewCollapsed(true);
1354
+ updatePreviewControls();
1341
1355
  });
1342
1356
 
1343
1357
 
1358
+ // «⋯» — меню скрытых/опасных действий таймлайна. Открывается на click,
1359
+ // закрывается на click outside.
1360
+ $('timelineMore')?.addEventListener('click', e => {
1361
+ e.stopPropagation();
1362
+ const menu = $('timelineMoreMenu');
1363
+ const willShow = menu.classList.contains('hidden');
1364
+ menu.classList.toggle('hidden', !willShow);
1365
+ if (willShow) {
1366
+ setTimeout(() => {
1367
+ document.addEventListener('mousedown', function onOutside(ev) {
1368
+ if (menu.contains(ev.target) || ev.target.id === 'timelineMore') return;
1369
+ document.removeEventListener('mousedown', onOutside);
1370
+ menu.classList.add('hidden');
1371
+ });
1372
+ }, 0);
1373
+ }
1374
+ });
1375
+
1344
1376
  $('timelineClear').addEventListener('click', () => {
1377
+ $('timelineMoreMenu')?.classList.add('hidden');
1345
1378
  if (!state.currentBoard) return;
1346
1379
  if (!confirm('Очистить все дорожки таймлайна?')) return;
1347
1380
  state.currentBoard.metadata.timeline = defaultTimeline();
@@ -1384,6 +1417,54 @@ function setPlayheadTime(t) {
1384
1417
  }
1385
1418
  }
1386
1419
  if (!_playStop) scheduleUpdatePreview();
1420
+ updatePreviewControls();
1421
+ }
1422
+
1423
+ // Aspect-ratio сцены → разрешение для ffmpeg-экспорта. Стараемся попасть
1424
+ // в стандартные «720p-эквиваленты» по короткой стороне ~720 (× round-up
1425
+ // до чётных пикселей — h264 требует even).
1426
+ function aspectToExportDimensions(aspectStr) {
1427
+ const map = {
1428
+ '16:9': { W: 1280, H: 720 },
1429
+ '9:16': { W: 720, H: 1280 },
1430
+ '1:1': { W: 1080, H: 1080 },
1431
+ '4:3': { W: 1280, H: 960 },
1432
+ '3:4': { W: 960, H: 1280 },
1433
+ '21:9': { W: 1680, H: 720 },
1434
+ '3:2': { W: 1200, H: 800 },
1435
+ '2:3': { W: 800, H: 1200 },
1436
+ };
1437
+ return map[aspectStr] || map['16:9']; // дефолт если поле пустое/незнакомое
1438
+ }
1439
+
1440
+ // Aspect-label сцены в заголовке таймлайна — обновляется при render'е.
1441
+ function updateTimelineAspectLabel() {
1442
+ const el = document.getElementById('timelineAspect');
1443
+ if (!el) return;
1444
+ const ratio = state.currentBoard?.metadata?.settings?.aspectRatio;
1445
+ el.textContent = ratio ? `▭ ${ratio}` : '';
1446
+ }
1447
+
1448
+ // Обновляет overlay-контролы превью: кнопка play/pause + время "X / Y с".
1449
+ // Y берём из `_lastTimelineTotal` (вычислено в renderTimeline).
1450
+ let _lastTimelineTotal = 0;
1451
+ function updatePreviewControls() {
1452
+ const ctl = document.getElementById('previewControls');
1453
+ if (!ctl) return;
1454
+ const tl = getTimeline();
1455
+ const hasClips = !!(tl?.tracks?.some(t => t.clips?.length));
1456
+ ctl.classList.toggle('hidden', !hasClips);
1457
+ if (!hasClips) return;
1458
+ const playing = !!_playStop;
1459
+ ctl.classList.toggle('is-playing', playing);
1460
+ const btn = document.getElementById('previewPlayBtn');
1461
+ if (btn) btn.textContent = playing ? '⏸' : '▶';
1462
+ const info = document.getElementById('previewTimeInfo');
1463
+ if (info) {
1464
+ const cur = (state.playheadTime || 0).toFixed(1);
1465
+ const tot = (_lastTimelineTotal || 0).toFixed(1);
1466
+ info.textContent = `${cur} / ${tot} с`;
1467
+ }
1387
1468
  }
1388
1469
 
1389
1470
  let _previewUpdateRaf = null;
@@ -1455,6 +1536,42 @@ function stopTimelinePlayback() {
1455
1536
  }
1456
1537
  }
1457
1538
 
1539
+ // Сброс UI таймлайна и превью при переключении board'а / закрытии проекта.
1540
+ // Без этого `<video>` и `<img>` превью держат src от прошлой сцены — юзер
1541
+ // видит фрейм старой сцены пока не нажмёт ▶/scrub. Также сбрасывает плейхед,
1542
+ // чистит контент дорожек и свёртывает preview-overlay.
1543
+ function resetTimelineUI() {
1544
+ // 1) Stop playback (если шёл).
1545
+ stopTimelinePlayback();
1546
+ // 2) Сбрасываем preview-source.
1547
+ const v = document.getElementById('timelinePreview');
1548
+ if (v) {
1549
+ try { v.pause(); } catch {}
1550
+ v.removeAttribute('src');
1551
+ try { v.load(); } catch {} // освободить decoder, очистить frame
1552
+ v.classList.add('hidden');
1553
+ }
1554
+ const img = document.getElementById('timelinePreviewImg');
1555
+ if (img) {
1556
+ img.removeAttribute('src');
1557
+ img.classList.add('hidden');
1558
+ }
1559
+ // 3) Чистим overlay-контролы (no clips → hidden).
1560
+ const ctl = document.getElementById('previewControls');
1561
+ if (ctl) ctl.classList.add('hidden');
1562
+ // 4) Чистим контент tracks внутри панели (на случай если новый
1563
+ // renderTimeline не запустится — например доска без таймлайна).
1564
+ const tracksEl = document.getElementById('timelineTracks');
1565
+ if (tracksEl) tracksEl.innerHTML = '';
1566
+ const dur = document.getElementById('timelineDuration');
1567
+ if (dur) dur.textContent = '';
1568
+ const phInfo = document.getElementById('timelinePlayheadInfo');
1569
+ if (phInfo) phInfo.textContent = '0.0 с';
1570
+ // 5) Сбрасываем плейхед (новый board подставит свой из scene.json).
1571
+ state.playheadTime = 0;
1572
+ }
1573
+ window.resetTimelineUI = resetTimelineUI;
1574
+
1458
1575
  function attachPlayheadDrag(handle, phEl) {
1459
1576
  handle.addEventListener('mousedown', e => {
1460
1577
  e.preventDefault();
@@ -2346,6 +2463,24 @@ function showTrackContextMenu(track, clientX, clientY, opts = {}) {
2346
2463
  setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
2347
2464
  }
2348
2465
 
2466
+ // Кнопка play/pause в overlay-е превью — дублирует клик по #timelinePlay,
2467
+ // логика playback живёт там.
2468
+ $('previewPlayBtn')?.addEventListener('click', e => {
2469
+ e.stopPropagation();
2470
+ $('timelinePlay').click();
2471
+ // Тут же обновляем UI чтобы overlay переключился в playing-state.
2472
+ setTimeout(updatePreviewControls, 0);
2473
+ });
2474
+
2475
+ // «×» в overlay — сворачивает preview-панель в полоску. Если идёт
2476
+ // playback — сначала останавливаем (иначе остаётся непонятное состояние).
2477
+ $('previewCloseBtn')?.addEventListener('click', e => {
2478
+ e.stopPropagation();
2479
+ if (_playStop) { _playStop(); _playStop = null; $('timelinePlay').textContent = '▶'; }
2480
+ setPreviewCollapsed(true);
2481
+ updatePreviewControls();
2482
+ });
2483
+
2349
2484
  $('addVideoTrack').addEventListener('click', () => {
2350
2485
  const tl = getTimeline();
2351
2486
  const n = tl.tracks.filter(t => t.kind === 'video').length;
@@ -2362,7 +2497,7 @@ $('addAudioTrack').addEventListener('click', () => {
2362
2497
  // Видео-элемент пока используется для рендера видео-клипов; sync с аудио-clock.
2363
2498
  let _playStop = null;
2364
2499
  $('timelinePlay').addEventListener('click', async () => {
2365
- if (_playStop) { _playStop(); _playStop = null; $('timelinePlay').textContent = '▶'; return; }
2500
+ if (_playStop) { _playStop(); _playStop = null; $('timelinePlay').textContent = '▶'; updatePreviewControls(); return; }
2366
2501
  const tl = getTimeline();
2367
2502
  const videoTrack = tl.tracks.find(t => t.kind === 'video' && t.clips.length);
2368
2503
  const audioTracks = tl.tracks.filter(t => t.kind === 'audio' && t.clips.length);
@@ -2455,6 +2590,7 @@ $('timelinePlay').addEventListener('click', async () => {
2455
2590
  const newT = startTime + Math.max(0, ctx.currentTime - playStartCtxTime);
2456
2591
  state.playheadTime = newT;
2457
2592
  positionPlayhead();
2593
+ updatePreviewControls(); // обновить «X / Y с» в overlay-е превью
2458
2594
  if (maxDur > 0 && newT >= maxDur) {
2459
2595
  if (_playStop) _playStop();
2460
2596
  return;
@@ -2542,6 +2678,7 @@ $('timelinePlay').addEventListener('click', async () => {
2542
2678
  for (const t of timers) clearTimeout(t);
2543
2679
  $('timelinePlay').textContent = '▶';
2544
2680
  _playStop = null;
2681
+ updatePreviewControls();
2545
2682
  });
2546
2683
 
2547
2684
  function waitMs(ms, cancelFn) {
@@ -2563,9 +2700,11 @@ $('timelineExport').addEventListener('click', async () => {
2563
2700
  status.classList.remove('error');
2564
2701
  const setStatus = (m) => { status.textContent = m; };
2565
2702
  const cleanupNames = [];
2566
- const W = 1280, H = 720;
2703
+ // Берём разрешение из scene aspectRatio. Дефолт 16:9 если не задан.
2704
+ const aspectStr = state.currentBoard?.metadata?.settings?.aspectRatio || '16:9';
2705
+ const { W, H } = aspectToExportDimensions(aspectStr);
2567
2706
  try {
2568
- setStatus('Загружаю ffmpeg...');
2707
+ setStatus(`Загружаю ffmpeg... (${W}×${H} для ${aspectStr})`);
2569
2708
  const ff = await ensureFFmpeg(setStatus);
2570
2709
 
2571
2710
  // 1) Видео-дорожка: каждый клип → silent mp4 segment
@@ -2674,8 +2813,11 @@ $('timelineExport').addEventListener('click', async () => {
2674
2813
 
2675
2814
  const data = await ff.readFile(finalName);
2676
2815
  const blob = new Blob([data.buffer], { type: 'video/mp4' });
2677
- triggerDownload(blob, `${state.currentBoard.name}_timeline.mp4`);
2678
- setStatus('Готово ✓');
2816
+ // Включаем aspect в имя файла — удобно когда юзер экспортит сцену
2817
+ // в нескольких форматах подряд (16:9 для YouTube, 9:16 для shorts).
2818
+ const aspectInName = aspectStr.replace(':', 'x');
2819
+ triggerDownload(blob, `${state.currentBoard.name}_timeline_${aspectInName}.mp4`);
2820
+ setStatus(`Готово ✓ (${W}×${H})`);
2679
2821
  } catch (e) {
2680
2822
  console.error('timeline export failed:', e);
2681
2823
  status.classList.add('error');
package/server.js CHANGED
@@ -133,7 +133,15 @@ async function handleProxy(res, url) {
133
133
 
134
134
  const r = await fetch(target);
135
135
  if (!r.ok) return send(res, r.status, { error: `upstream ${r.status}` });
136
- const headers = { 'Content-Type': r.headers.get('content-type') || 'application/octet-stream' };
136
+ const headers = {
137
+ 'Content-Type': r.headers.get('content-type') || 'application/octet-stream',
138
+ // CDN-обложки и медиа адресуемы по hash в URL — содержимое
139
+ // immutable. Разрешаем браузеру кэшировать, чтобы welcome не
140
+ // перезагружал обложки облачных проектов на каждый рендер.
141
+ // (Глобальный default 'no-store' выставляется в send(); тут
142
+ // переопределяем.)
143
+ 'Cache-Control': 'public, max-age=86400, immutable',
144
+ };
137
145
  const len = r.headers.get('content-length');
138
146
  if (len) headers['Content-Length'] = len;
139
147
  res.writeHead(200, headers);
@@ -245,6 +253,48 @@ async function handleTemplateDelete(res, id) {
245
253
  } catch (e) { sendError(res, e, 502); }
246
254
  }
247
255
 
256
+ async function handleTemplateUpdate(req, res, id) {
257
+ try {
258
+ const body = await readJson(req);
259
+ send(res, 200, await providers.updateTemplate(id, body, getSettings()));
260
+ } catch (e) { sendError(res, e, 502); }
261
+ }
262
+
263
+ // =============================================================================
264
+ // Cloud projects: тонкие proxy-роуты на Chatium-сервер.
265
+ // (Зеркальный код templates — но для редактируемых проектов.)
266
+ // =============================================================================
267
+ async function handleProjectsList(res) {
268
+ try { send(res, 200, await providers.listProjects(getSettings())); }
269
+ catch (e) { sendError(res, e, 502); }
270
+ }
271
+
272
+ async function handleProjectGet(res, id) {
273
+ try { send(res, 200, await providers.getProject(id, getSettings())); }
274
+ catch (e) { sendError(res, e, 502); }
275
+ }
276
+
277
+ async function handleProjectCreate(req, res) {
278
+ try {
279
+ const body = await readJson(req);
280
+ send(res, 200, await providers.createProject(body, getSettings()));
281
+ } catch (e) { sendError(res, e, 502); }
282
+ }
283
+
284
+ async function handleProjectUpdate(req, res, id) {
285
+ try {
286
+ const body = await readJson(req);
287
+ send(res, 200, await providers.updateProject(id, body, getSettings()));
288
+ } catch (e) { sendError(res, e, 502); }
289
+ }
290
+
291
+ async function handleProjectDelete(res, id) {
292
+ try {
293
+ await providers.deleteProject(id, getSettings());
294
+ send(res, 200, { ok: true });
295
+ } catch (e) { sendError(res, e, 502); }
296
+ }
297
+
248
298
  // =============================================================================
249
299
  // Static files (renderer assets).
250
300
  // =============================================================================
@@ -290,9 +340,21 @@ const server = createServer(async (req, res) => {
290
340
  const m = url.pathname.match(/^\/api\/templates\/([^/]+)$/);
291
341
  if (m) {
292
342
  if (req.method === 'GET') return handleTemplateGet(res, decodeURIComponent(m[1]));
343
+ if (req.method === 'POST') return handleTemplateUpdate(req, res, decodeURIComponent(m[1]));
293
344
  if (req.method === 'DELETE') return handleTemplateDelete(res, decodeURIComponent(m[1]));
294
345
  }
295
346
  }
347
+ // Cloud-projects routes — зеркало templates, но для редактируемых проектов.
348
+ if (req.method === 'GET' && url.pathname === '/api/projects') return handleProjectsList(res);
349
+ if (req.method === 'POST' && url.pathname === '/api/projects') return handleProjectCreate(req, res);
350
+ {
351
+ const m = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
352
+ if (m) {
353
+ if (req.method === 'GET') return handleProjectGet(res, decodeURIComponent(m[1]));
354
+ if (req.method === 'POST') return handleProjectUpdate(req, res, decodeURIComponent(m[1]));
355
+ if (req.method === 'DELETE') return handleProjectDelete(res, decodeURIComponent(m[1]));
356
+ }
357
+ }
296
358
  if (req.method === 'GET') return serveStatic(res, url);
297
359
  send(res, 404, 'not found');
298
360
  } catch (e) {
package/skill/SKILL.md CHANGED
@@ -132,6 +132,115 @@ Backward-compat: если в scene.json остался старый формат
132
132
  `settings.defaultPrompts: { image: "..." }` — CLI и UI конвертируют
133
133
  его в массив на лету; следующее сохранение перепишет в новый формат.
134
134
 
135
+ #### Когда заполнять defaultPrompts по умолчанию
136
+
137
+ Если юзер описывает сцену **как кадр фильма/сериала/последовательности** —
138
+ сразу при создании сцены пропиши в `defaultPrompts` визуальный стиль кадров.
139
+ Это сэкономит юзеру тонну ручного ввода и сохранит консистентность всех
140
+ генераций в сцене.
141
+
142
+ Что добавлять (зависит от описания):
143
+
144
+ - **Технические характеристики камеры**: «35mm film», «70mm IMAX», «shot
145
+ on Arri Alexa», «handheld camera», «steadicam», «drone shot», «macro
146
+ lens», «anamorphic lens (cinematic widescreen with horizontal lens
147
+ flares)».
148
+ - **Освещение**: «natural daylight», «golden hour», «overcast soft
149
+ lighting», «harsh midday sun», «cinematic three-point lighting»,
150
+ «practical sources only», «moody low-key lighting», «high-key bright»,
151
+ «neon accents».
152
+ - **Композиция и план**: «wide establishing shot», «medium close-up»,
153
+ «extreme close-up», «over-the-shoulder», «Dutch angle», «symmetrical
154
+ Wes-Anderson framing», «rule of thirds».
155
+ - **Цветовая палитра / grading**: «teal and orange», «desaturated muted
156
+ tones», «vintage Kodachrome», «cold blue tint», «warm sepia», «black
157
+ and white high contrast».
158
+ - **Жанровый/режиссёрский референс**: «in the style of Christopher
159
+ Nolan», «Wes Anderson aesthetic», «David Fincher dark moody», «Wong
160
+ Kar-wai dreamy slow-motion», «Roger Deakins cinematography»,
161
+ «Studio Ghibli animation style», «1970s grindhouse film grain».
162
+ - **Период/эпоха**: «1990s film aesthetic», «retro VHS look»,
163
+ «contemporary photoreal», «futuristic cyberpunk neon».
164
+ - **Атмосфера**: «foggy atmosphere», «dust particles in the air»,
165
+ «volumetric god rays», «slight motion blur», «shallow depth of field
166
+ with bokeh».
167
+
168
+ Пример: юзер сказал «давай сцену про детектива в нуар-фильме 50-х в
169
+ дождливом Нью-Йорке».
170
+
171
+ Создай сцену, и сразу после `add-board` добавь в `scene.json →
172
+ settings.defaultPrompts`:
173
+
174
+ ```json
175
+ [
176
+ { "id": "...", "text": "1950s film noir aesthetic, black and white high contrast, dramatic shadows", "kinds": ["image","video"], "enabled": true },
177
+ { "id": "...", "text": "rainy New York street, wet asphalt reflecting neon signs, foggy atmosphere", "kinds": ["image","video"], "enabled": true },
178
+ { "id": "...", "text": "anamorphic lens, shallow depth of field, cinematic composition", "kinds": ["image","video"], "enabled": true }
179
+ ]
180
+ ```
181
+
182
+ Раздели на 2-4 prompt'а (а не один длинный) — юзер потом сможет временно
183
+ выключить какой-то один в gen-modal'е (например прохладную палитру для
184
+ конкретной картинки, оставив остальное).
185
+
186
+ После создания сцены **сообщи юзеру** что defaultPrompts настроены, и
187
+ покажи текстом что именно — чтобы он мог поправить.
188
+
189
+ #### Раскадровка: разноси повторяющееся в @-ссылки или defaultPrompts
190
+
191
+ Когда юзер просит **раскадровку** (storyboard) — серию кадров одной
192
+ сцены/эпизода — **не дублируй общие части в каждый промпт**. Вместо
193
+ повторения извлекай их одним из двух способов:
194
+
195
+ **1. Persistent описания → отдельные ноды (text/image) + @-ссылки.**
196
+
197
+ Если в каждом кадре повторяется один и тот же **персонаж, локация,
198
+ объект, реквизит** — заведи отдельную text/image-ноду и в промптах
199
+ кадров ссылайся на неё через `@имя`. CLI и UI резолвят `[@имя]` в
200
+ полный текст ноды (или прикладывают image как референс) на этапе
201
+ генерации.
202
+
203
+ Пример. Юзер просит раскадровку «детектив идёт по улице, видит труп,
204
+ звонит партнёру». Не пиши в каждый промпт «молодой детектив в потрёпанном
205
+ сером плаще с блокнотом, тёмные волосы, усталое лицо». Вместо:
206
+
207
+ ```bash
208
+ # Сначала — text-нода с описанием персонажа.
209
+ kingkont add-node <project> <board> --kind=text --name="Анна-детектив" \
210
+ --text="Молодая женщина-следователь лет 30, потрёпанный серый плащ, \
211
+ тёмные волосы собраны в хвост, усталое лицо, в руках блокнот."
212
+
213
+ # Потом каждый кадр — короткий промпт с @-ссылкой:
214
+ kingkont gen <project> <board> --kind=image --name="кадр-1" \
215
+ --prompt="[@Анна-детектив] идёт вдоль кирпичной стены, ночь, дождь" \
216
+ --refs="@Анна-детектив"
217
+
218
+ kingkont gen <project> <board> --kind=image --name="кадр-2" \
219
+ --prompt="[@Анна-детектив] склоняется над телом, освещение от уличного фонаря" \
220
+ --refs="@Анна-детектив"
221
+ ```
222
+
223
+ Это даёт **визуальную консистентность** (модель видит то же описание),
224
+ плюс если юзер захочет уточнить персонажа — поправит ОДНУ ноду, не все
225
+ кадры.
226
+
227
+ **2. Universal стиль/съёмка → defaultPrompts сцены.**
228
+
229
+ Если повторяется НЕ конкретный объект, а **как мы снимаем эту сцену**
230
+ (освещение, камера, цветокор, жанр) — кладёшь в `settings.defaultPrompts`
231
+ (см. раздел выше про defaultPrompts). Каждый кадр потом не нуждается в
232
+ этих словах — они автоматически префиксуются ко всем генерациям.
233
+
234
+ Чек-лист при раскадровке:
235
+
236
+ - [ ] Персонажи / локации / реквизит описаны как text/image-ноды → ссылки `@`
237
+ - [ ] Стиль съёмки (камера / свет / палитра / жанр) → `settings.defaultPrompts`
238
+ - [ ] Промпт КАДРА содержит ТОЛЬКО что отличает его от других — действие,
239
+ ракурс, момент в кадре
240
+
241
+ Если соблюсти оба правила — типичный кадр умещается в одну фразу
242
+ («[@Анна-детектив] открывает дверь»), и вся сцена выглядит цельно.
243
+
135
244
  ## ⚠️ Text-ноды — генерируй САМ, не через `kingkont gen --kind=text`
136
245
 
137
246
  Ты — Claude. Когда юзер просит «напиши диалог», «придумай реплику»,