kingkont 0.7.39 → 0.7.41
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/PROJECT_CLAUDE.md +31 -132
- package/bin/kingkont.js +59 -49
- package/index.html +9 -10137
- package/lib/cli.js +504 -0
- package/lib/projectFs.js +391 -0
- package/lib/providers.js +689 -0
- package/lib/settings.js +70 -0
- package/main.js +28 -0
- package/package.json +6 -1
- package/preload.js +7 -0
- package/renderer/board.js +1522 -0
- package/renderer/generate.js +1727 -0
- package/renderer/media.js +1413 -0
- package/renderer/settings.js +1128 -0
- package/renderer/state.js +566 -0
- package/renderer/styles.css +1018 -0
- package/renderer/timeline.js +2836 -0
- package/server.js +103 -787
- package/settings.html +56 -0
- package/skill/SKILL.md +160 -78
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// renderer/state.js — IndexedDB + File System Access helpers, scene.json IO, app state, history (undo/redo)
|
|
2
|
+
//
|
|
3
|
+
// Этот модуль был выделен из index.html (раньше всё было в одном <script>
|
|
4
|
+
// блоке на 9123 строки). Все модули загружаются как plain <script>
|
|
5
|
+
// в одном глобальном scope — поэтому функции/переменные между файлами
|
|
6
|
+
// видят друг друга по именам, без import/export. Порядок загрузки
|
|
7
|
+
// важен: см. <script> теги внизу index.html.
|
|
8
|
+
|
|
9
|
+
// =================== IndexedDB (хэндл папки фильма) ===================
|
|
10
|
+
const DB_NAME = 'video-editor';
|
|
11
|
+
const STORE = 'handles';
|
|
12
|
+
|
|
13
|
+
function openDB() {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
16
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
17
|
+
req.onsuccess = () => resolve(req.result);
|
|
18
|
+
req.onerror = () => reject(req.error);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function idbGet(key) {
|
|
22
|
+
const db = await openDB();
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const r = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
|
|
25
|
+
r.onsuccess = () => resolve(r.result);
|
|
26
|
+
r.onerror = () => reject(r.error);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function idbSet(key, value) {
|
|
30
|
+
const db = await openDB();
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const tx = db.transaction(STORE, 'readwrite');
|
|
33
|
+
tx.objectStore(STORE).put(value, key);
|
|
34
|
+
tx.oncomplete = () => resolve();
|
|
35
|
+
tx.onerror = () => reject(tx.error);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =================== File system helpers ===================
|
|
40
|
+
async function verifyPermission(handle, mode = 'readwrite') {
|
|
41
|
+
const opts = { mode };
|
|
42
|
+
if ((await handle.queryPermission(opts)) === 'granted') return true;
|
|
43
|
+
if ((await handle.requestPermission(opts)) === 'granted') return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CHAR_DIR = '_characters';
|
|
48
|
+
const LOC_DIR = '_locations';
|
|
49
|
+
|
|
50
|
+
async function listEpisodes(filmHandle) {
|
|
51
|
+
const folders = [];
|
|
52
|
+
for await (const [name, h] of filmHandle.entries()) {
|
|
53
|
+
if (h.kind === 'directory' && !name.startsWith('_') && !name.startsWith('.')) {
|
|
54
|
+
folders.push({ name, handle: h });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
folders.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
|
58
|
+
return folders;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listCharacters(filmHandle) {
|
|
62
|
+
try {
|
|
63
|
+
const root = await filmHandle.getDirectoryHandle(CHAR_DIR);
|
|
64
|
+
const folders = [];
|
|
65
|
+
for await (const [name, h] of root.entries()) {
|
|
66
|
+
if (h.kind === 'directory') folders.push({ name, handle: h });
|
|
67
|
+
}
|
|
68
|
+
folders.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
|
69
|
+
return folders;
|
|
70
|
+
} catch { return []; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function listLocations(filmHandle) {
|
|
74
|
+
try {
|
|
75
|
+
const root = await filmHandle.getDirectoryHandle(LOC_DIR);
|
|
76
|
+
const folders = [];
|
|
77
|
+
for await (const [name, h] of root.entries()) {
|
|
78
|
+
if (h.kind === 'directory') folders.push({ name, handle: h });
|
|
79
|
+
}
|
|
80
|
+
folders.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
|
81
|
+
return folders;
|
|
82
|
+
} catch { return []; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fileExists(handle, name) {
|
|
86
|
+
try { await handle.getFileHandle(name); return true; } catch { return false; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function uniqueName(handle, name) {
|
|
90
|
+
if (!(await fileExists(handle, name))) return name;
|
|
91
|
+
const dot = name.lastIndexOf('.');
|
|
92
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
93
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
94
|
+
let i = 1;
|
|
95
|
+
while (await fileExists(handle, `${base}_${i}${ext}`)) i++;
|
|
96
|
+
return `${base}_${i}${ext}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeFile(dirHandle, name, data) {
|
|
100
|
+
const fh = await dirHandle.getFileHandle(name, { create: true });
|
|
101
|
+
const w = await fh.createWritable();
|
|
102
|
+
await w.write(data);
|
|
103
|
+
await w.close();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function importFile(dirHandle, file) {
|
|
107
|
+
const name = await uniqueName(dirHandle, file.name);
|
|
108
|
+
await writeFile(dirHandle, name, file);
|
|
109
|
+
return name;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// =================== Раскладка по подпапкам внутри доски ===================
|
|
113
|
+
// clips/ — видео, frames/ — картинки, audio/ — звуки, texts/ — .md
|
|
114
|
+
function boardSubdirForType(type) {
|
|
115
|
+
if (type === 'video') return 'clips';
|
|
116
|
+
if (type === 'image') return 'frames';
|
|
117
|
+
if (type === 'audio') return 'audio';
|
|
118
|
+
if (type === 'text') return 'texts';
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const BOARD_SUBDIRS = ['clips', 'frames', 'audio', 'texts'];
|
|
123
|
+
|
|
124
|
+
async function getOrCreateBoardSubdir(boardHandle, name) {
|
|
125
|
+
return await boardHandle.getDirectoryHandle(name, { create: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Получить FileHandle по relPath (поддерживает 'clips/x.mp4', легаси 'x.mp4' и устаревшие пути)
|
|
129
|
+
async function resolveBoardFile(boardHandle, relPath) {
|
|
130
|
+
if (!relPath) throw new Error('empty path');
|
|
131
|
+
const tryDirect = async (h, p) => {
|
|
132
|
+
const parts = p.split('/');
|
|
133
|
+
let cur = h;
|
|
134
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
135
|
+
cur = await cur.getDirectoryHandle(parts[i]);
|
|
136
|
+
}
|
|
137
|
+
return cur.getFileHandle(parts[parts.length - 1]);
|
|
138
|
+
};
|
|
139
|
+
if (relPath.includes('/')) {
|
|
140
|
+
try { return await tryDirect(boardHandle, relPath); } catch {}
|
|
141
|
+
}
|
|
142
|
+
// Фоллбэк по basename: пробуем рут, затем известные подпапки
|
|
143
|
+
const bare = relPath.split('/').pop();
|
|
144
|
+
try { return await boardHandle.getFileHandle(bare); } catch {}
|
|
145
|
+
for (const sub of BOARD_SUBDIRS) {
|
|
146
|
+
try {
|
|
147
|
+
const h = await boardHandle.getDirectoryHandle(sub);
|
|
148
|
+
return await h.getFileHandle(bare);
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`Файл не найден: ${relPath}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Записать файл по relPath (создаёт папки)
|
|
155
|
+
async function writeBoardFile(boardHandle, relPath, data) {
|
|
156
|
+
const parts = relPath.split('/');
|
|
157
|
+
let h = boardHandle;
|
|
158
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
159
|
+
h = await h.getDirectoryHandle(parts[i], { create: true });
|
|
160
|
+
}
|
|
161
|
+
const fh = await h.getFileHandle(parts[parts.length - 1], { create: true });
|
|
162
|
+
const w = await fh.createWritable();
|
|
163
|
+
await w.write(data);
|
|
164
|
+
await w.close();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Уникальное имя в подпапке (или в руте если subdir==null)
|
|
168
|
+
async function uniqueBoardFilename(boardHandle, subdir, name) {
|
|
169
|
+
const dir = subdir ? await getOrCreateBoardSubdir(boardHandle, subdir) : boardHandle;
|
|
170
|
+
return await uniqueName(dir, name);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Импорт File-объекта в типовую подпапку, возвращает relPath
|
|
174
|
+
async function importToBoard(boardHandle, file, type) {
|
|
175
|
+
const sub = boardSubdirForType(type);
|
|
176
|
+
if (!sub) {
|
|
177
|
+
const n = await uniqueName(boardHandle, file.name);
|
|
178
|
+
await writeFile(boardHandle, n, file);
|
|
179
|
+
return n;
|
|
180
|
+
}
|
|
181
|
+
const dir = await getOrCreateBoardSubdir(boardHandle, sub);
|
|
182
|
+
const n = await uniqueName(dir, file.name);
|
|
183
|
+
await writeFile(dir, n, file);
|
|
184
|
+
return `${sub}/${n}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Удалить файл по relPath (используется в deleteNode)
|
|
188
|
+
async function removeBoardFile(boardHandle, relPath) {
|
|
189
|
+
if (relPath.includes('/')) {
|
|
190
|
+
const parts = relPath.split('/');
|
|
191
|
+
let h = boardHandle;
|
|
192
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
193
|
+
h = await h.getDirectoryHandle(parts[i]);
|
|
194
|
+
}
|
|
195
|
+
await h.removeEntry(parts[parts.length - 1]);
|
|
196
|
+
} else {
|
|
197
|
+
// легаси: пробуем рут, затем подпапки
|
|
198
|
+
try { await boardHandle.removeEntry(relPath); return; } catch {}
|
|
199
|
+
for (const sub of BOARD_SUBDIRS) {
|
|
200
|
+
try {
|
|
201
|
+
const h = await boardHandle.getDirectoryHandle(sub);
|
|
202
|
+
await h.removeEntry(relPath);
|
|
203
|
+
return;
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`Файл не найден для удаления: ${relPath}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Чтение бинаря/текста файла по relPath
|
|
211
|
+
async function readBoardFile(boardHandle, relPath) {
|
|
212
|
+
const fh = await resolveBoardFile(boardHandle, relPath);
|
|
213
|
+
return await fh.getFile();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function defaultNameForFile(filename) {
|
|
217
|
+
const dot = filename.lastIndexOf('.');
|
|
218
|
+
return dot > 0 ? filename.slice(0, dot) : filename;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function uniqueNodeName(nodes, base) {
|
|
222
|
+
if (!nodes.some(n => n.name === base)) return base;
|
|
223
|
+
let i = 2;
|
|
224
|
+
while (nodes.some(n => n.name === `${base}-${i}`)) i++;
|
|
225
|
+
return `${base}-${i}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function slugifyPrompt(prompt, maxWords = 4) {
|
|
229
|
+
if (!prompt) return '';
|
|
230
|
+
return prompt
|
|
231
|
+
.toLowerCase()
|
|
232
|
+
.replace(/[.,!?;:'"«»()\[\]{}<>\/\\@#%^&*=+|`~]/g, '')
|
|
233
|
+
.split(/\s+/)
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.slice(0, maxWords)
|
|
236
|
+
.join('-')
|
|
237
|
+
.slice(0, 48);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function loadBoardMetadata(boardHandle) {
|
|
241
|
+
let migrated = false;
|
|
242
|
+
let nodes = [];
|
|
243
|
+
let connections = [];
|
|
244
|
+
let legacyEditor = false;
|
|
245
|
+
|
|
246
|
+
let view = null;
|
|
247
|
+
let character = null;
|
|
248
|
+
let location = null;
|
|
249
|
+
let timeline = null;
|
|
250
|
+
let history = null;
|
|
251
|
+
// 1) Пробуем scene.json
|
|
252
|
+
try {
|
|
253
|
+
const fh = await boardHandle.getFileHandle('scene.json');
|
|
254
|
+
const data = JSON.parse(await (await fh.getFile()).text());
|
|
255
|
+
nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
|
256
|
+
connections = Array.isArray(data.connections) ? data.connections : [];
|
|
257
|
+
view = data.view || null;
|
|
258
|
+
character = data.character || null;
|
|
259
|
+
location = data.location || null;
|
|
260
|
+
timeline = data.timeline || null;
|
|
261
|
+
history = data.history || null;
|
|
262
|
+
} catch {
|
|
263
|
+
// 2) Легаси: .editor.json
|
|
264
|
+
try {
|
|
265
|
+
const fh = await boardHandle.getFileHandle('.editor.json');
|
|
266
|
+
const data = JSON.parse(await (await fh.getFile()).text());
|
|
267
|
+
nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
|
268
|
+
connections = Array.isArray(data.connections) ? data.connections : [];
|
|
269
|
+
view = data.view || null;
|
|
270
|
+
character = data.character || null;
|
|
271
|
+
location = data.location || null;
|
|
272
|
+
timeline = data.timeline || null;
|
|
273
|
+
legacyEditor = true;
|
|
274
|
+
migrated = true;
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Миграция/загрузка текстовых нод из .md
|
|
279
|
+
for (const n of nodes) {
|
|
280
|
+
if (n.type !== 'text') continue;
|
|
281
|
+
if (n.file) {
|
|
282
|
+
try {
|
|
283
|
+
const fh = await resolveBoardFile(boardHandle, n.file);
|
|
284
|
+
n.text = await (await fh.getFile()).text();
|
|
285
|
+
} catch { n.text = ''; }
|
|
286
|
+
} else {
|
|
287
|
+
// Легаси: текст инлайном — переезжает в .md в texts/
|
|
288
|
+
const dir = await getOrCreateBoardSubdir(boardHandle, 'texts');
|
|
289
|
+
const baseName = await uniqueName(dir, (n.name || 'text') + '.md');
|
|
290
|
+
await writeFile(dir, baseName, n.text || '');
|
|
291
|
+
n.file = `texts/${baseName}`;
|
|
292
|
+
migrated = true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Удалить старый .editor.json после успешной миграции в scene.json
|
|
297
|
+
if (legacyEditor) {
|
|
298
|
+
try { await boardHandle.removeEntry('.editor.json'); } catch {}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { nodes, connections, view, character, location, timeline, history, _migrated: migrated };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function saveBoardMetadata(boardHandle, meta) {
|
|
305
|
+
for (const n of meta.nodes) {
|
|
306
|
+
if (n.type === 'text' && n.file) {
|
|
307
|
+
try { await writeBoardFile(boardHandle, n.file, n.text || ''); }
|
|
308
|
+
catch (e) { console.error('save .md failed', n.file, e); }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const nodesForJson = meta.nodes.map(n => {
|
|
312
|
+
const out = { ...n };
|
|
313
|
+
delete out.text;
|
|
314
|
+
return out;
|
|
315
|
+
});
|
|
316
|
+
const payload = {
|
|
317
|
+
nodes: nodesForJson,
|
|
318
|
+
connections: meta.connections || [],
|
|
319
|
+
view: meta.view || null,
|
|
320
|
+
character: meta.character || null,
|
|
321
|
+
location: meta.location || null,
|
|
322
|
+
timeline: meta.timeline || null,
|
|
323
|
+
history: meta.history && (meta.history.past?.length || meta.history.future?.length)
|
|
324
|
+
? meta.history : null,
|
|
325
|
+
};
|
|
326
|
+
await writeFile(boardHandle, 'scene.json', JSON.stringify(payload, null, 2));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getFileType(file) {
|
|
330
|
+
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
|
331
|
+
if (['md','markdown'].includes(ext)) return 'text';
|
|
332
|
+
if (file.type.startsWith('video/')) return 'video';
|
|
333
|
+
if (file.type.startsWith('audio/')) return 'audio';
|
|
334
|
+
if (file.type.startsWith('image/')) return 'image';
|
|
335
|
+
if (['mp4','mov','webm','mkv','avi','m4v'].includes(ext)) return 'video';
|
|
336
|
+
if (['mp3','wav','ogg','oga','m4a','flac','aac'].includes(ext)) return 'audio';
|
|
337
|
+
if (['png','jpg','jpeg','gif','webp','bmp','svg','heic','avif'].includes(ext)) return 'image';
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// =================== State ===================
|
|
342
|
+
const state = {
|
|
343
|
+
filmHandle: null,
|
|
344
|
+
currentBoard: null, // { kind, name, key, handle, metadata, urls }
|
|
345
|
+
genKind: 'image',
|
|
346
|
+
imageModel: 'nano-banana-2', // 'nano-banana-2' | 'grok' | ...
|
|
347
|
+
imageAspect: localStorage.getItem('imageAspect') || '1:1', // 1:1 / 16:9 / 9:16 / 3:2 / 2:3 / 4:3 / 3:4
|
|
348
|
+
videoModel: localStorage.getItem('videoModel') || 'seedance-2', // 'seedance-2' | 'kling-o1' | 'kling-3.0' | ...
|
|
349
|
+
ttsModel: localStorage.getItem('ttsModel') || 'qwen/qwen3-tts', // qwen/elevenlabs/v3/minimax/speech-02-hd/gemini
|
|
350
|
+
videoDuration: +(localStorage.getItem('videoDuration') || 5),
|
|
351
|
+
videoResolution: localStorage.getItem('videoResolution') || '720p',
|
|
352
|
+
videoAspect: localStorage.getItem('videoAspect') || '9:16',
|
|
353
|
+
jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
|
|
354
|
+
undoStack: [], // {type, ...payload}
|
|
355
|
+
zoom: 1,
|
|
356
|
+
pendingConnectionFrom: null, // id ноды-источника при drag-line → modal
|
|
357
|
+
addMenuPos: null, // {clientX, clientY} клика
|
|
358
|
+
charactersInfo: [], // [{name, handle, voice, voiceName, tone, commonTones}]
|
|
359
|
+
locationsInfo: [], // [{name, handle, sheet}]
|
|
360
|
+
pickedCharNames: new Set(), // выбранные персонажи в gen-модалке (имена)
|
|
361
|
+
pickedLocName: null, // выбранная локация в gen-модалке
|
|
362
|
+
selectedTrackIds: new Set(), // выделение дорожек таймлайна
|
|
363
|
+
selectedClipIds: new Set(), // выделение клипов таймлайна
|
|
364
|
+
timelineGenTarget: null, // {trackId, time, charName} — куда положить будущий audio-клип
|
|
365
|
+
selectedNodeIds: new Set(), // мультивыделение
|
|
366
|
+
clipboard: [], // [{node, blob?, textContent?}] для Cmd+C/V между досками
|
|
367
|
+
regenerateTarget: null, // нода для перегенерации (при открытии modal)
|
|
368
|
+
activeTones: [], // активные тоны для текущей голосовой генерации
|
|
369
|
+
toneSuggestions: [], // подсказки для tone-input (commonTones из персонажа + дефолты)
|
|
370
|
+
jobLogs: new Map(), // nodeId -> [{ts, msg}]
|
|
371
|
+
timelineZoom: parseFloat(localStorage.getItem('timelineZoom') || '50'), // px на секунду
|
|
372
|
+
playheadTime: 0, // текущая позиция playhead в секундах
|
|
373
|
+
playheadStart: null, // {time, ts} для tracking при play
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
function logJob(nodeId, msg) {
|
|
377
|
+
if (!state.jobLogs.has(nodeId)) state.jobLogs.set(nodeId, []);
|
|
378
|
+
const arr = state.jobLogs.get(nodeId);
|
|
379
|
+
arr.push({ ts: Date.now(), msg });
|
|
380
|
+
if (arr.length > 1000) arr.splice(0, arr.length - 1000);
|
|
381
|
+
console.log(`[${nodeId.slice(0,8)}] ${msg}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Зеркалит логику маршрутизации в server.js — для timeline-лога ноды,
|
|
385
|
+
// чтобы юзер ВИДЕЛ ДО запроса, на какой провайдер уйдут данные. Если
|
|
386
|
+
// настройки не позволяют ни один путь — возвращает 'none' (запрос либо
|
|
387
|
+
// упадёт 503, либо отработает дефолтом — UI всё равно покажет факт).
|
|
388
|
+
async function plannedProvider(kind) {
|
|
389
|
+
let s;
|
|
390
|
+
try { s = await window.appSettings.get(); } catch { s = {}; }
|
|
391
|
+
const hasChatium = !!(s.useChatium && s.chatium?.token && s.chatium?.base);
|
|
392
|
+
switch (kind) {
|
|
393
|
+
case 'text':
|
|
394
|
+
if (hasChatium) return 'kingkont';
|
|
395
|
+
if (s.useOpenrouter === true) return 'openrouter';
|
|
396
|
+
return 'none';
|
|
397
|
+
case 'audio': case 'tts': case 'sfx': case 'music':
|
|
398
|
+
// Если ElevenLabs включён С ключом — приоритет прямого ElevenLabs
|
|
399
|
+
// (юзеру нужны его кастомные/cloned voices). Иначе KingKont.
|
|
400
|
+
if (s.useElevenlabs === true && s.elevenKey) return 'elevenlabs';
|
|
401
|
+
if (hasChatium) return 'kingkont';
|
|
402
|
+
if (s.useElevenlabs === true) return 'elevenlabs';
|
|
403
|
+
return 'none';
|
|
404
|
+
case 'video':
|
|
405
|
+
if (hasChatium) return 'kingkont';
|
|
406
|
+
if (s.useKie === true) return 'kie';
|
|
407
|
+
return 'none';
|
|
408
|
+
case 'image':
|
|
409
|
+
if (hasChatium) return 'kingkont';
|
|
410
|
+
if (s.useKie === true) return 'kie';
|
|
411
|
+
return 'none';
|
|
412
|
+
default:
|
|
413
|
+
return '?';
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Безопасная сериализация для логов: обрезает длинные строки и убирает blob/handle
|
|
417
|
+
function logSafe(obj) {
|
|
418
|
+
try {
|
|
419
|
+
return JSON.stringify(obj, (k, v) => {
|
|
420
|
+
if (k === 'boardHandle') return v ? `<DirectoryHandle ${v.name || ''}>` : null;
|
|
421
|
+
if (typeof v === 'string' && v.length > 200) return v.slice(0, 200) + `…(+${v.length - 200} chars)`;
|
|
422
|
+
return v;
|
|
423
|
+
});
|
|
424
|
+
} catch (e) { return String(obj); }
|
|
425
|
+
}
|
|
426
|
+
const DEFAULT_TONES = ['шепот', 'крик', 'смех', 'плач', 'вздох', 'нежно', 'сурово', 'удивлённо', 'грустно', 'возбуждённо'];
|
|
427
|
+
|
|
428
|
+
function clearSelection() { state.selectedNodeIds.clear(); }
|
|
429
|
+
function toggleSelection(id) {
|
|
430
|
+
if (state.selectedNodeIds.has(id)) state.selectedNodeIds.delete(id);
|
|
431
|
+
else state.selectedNodeIds.add(id);
|
|
432
|
+
}
|
|
433
|
+
function renderSelection() {
|
|
434
|
+
canvas.querySelectorAll('.node.picked').forEach(el => el.classList.remove('picked'));
|
|
435
|
+
for (const id of state.selectedNodeIds) {
|
|
436
|
+
const el = canvas.querySelector(`.node[data-id="${id}"]`);
|
|
437
|
+
if (el) el.classList.add('picked');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const ZOOM_MIN = 0.1, ZOOM_MAX = 4;
|
|
441
|
+
const MAX_HISTORY = 30;
|
|
442
|
+
|
|
443
|
+
// =================== История изменений (undo/redo, persist в scene.json) ===================
|
|
444
|
+
// Хранится в state.currentBoard.metadata.history = { past: [], future: [] },
|
|
445
|
+
// каждый элемент — { ts, label, snap } где snap — сериализованная сцена.
|
|
446
|
+
// snap охватывает: nodes (включая n.text для текстовых), connections, timeline,
|
|
447
|
+
// character, location. Файлы НЕ копируются, но при удалении они уезжают в _deleted
|
|
448
|
+
// и movedFiles в past-элементе помогает их вернуть.
|
|
449
|
+
|
|
450
|
+
function _getHistory() {
|
|
451
|
+
if (!state.currentBoard) return null;
|
|
452
|
+
const meta = state.currentBoard.metadata;
|
|
453
|
+
if (!meta.history) meta.history = { past: [], future: [] };
|
|
454
|
+
if (!Array.isArray(meta.history.past)) meta.history.past = [];
|
|
455
|
+
if (!Array.isArray(meta.history.future)) meta.history.future = [];
|
|
456
|
+
return meta.history;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function captureScene() {
|
|
460
|
+
if (!state.currentBoard) return null;
|
|
461
|
+
const meta = state.currentBoard.metadata;
|
|
462
|
+
// Копируем ноды с n.text (для text-node содержимое .md-файла) — иначе откат текста невозможен.
|
|
463
|
+
const nodes = meta.nodes.map(n => ({ ...n }));
|
|
464
|
+
return JSON.stringify({
|
|
465
|
+
nodes,
|
|
466
|
+
connections: meta.connections || [],
|
|
467
|
+
timeline: meta.timeline || null,
|
|
468
|
+
character: meta.character || null,
|
|
469
|
+
location: meta.location || null,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Записывает текущее состояние в past и очищает future.
|
|
474
|
+
// Вызывать ДО мутации. opts.movedFiles — для случаев когда удаление файлов уже произошло
|
|
475
|
+
// и при undo нужно их вернуть (см. вызовы в deleteSelectedNodes / deleteNode).
|
|
476
|
+
function pushHistory(label = '', opts = {}) {
|
|
477
|
+
const h = _getHistory();
|
|
478
|
+
if (!h) return;
|
|
479
|
+
const snap = captureScene();
|
|
480
|
+
h.past.push({ ts: Date.now(), label, snap, movedFiles: opts.movedFiles || null });
|
|
481
|
+
if (h.past.length > MAX_HISTORY) h.past.shift();
|
|
482
|
+
h.future.length = 0;
|
|
483
|
+
scheduleSave();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function _restoreMovedFiles(boardHandle, movedFiles) {
|
|
487
|
+
if (!movedFiles?.length) return;
|
|
488
|
+
for (const mv of movedFiles) {
|
|
489
|
+
try {
|
|
490
|
+
const delDir = await boardHandle.getDirectoryHandle('_deleted');
|
|
491
|
+
const srcFh = await delDir.getFileHandle(mv.movedFilename);
|
|
492
|
+
const file = await srcFh.getFile();
|
|
493
|
+
await writeBoardFile(boardHandle, mv.originalPath, file);
|
|
494
|
+
await delDir.removeEntry(mv.movedFilename);
|
|
495
|
+
} catch (e) { console.warn('restoreMovedFiles', mv, e); }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function applyScene(snap) {
|
|
500
|
+
if (!state.currentBoard) return;
|
|
501
|
+
const data = JSON.parse(snap);
|
|
502
|
+
const meta = state.currentBoard.metadata;
|
|
503
|
+
// Текстовые ноды: их содержимое — это .md файл, метаданные не хранят текст.
|
|
504
|
+
// При откате нам нужно перезаписать .md актуальным n.text из snapshot.
|
|
505
|
+
for (const n of (data.nodes || [])) {
|
|
506
|
+
if (n.type === 'text' && n.file) {
|
|
507
|
+
try { await writeBoardFile(state.currentBoard.handle, n.file, n.text || ''); }
|
|
508
|
+
catch (e) { console.warn('applyScene: text write failed', n.file, e); }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
meta.nodes = data.nodes || [];
|
|
512
|
+
meta.connections = data.connections || [];
|
|
513
|
+
meta.timeline = data.timeline || null;
|
|
514
|
+
meta.character = data.character || null;
|
|
515
|
+
meta.location = data.location || null;
|
|
516
|
+
// blob-URL'ы пересоздадим при следующем рендере — они могли указывать на исчезнувшие файлы
|
|
517
|
+
for (const u of Object.values(state.currentBoard.urls || {})) URL.revokeObjectURL(u);
|
|
518
|
+
state.currentBoard.urls = {};
|
|
519
|
+
state.selectedNodeIds.clear();
|
|
520
|
+
state.selectedClipIds.clear();
|
|
521
|
+
state.selectedTrackIds.clear();
|
|
522
|
+
await renderCanvas();
|
|
523
|
+
if (!$('timelinePanel').classList.contains('hidden')) await renderTimeline();
|
|
524
|
+
scheduleSave();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function undo() {
|
|
528
|
+
const h = _getHistory();
|
|
529
|
+
if (h && h.past.length) {
|
|
530
|
+
const cur = captureScene();
|
|
531
|
+
const prev = h.past.pop();
|
|
532
|
+
h.future.push({ ts: Date.now(), label: prev.label, snap: cur });
|
|
533
|
+
if (h.future.length > MAX_HISTORY) h.future.shift();
|
|
534
|
+
if (prev.movedFiles) await _restoreMovedFiles(state.currentBoard.handle, prev.movedFiles);
|
|
535
|
+
await applyScene(prev.snap);
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
// Фоллбэк: восстанавливаем последнее удалённое board из _deleted.
|
|
539
|
+
return await undoBoardDelete();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function redo() {
|
|
543
|
+
const h = _getHistory();
|
|
544
|
+
if (!h || !h.future.length) return false;
|
|
545
|
+
const cur = captureScene();
|
|
546
|
+
const next = h.future.pop();
|
|
547
|
+
h.past.push({ ts: Date.now(), label: next.label, snap: cur, movedFiles: null });
|
|
548
|
+
if (h.past.length > MAX_HISTORY) h.past.shift();
|
|
549
|
+
await applyScene(next.snap);
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Совместимость со старыми вызовами pushUndo({type:'sceneSnapshot'|'timelineSnapshot'|'delete', ...}).
|
|
554
|
+
// Мы превращаем их в pushHistory: всё равно snapshot уже содержит всю сцену.
|
|
555
|
+
function pushUndo(action) {
|
|
556
|
+
if (!state.currentBoard) return;
|
|
557
|
+
const movedFiles = action?.movedFiles || (action?.type === 'delete' && action.movedFilename
|
|
558
|
+
? [{ originalPath: action.node?.file, movedFilename: action.movedFilename }]
|
|
559
|
+
: null);
|
|
560
|
+
pushHistory(action?.label || action?.type || 'edit', { movedFiles });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function performUndo() { return undo(); }
|
|
564
|
+
|
|
565
|
+
const boardKey = (kind, name) => `${kind}/${name}`;
|
|
566
|
+
|