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.
- package/assets/templates.jpg +0 -0
- package/index.html +42 -3
- package/lib/providers.js +131 -2
- package/main.js +120 -0
- package/package.json +1 -1
- package/preload.js +38 -12
- package/renderer/board.js +345 -87
- package/renderer/cloudFs.js +271 -0
- package/renderer/cloudProjects.js +612 -0
- package/renderer/generate.js +7 -0
- package/renderer/settings.js +40 -1
- package/renderer/state.js +5 -0
- package/renderer/styles.css +97 -17
- package/renderer/templates.js +286 -62
- package/renderer/timeline.js +149 -7
- package/server.js +63 -1
- package/skill/SKILL.md +109 -0
package/renderer/timeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2678
|
-
|
|
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 = {
|
|
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. Когда юзер просит «напиши диалог», «придумай реплику»,
|