kingkont 0.14.1 → 0.14.3
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/package.json +1 -1
- package/renderer/board.js +44 -0
- package/renderer/chat.js +17 -9
- package/renderer/generate.js +48 -18
- package/renderer/media.js +7 -2
- package/renderer/state.js +25 -0
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -1748,6 +1748,41 @@ $('newLocation').addEventListener('click', async () => {
|
|
|
1748
1748
|
});
|
|
1749
1749
|
|
|
1750
1750
|
// =================== Board (универсально для серии и персонажа) ===================
|
|
1751
|
+
// Если ни одна нода не попадает в видимую область canvas-wrap, скроллим
|
|
1752
|
+
// view на центр bbox всех нод. Используется после selectBoard / openFilm
|
|
1753
|
+
// — типичный кейс: юзер открывает старый проект, scroll был сохранён в
|
|
1754
|
+
// одном месте, ноды расположены далеко (например после chat-добавления).
|
|
1755
|
+
function _autoScrollToNodesIfHidden(padX, padY) {
|
|
1756
|
+
const board = state.currentBoard;
|
|
1757
|
+
if (!board) return;
|
|
1758
|
+
const nodes = board.metadata.nodes || [];
|
|
1759
|
+
if (!nodes.length) return;
|
|
1760
|
+
// Считаем bbox в canvas-coords.
|
|
1761
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1762
|
+
for (const n of nodes) {
|
|
1763
|
+
const w = n.width || 280, h = n.height || 220;
|
|
1764
|
+
minX = Math.min(minX, n.x);
|
|
1765
|
+
minY = Math.min(minY, n.y);
|
|
1766
|
+
maxX = Math.max(maxX, n.x + w);
|
|
1767
|
+
maxY = Math.max(maxY, n.y + h);
|
|
1768
|
+
}
|
|
1769
|
+
const z = state.zoom || 1;
|
|
1770
|
+
// Видимое окно в canvas-coords (viewport / zoom).
|
|
1771
|
+
const wrap = canvasWrap;
|
|
1772
|
+
const viewLeft = (wrap.scrollLeft - padX) / z;
|
|
1773
|
+
const viewTop = (wrap.scrollTop - padY) / z;
|
|
1774
|
+
const viewRight = viewLeft + wrap.clientWidth / z;
|
|
1775
|
+
const viewBottom = viewTop + wrap.clientHeight / z;
|
|
1776
|
+
// Перекрытие? Если bbox нод хоть как-то пересекается с viewport — не трогаем.
|
|
1777
|
+
const intersects = !(maxX < viewLeft || minX > viewRight || maxY < viewTop || minY > viewBottom);
|
|
1778
|
+
if (intersects) return;
|
|
1779
|
+
// Иначе центрируем на bbox.
|
|
1780
|
+
const cx = (minX + maxX) / 2;
|
|
1781
|
+
const cy = (minY + maxY) / 2;
|
|
1782
|
+
wrap.scrollLeft = cx * z + padX - wrap.clientWidth / 2;
|
|
1783
|
+
wrap.scrollTop = cy * z + padY - wrap.clientHeight / 2;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1751
1786
|
async function selectBoard(board) {
|
|
1752
1787
|
// Останавливаем file-watcher предыдущей доски (если был).
|
|
1753
1788
|
stopExternalWatcher();
|
|
@@ -1855,6 +1890,15 @@ async function selectBoard(board) {
|
|
|
1855
1890
|
canvasWrap.scrollTop = padY;
|
|
1856
1891
|
}
|
|
1857
1892
|
|
|
1893
|
+
// Auto-scroll к bbox нод — ТОЛЬКО если view не был сохранён ранее
|
|
1894
|
+
// (т.е. это первый раз открываем доску, или юзер ни разу не скроллил).
|
|
1895
|
+
// Раньше срабатывало всегда — из-за гонки с applyZoomStyles (resize
|
|
1896
|
+
// через setTimeout 200ms) scrollLeft при zoom<1 клампился, bbox казался
|
|
1897
|
+
// невидимым, view перебивался → юзер терял сохранённую позицию.
|
|
1898
|
+
if (!view || (typeof view.scrollLeft !== 'number' && typeof view.scrollTop !== 'number')) {
|
|
1899
|
+
requestAnimationFrame(() => _autoScrollToNodesIfHidden(padX, padY));
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1858
1902
|
// Возобновить незавершённые джобы текущей доски
|
|
1859
1903
|
for (const n of state.currentBoard.metadata.nodes) {
|
|
1860
1904
|
if (n.status === 'generating' && n.generated?.taskId && !state.jobs.has(n.id)) {
|
package/renderer/chat.js
CHANGED
|
@@ -91,17 +91,17 @@
|
|
|
91
91
|
file: n.file || null,
|
|
92
92
|
status: n.status || (n.file ? 'done' : 'draft'),
|
|
93
93
|
modelKey: n.generated?.modelKey || null,
|
|
94
|
+
aspectRatio: n.generated?.aspectRatio || null,
|
|
94
95
|
}));
|
|
95
96
|
const connections = (b.metadata.connections || []).map(c => ({ from: c.from, to: c.to, toPort: c.toPort || null }));
|
|
96
|
-
|
|
97
|
-
return { kind: b.kind, name: b.name, nodes, connections, settings };
|
|
97
|
+
return { kind: b.kind, name: b.name, nodes, connections, settings: b.metadata.settings || {} };
|
|
98
98
|
},
|
|
99
99
|
},
|
|
100
100
|
|
|
101
101
|
add_node: {
|
|
102
|
-
description: 'Добавить ноду на текущую доску. Для image/video/audio
|
|
103
|
-
params: '{"type":"image|video|audio|text","subKind":"music|sfx|voice (only for audio)","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>","durationMs":<for-music-or-sfx>}',
|
|
104
|
-
async handler({ type, subKind, name, prompt, x, y, text, modelKey, durationMs }) {
|
|
102
|
+
description: 'Добавить ноду на текущую доску. Для image/video/audio с prompt — нода будет draft (юзер запустит generation отдельно или используй generate_node). Для audio: subKind="music"|"sfx"|"voice" (default: voice/TTS). aspectRatio: "16:9"|"9:16"|"1:1"|"3:4"|"4:3"|"21:9" — переопределяет дефолтный для текущей сцены. ВАЖНО: если юзер сказал "горизонтальная" — 16:9, "вертикальная" — 9:16, "квадрат" — 1:1.',
|
|
103
|
+
params: '{"type":"image|video|audio|text","subKind":"music|sfx|voice (only for audio)","name":"<optional>","prompt":"<optional>","x":<optional>,"y":<optional>,"text":"<for-type=text>","modelKey":"<optional>","durationMs":<for-music-or-sfx>,"aspectRatio":"<optional, for image/video>"}',
|
|
104
|
+
async handler({ type, subKind, name, prompt, x, y, text, modelKey, durationMs, aspectRatio }) {
|
|
105
105
|
if (!state.currentBoard) throw new Error('доска не выбрана');
|
|
106
106
|
if (!['image','video','audio','text','label'].includes(type)) throw new Error(`unknown type: ${type}`);
|
|
107
107
|
// Поиск незанятого места: пытаемся справа от последней ноды,
|
|
@@ -152,6 +152,13 @@
|
|
|
152
152
|
if (prompt && type !== 'text' && type !== 'label') {
|
|
153
153
|
node.status = 'draft';
|
|
154
154
|
node.generated = { rawPrompt: prompt, prompt, modelKey: modelKey || undefined };
|
|
155
|
+
// aspectRatio в node.generated — startGenerationJob/submitBody читает
|
|
156
|
+
// node.generated.aspectRatio как первый приоритет (выше чем
|
|
157
|
+
// board-level setting). Без этого новая нода берёт scene aspect,
|
|
158
|
+
// и пользовательское «горизонтальная» теряется.
|
|
159
|
+
if (aspectRatio && (type === 'image' || type === 'video')) {
|
|
160
|
+
node.generated.aspectRatio = aspectRatio;
|
|
161
|
+
}
|
|
155
162
|
// Audio имеет 3 sub-kind'а: voice (TTS — дефолт), music, sfx.
|
|
156
163
|
// generate_node роутит в нужный job по этому полю.
|
|
157
164
|
if (type === 'audio') {
|
|
@@ -184,7 +191,7 @@
|
|
|
184
191
|
},
|
|
185
192
|
|
|
186
193
|
connect_nodes: {
|
|
187
|
-
description: 'Соединить две ноды (from → to). toPort: "image"
|
|
194
|
+
description: 'Соединить две ноды (from → to). toPort: "image"|"video"|"audio" — куда подаём ссылку (опционально, для @-резолва при генерации).',
|
|
188
195
|
params: '{"from":"<id>","to":"<id>","toPort":"image|video|audio (optional)"}',
|
|
189
196
|
async handler({ from, to, toPort }) {
|
|
190
197
|
const b = state.currentBoard;
|
|
@@ -213,7 +220,6 @@
|
|
|
213
220
|
const el = document.querySelector(`.node[data-id="${id}"]`);
|
|
214
221
|
if (typeof deleteNode === 'function') await deleteNode(node, el);
|
|
215
222
|
else {
|
|
216
|
-
// Fallback: просто убираем из массива.
|
|
217
223
|
b.metadata.nodes = b.metadata.nodes.filter(n => n.id !== id);
|
|
218
224
|
b.metadata.connections = (b.metadata.connections || []).filter(c => c.from !== id && c.to !== id);
|
|
219
225
|
scheduleSave();
|
|
@@ -248,7 +254,7 @@
|
|
|
248
254
|
},
|
|
249
255
|
|
|
250
256
|
add_clip_to_timeline: {
|
|
251
|
-
description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан —
|
|
257
|
+
description: 'Добавить ноду как клип в таймлайн. trackKind="video" для image/video, "audio" для audio. Если start не задан — в конец дорожки.',
|
|
252
258
|
params: '{"nodeId":"<node-id>","trackKind":"video|audio","start":<seconds (optional)>,"duration":<seconds (optional, default по типу)>}',
|
|
253
259
|
async handler({ nodeId, trackKind, start, duration }) {
|
|
254
260
|
const b = state.currentBoard;
|
|
@@ -337,7 +343,7 @@
|
|
|
337
343
|
},
|
|
338
344
|
|
|
339
345
|
generate_node: {
|
|
340
|
-
description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа
|
|
346
|
+
description: 'Запустить генерацию для draft-ноды (image/video/audio/text). Стартует напрямую в фоне БЕЗ показа диалога.',
|
|
341
347
|
params: '{"id":"<node-id>"}',
|
|
342
348
|
async handler({ id }) {
|
|
343
349
|
const b = state.currentBoard;
|
|
@@ -410,6 +416,8 @@
|
|
|
410
416
|
lines.push('- Не выдумывай id нод — id это случайные UUID, угадать нельзя.');
|
|
411
417
|
lines.push(' - Если нужен id существующей ноды — сначала read_scene (вернёт массив с реальными id).');
|
|
412
418
|
lines.push(' - После add_node — id новой ноды лежит в result.id ответа этого вызова.');
|
|
419
|
+
lines.push('- НЕ упоминай id в текстовых сообщениях пользователю (это шум). Используй имена нод.');
|
|
420
|
+
lines.push(' Примеры: «Готово, добавил картинку «Закат»», а не «Готово, добавил картинку id=abc-123».');
|
|
413
421
|
lines.push('- Не выдумывай имена сцен — сначала list_scenes.');
|
|
414
422
|
lines.push('- Для генерации сразу после add_node — ВОЗЬМИ id из result.id ПРЕДЫДУЩЕГО вызова и передай в generate_node.');
|
|
415
423
|
lines.push('- Когда нужно создать несколько нод сразу — выдавай add_node + generate_node чередуя, или сначала все add_node, потом все generate_node (используя id из их result-ов).');
|
package/renderer/generate.js
CHANGED
|
@@ -1863,52 +1863,76 @@ async function resumeJob(node, bKey, boardHandle) {
|
|
|
1863
1863
|
if (state.jobs.has(node.id)) return;
|
|
1864
1864
|
if (!node.generated || node.status !== 'generating') return;
|
|
1865
1865
|
|
|
1866
|
+
// Маршрутизация по kind/subKind. Без этого music/sfx падали на
|
|
1867
|
+
// resume (всё уходило в runTTSJob).
|
|
1868
|
+
const kind = node.generated.kind;
|
|
1869
|
+
const subKind = node.generated.subKind || (kind === 'audio' ? 'voice' : null);
|
|
1870
|
+
|
|
1866
1871
|
if (node.generated.taskId) {
|
|
1867
|
-
// Уже зарегистрировано в KIE — просто
|
|
1868
|
-
const job = { boardKey: bKey, boardHandle, kind
|
|
1872
|
+
// Уже зарегистрировано в KIE — просто опрашиваем.
|
|
1873
|
+
const job = { boardKey: bKey, boardHandle, kind, taskId: node.generated.taskId, nodeId: node.id };
|
|
1869
1874
|
state.jobs.set(node.id, job);
|
|
1870
1875
|
updateJobsBadge();
|
|
1871
1876
|
try {
|
|
1872
|
-
await pollJob(job, node.id, bKey, boardHandle,
|
|
1877
|
+
await pollJob(job, node.id, bKey, boardHandle, kind);
|
|
1873
1878
|
} catch (e) {
|
|
1874
1879
|
await mutateNode(bKey, boardHandle, node.id, n => { n.status = 'error'; n.error = e.message; });
|
|
1875
1880
|
state.jobs.delete(node.id);
|
|
1876
1881
|
updateJobsBadge();
|
|
1877
1882
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
if (
|
|
1883
|
-
await
|
|
1884
|
-
} else if (
|
|
1885
|
-
|
|
1886
|
-
const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
|
|
1887
|
-
await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
// Перезагрузка случилась до сабмита: рестарт с фазы upload+submit.
|
|
1886
|
+
if (kind === 'audio') {
|
|
1887
|
+
if (subKind === 'music' && typeof runMusicJob === 'function') {
|
|
1888
|
+
await runMusicJob(node, node.generated.prompt, node.generated.durationMs || null, boardHandle, bKey);
|
|
1889
|
+
} else if (subKind === 'sfx' && typeof runSfxJob === 'function') {
|
|
1890
|
+
await runSfxJob(node, node.generated.prompt, node.generated.durationMs ? node.generated.durationMs / 1000 : null, boardHandle, bKey);
|
|
1888
1891
|
} else {
|
|
1889
|
-
|
|
1890
|
-
await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
|
|
1892
|
+
await runTTSJob(node, node.generated.prompt, boardHandle, bKey, node.generated.voiceId);
|
|
1891
1893
|
}
|
|
1894
|
+
} else if (kind === 'text') {
|
|
1895
|
+
const imageRefs = (node.generated.refs || []).filter(r => r.type === 'image' && r.file);
|
|
1896
|
+
const model = node.generated.model || node.generated.modelKey || 'anthropic/claude-sonnet-4';
|
|
1897
|
+
await runTextJob(node, node.generated.prompt, model, boardHandle, bKey, imageRefs);
|
|
1898
|
+
} else {
|
|
1899
|
+
const refs = (node.generated.refs || []).map(r => ({ name: r.name, type: r.type, file: r.file }));
|
|
1900
|
+
await startGenerationJob(node, kind, node.generated.prompt, refs, boardHandle, bKey, node.generated.modelKey);
|
|
1892
1901
|
}
|
|
1893
1902
|
}
|
|
1894
1903
|
|
|
1904
|
+
// Сканируем ВСЕ доски проекта (episodes + characters + locations) на
|
|
1905
|
+
// зависшие генерации. Раньше только episodes+characters → location-нода
|
|
1906
|
+
// в статусе 'generating' оставалась навсегда.
|
|
1907
|
+
// Resume и для нод БЕЗ taskId (рестарт submit'a) — раньше пропускали.
|
|
1895
1908
|
async function scanAllBoardsForPendingJobs(filmHandle) {
|
|
1896
|
-
const [eps, chars] = await Promise.all([
|
|
1909
|
+
const [eps, chars, locs] = await Promise.all([
|
|
1910
|
+
listEpisodes(filmHandle),
|
|
1911
|
+
listCharacters(filmHandle),
|
|
1912
|
+
listLocations(filmHandle),
|
|
1913
|
+
]);
|
|
1897
1914
|
const all = [
|
|
1898
1915
|
...eps.map(b => ({ kind: 'episode', ...b })),
|
|
1899
1916
|
...chars.map(b => ({ kind: 'character', ...b })),
|
|
1917
|
+
...locs.map(b => ({ kind: 'location', ...b })),
|
|
1900
1918
|
];
|
|
1919
|
+
let resumed = 0;
|
|
1901
1920
|
for (const b of all) {
|
|
1902
1921
|
try {
|
|
1903
1922
|
const meta = await loadBoardMetadata(b.handle);
|
|
1904
1923
|
const bKey = boardKey(b.kind, b.name);
|
|
1905
1924
|
for (const n of meta.nodes) {
|
|
1906
|
-
if (n.status === 'generating' &&
|
|
1907
|
-
|
|
1925
|
+
if (n.status === 'generating' && !state.jobs.has(n.id)) {
|
|
1926
|
+
resumed++;
|
|
1927
|
+
// resumeJob handles BOTH taskId-есть (poll) и taskId-нет (restart submit).
|
|
1928
|
+
resumeJob(n, bKey, b.handle).catch(e => console.warn('resume failed', n.id, e?.message));
|
|
1908
1929
|
}
|
|
1909
1930
|
}
|
|
1910
1931
|
} catch (e) { console.warn('scan board failed', b.name, e); }
|
|
1911
1932
|
}
|
|
1933
|
+
if (resumed) {
|
|
1934
|
+
showToast(`▶ Возобновлено ${resumed} незаконч. генераций`, 'info');
|
|
1935
|
+
}
|
|
1912
1936
|
}
|
|
1913
1937
|
|
|
1914
1938
|
async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
@@ -1947,8 +1971,10 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
1947
1971
|
const cost = typeof pd.cost === 'number' ? pd.cost : null;
|
|
1948
1972
|
if (cost !== null) logJob(nodeId, `списано ${cost} credits`);
|
|
1949
1973
|
logJob(nodeId, `done → file=${relPath} (${blob.size} bytes)`);
|
|
1974
|
+
let nodeName;
|
|
1950
1975
|
await mutateNode(bKey, boardHandle, nodeId, n => {
|
|
1951
1976
|
n.status = undefined; n.error = undefined; n.file = relPath;
|
|
1977
|
+
nodeName = n.name;
|
|
1952
1978
|
if (cost !== null) {
|
|
1953
1979
|
n.generated = { ...(n.generated || {}), creditsCharged: cost };
|
|
1954
1980
|
}
|
|
@@ -1956,6 +1982,10 @@ async function pollJob(job, nodeId, bKey, boardHandle, kind) {
|
|
|
1956
1982
|
state.jobs.delete(nodeId);
|
|
1957
1983
|
updateJobsBadge();
|
|
1958
1984
|
if (typeof window.refreshBalance === 'function') window.refreshBalance();
|
|
1985
|
+
// Toast если завершилось НЕ на текущей доске (юзер мог переключиться).
|
|
1986
|
+
if (typeof showToast === 'function' && state.currentBoard?.key !== bKey) {
|
|
1987
|
+
showToast(`✓ ${kind} «${nodeName || nodeId.slice(0,6)}» готов в ${bKey}`, 'ok');
|
|
1988
|
+
}
|
|
1959
1989
|
return;
|
|
1960
1990
|
}
|
|
1961
1991
|
if (pd.status === 'error') {
|
package/renderer/media.js
CHANGED
|
@@ -216,8 +216,13 @@ function triggerDownload(blob, filename) {
|
|
|
216
216
|
function findFreeSpot(w = 280, h = 320) {
|
|
217
217
|
const nodes = state.currentBoard?.metadata?.nodes || [];
|
|
218
218
|
const margin = 24;
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
// ВАЖНО: учесть canvas-padding (после v0.12.0). scrollLeft теперь
|
|
220
|
+
// включает padX. Без вычитания startX оказывался ~+2000 от content
|
|
221
|
+
// origin'a и нода создавалась за экраном (типичный кейс — музыка
|
|
222
|
+
// не появлялась после клика на 🎵 кнопку).
|
|
223
|
+
const { padX, padY } = _getFramePadding();
|
|
224
|
+
const startX = (canvasWrap.scrollLeft - padX) / state.zoom + 40;
|
|
225
|
+
const startY = (canvasWrap.scrollTop - padY) / state.zoom + 40;
|
|
221
226
|
const step = 60;
|
|
222
227
|
for (let y = startY; y < startY + 3000; y += step) {
|
|
223
228
|
for (let x = startX; x < startX + 3000; x += step) {
|
package/renderer/state.js
CHANGED
|
@@ -70,6 +70,31 @@ async function listCharacters(filmHandle) {
|
|
|
70
70
|
} catch { return []; }
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Глобальный toast для информативных уведомлений (генерация завершилась,
|
|
74
|
+
// resume сработал, ...). Стек справа сверху, auto-dismiss через 5s.
|
|
75
|
+
function showToast(text, kind) {
|
|
76
|
+
let host = document.getElementById('toastHost');
|
|
77
|
+
if (!host) {
|
|
78
|
+
host = document.createElement('div');
|
|
79
|
+
host.id = 'toastHost';
|
|
80
|
+
host.style.cssText = 'position:fixed; right:16px; top:64px; z-index:9999; display:flex; flex-direction:column; gap:6px; pointer-events:none;';
|
|
81
|
+
document.body.appendChild(host);
|
|
82
|
+
}
|
|
83
|
+
const t = document.createElement('div');
|
|
84
|
+
const colors = {
|
|
85
|
+
ok: 'background:#1a3a1a; border:1px solid #2a6a3a; color:#9efa9e;',
|
|
86
|
+
error: 'background:#3a1a1a; border:1px solid #8a2a2a; color:#fa9e9e;',
|
|
87
|
+
info: 'background:#1a2a3a; border:1px solid #2a4a6a; color:#9ecdfa;',
|
|
88
|
+
};
|
|
89
|
+
t.style.cssText = `${colors[kind] || colors.info} padding:8px 14px; border-radius:6px; font-size:12px; pointer-events:auto; box-shadow:0 4px 16px rgba(0,0,0,0.4); animation:toastIn 0.18s ease-out;`;
|
|
90
|
+
t.textContent = text;
|
|
91
|
+
t.addEventListener('click', () => t.remove());
|
|
92
|
+
host.appendChild(t);
|
|
93
|
+
setTimeout(() => t.remove(), 5000);
|
|
94
|
+
return t;
|
|
95
|
+
}
|
|
96
|
+
window.showToast = showToast;
|
|
97
|
+
|
|
73
98
|
async function listLocations(filmHandle) {
|
|
74
99
|
try {
|
|
75
100
|
const root = await filmHandle.getDirectoryHandle(LOC_DIR);
|