kingkont 0.7.98 → 0.8.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/package.json +1 -1
- package/renderer/board.js +9 -5
- package/renderer/generate.js +47 -13
- package/renderer/media.js +23 -0
- package/renderer/settings.js +11 -0
- package/renderer/state.js +15 -1
package/package.json
CHANGED
package/renderer/board.js
CHANGED
|
@@ -1885,15 +1885,19 @@ function showNodeContextMenu(node, clientX, clientY) {
|
|
|
1885
1885
|
add('📥 Вставить (Cmd+V)', () => pasteClipboardNodes(), { disabled: !canPaste });
|
|
1886
1886
|
const canReplace = !!(state.clipboard?.length);
|
|
1887
1887
|
add('⇄ Заменить из буфера', () => replaceNodeFromClipboard(node), { disabled: !canReplace });
|
|
1888
|
-
|
|
1889
|
-
|
|
1888
|
+
// «Сохранить как…» — для любого медиа-файла (image / audio / video).
|
|
1889
|
+
// Показывает нативный системный диалог через FS-Access-API; если API
|
|
1890
|
+
// недоступен (web-режим) — auto-сохранение в Downloads/.
|
|
1891
|
+
if ((node.type === 'image' || node.type === 'audio' || node.type === 'video') && node.file) {
|
|
1892
|
+
add('💾 Сохранить как…', async () => {
|
|
1890
1893
|
try {
|
|
1891
1894
|
const fh = await resolveBoardFile(state.currentBoard.handle, node.file);
|
|
1892
1895
|
const file = await fh.getFile();
|
|
1893
|
-
|
|
1896
|
+
const suggested = node.file.split('/').pop();
|
|
1897
|
+
await saveAsToDisk(file, suggested);
|
|
1894
1898
|
} catch (err) {
|
|
1895
|
-
console.error('
|
|
1896
|
-
alert('Не удалось
|
|
1899
|
+
console.error('Save failed:', err);
|
|
1900
|
+
alert('Не удалось сохранить: ' + (err?.message || err));
|
|
1897
1901
|
}
|
|
1898
1902
|
});
|
|
1899
1903
|
}
|
package/renderer/generate.js
CHANGED
|
@@ -398,6 +398,20 @@ async function openGenModal(kind) {
|
|
|
398
398
|
state.imageAspect = 'source';
|
|
399
399
|
syncImageAspectActive();
|
|
400
400
|
}
|
|
401
|
+
// Для video-из-картинки нет 'source' опции (ratios у video свои), но
|
|
402
|
+
// юзер всё равно ожидает сохранения пропорций. Считаем aspect картинки
|
|
403
|
+
// и выбираем ближайший из видео-списка. localStorage НЕ обновляем —
|
|
404
|
+
// это per-open default, не sticky (следующий open без source вернёт
|
|
405
|
+
// последний явный выбор юзера).
|
|
406
|
+
if (kind === 'video'
|
|
407
|
+
&& state.sourceRef && state.sourceRef.type === 'image' && state.sourceRef.use) {
|
|
408
|
+
computeSourceMediaAspect(VIDEO_ASPECTS).then(asp => {
|
|
409
|
+
if (asp && state.genKind === 'video') {
|
|
410
|
+
state.videoAspect = asp;
|
|
411
|
+
if (typeof syncVideoOptionsActive === 'function') syncVideoOptionsActive();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
401
415
|
$('genModal').classList.remove('hidden');
|
|
402
416
|
setTimeout(() => $('genPrompt').focus(), 50);
|
|
403
417
|
}
|
|
@@ -825,9 +839,32 @@ document.querySelectorAll('#genModal [data-kind]').forEach(b => {
|
|
|
825
839
|
});
|
|
826
840
|
});
|
|
827
841
|
|
|
842
|
+
// Списки поддерживаемых aspect-ratio для image vs video — не идентичны,
|
|
843
|
+
// потому что у моделей разные ограничения (image поддерживает 3:2/2:3,
|
|
844
|
+
// video — 21:9 ultra-wide). При выводе video-из-image выбираем ближайший
|
|
845
|
+
// видео-aspect к natural-aspect'у картинки (см. computeSourceMediaAspect).
|
|
846
|
+
const IMAGE_ASPECTS = [
|
|
847
|
+
{ name: '1:1', v: 1.0 },
|
|
848
|
+
{ name: '16:9', v: 16/9 },
|
|
849
|
+
{ name: '9:16', v: 9/16 },
|
|
850
|
+
{ name: '3:2', v: 3/2 },
|
|
851
|
+
{ name: '2:3', v: 2/3 },
|
|
852
|
+
{ name: '4:3', v: 4/3 },
|
|
853
|
+
{ name: '3:4', v: 3/4 },
|
|
854
|
+
];
|
|
855
|
+
const VIDEO_ASPECTS = [
|
|
856
|
+
{ name: '1:1', v: 1.0 },
|
|
857
|
+
{ name: '16:9', v: 16/9 },
|
|
858
|
+
{ name: '9:16', v: 9/16 },
|
|
859
|
+
{ name: '4:3', v: 4/3 },
|
|
860
|
+
{ name: '3:4', v: 3/4 },
|
|
861
|
+
{ name: '21:9', v: 21/9 },
|
|
862
|
+
];
|
|
863
|
+
|
|
828
864
|
// Вычислить ближайший поддерживаемый aspect ratio из state.sourceRef.
|
|
865
|
+
// opts — список поддерживаемых семейством (image/video).
|
|
829
866
|
// Возвращает строку '16:9' / '1:1' / etc, или null если не удалось.
|
|
830
|
-
async function
|
|
867
|
+
async function computeSourceMediaAspect(opts = IMAGE_ASPECTS) {
|
|
831
868
|
if (!state.sourceRef || state.sourceRef.type !== 'image' || !state.sourceRef.file) return null;
|
|
832
869
|
const handle = state.sourceRef.boardHandle || state.currentBoard?.handle;
|
|
833
870
|
if (!handle) return null;
|
|
@@ -843,24 +880,21 @@ async function computeSourceImageAspect() {
|
|
|
843
880
|
img.src = url;
|
|
844
881
|
});
|
|
845
882
|
if (!dim.w || !dim.h) return null;
|
|
846
|
-
return nearestSupportedAspect(dim.w / dim.h);
|
|
883
|
+
return nearestSupportedAspect(dim.w / dim.h, opts);
|
|
847
884
|
} catch { return null; }
|
|
848
885
|
finally { if (url) URL.revokeObjectURL(url); }
|
|
849
886
|
}
|
|
850
887
|
|
|
851
|
-
//
|
|
888
|
+
// Backward-compat: старое имя computeSourceImageAspect используется в
|
|
889
|
+
// submit-flow image'а. Для image-aspect списка по умолчанию.
|
|
890
|
+
async function computeSourceImageAspect() {
|
|
891
|
+
return computeSourceMediaAspect(IMAGE_ASPECTS);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Из произвольного ratio выбирает ближайший из переданного списка.
|
|
852
895
|
// Сравнение в логарифмической метрике — справедливо для пропорций
|
|
853
896
|
// (1.5 ↔ 0.667 равноудалены от 1.0).
|
|
854
|
-
function nearestSupportedAspect(ratio) {
|
|
855
|
-
const opts = [
|
|
856
|
-
{ name: '1:1', v: 1.0 },
|
|
857
|
-
{ name: '16:9', v: 16/9 },
|
|
858
|
-
{ name: '9:16', v: 9/16 },
|
|
859
|
-
{ name: '3:2', v: 3/2 },
|
|
860
|
-
{ name: '2:3', v: 2/3 },
|
|
861
|
-
{ name: '4:3', v: 4/3 },
|
|
862
|
-
{ name: '3:4', v: 3/4 },
|
|
863
|
-
];
|
|
897
|
+
function nearestSupportedAspect(ratio, opts = IMAGE_ASPECTS) {
|
|
864
898
|
let best = opts[0];
|
|
865
899
|
let bestDiff = Math.abs(Math.log(ratio / best.v));
|
|
866
900
|
for (const o of opts) {
|
package/renderer/media.js
CHANGED
|
@@ -152,6 +152,29 @@ async function downloadAudioAtSpeed(file, filename, speed, onProgress) {
|
|
|
152
152
|
await ff.deleteFile(outName).catch(() => {});
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// «Сохранить как…» — показывает нативный системный диалог выбора пути
|
|
156
|
+
// (через FS-Access-API), куда сохранить файл. Если API недоступен или
|
|
157
|
+
// юзер ничего не выбрал — fallback на triggerDownload (auto-сохранение
|
|
158
|
+
// в Downloads/). Возвращает true если файл сохранён, false если отменено.
|
|
159
|
+
async function saveAsToDisk(file, suggestedName) {
|
|
160
|
+
if (typeof window.showSaveFilePicker === 'function') {
|
|
161
|
+
try {
|
|
162
|
+
const handle = await window.showSaveFilePicker({ suggestedName });
|
|
163
|
+
const w = await handle.createWritable();
|
|
164
|
+
await w.write(file);
|
|
165
|
+
await w.close();
|
|
166
|
+
return true;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// AbortError — юзер закрыл диалог. Не fallback'аемся, чтобы не
|
|
169
|
+
// дёргать auto-download против его воли.
|
|
170
|
+
if (err?.name === 'AbortError') return false;
|
|
171
|
+
// Любая другая ошибка — fall through на triggerDownload.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
triggerDownload(file, suggestedName);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
function triggerDownload(blob, filename) {
|
|
156
179
|
const url = URL.createObjectURL(blob);
|
|
157
180
|
const a = document.createElement('a');
|
package/renderer/settings.js
CHANGED
|
@@ -678,6 +678,17 @@ async function openGenerateForRef(fromNode, clientX, clientY, forceKind) {
|
|
|
678
678
|
state.imageAspect = 'source';
|
|
679
679
|
syncImageAspectActive();
|
|
680
680
|
}
|
|
681
|
+
// Video-из-картинки: подбираем ближайший видео-aspect к natural-aspect
|
|
682
|
+
// картинки (см. openGenModal для аналогичной логики).
|
|
683
|
+
if (forceKind === 'video'
|
|
684
|
+
&& state.sourceRef && state.sourceRef.type === 'image' && state.sourceRef.use) {
|
|
685
|
+
computeSourceMediaAspect(VIDEO_ASPECTS).then(asp => {
|
|
686
|
+
if (asp && state.genKind === 'video') {
|
|
687
|
+
state.videoAspect = asp;
|
|
688
|
+
if (typeof syncVideoOptionsActive === 'function') syncVideoOptionsActive();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
}
|
|
681
692
|
|
|
682
693
|
closeMentionPopup();
|
|
683
694
|
$('genModal').classList.remove('hidden');
|
package/renderer/state.js
CHANGED
|
@@ -439,6 +439,20 @@ function getFileType(file) {
|
|
|
439
439
|
} catch {}
|
|
440
440
|
})();
|
|
441
441
|
|
|
442
|
+
// One-time migration: дефолт video-разрешения раньше был 720p — сбрасываем
|
|
443
|
+
// тех, у кого залипло '720p' (вероятно, они никогда явно не выбирали),
|
|
444
|
+
// на минимальное '480p'. После явного выбора 720p/1080p — он сохранится
|
|
445
|
+
// как и раньше, миграция повторно не отрабатывает.
|
|
446
|
+
(function migrateVideoResolutionDefault() {
|
|
447
|
+
try {
|
|
448
|
+
if (localStorage.getItem('videoResolutionResetMin') === '1') return;
|
|
449
|
+
if (localStorage.getItem('videoResolution') === '720p') {
|
|
450
|
+
localStorage.setItem('videoResolution', '480p');
|
|
451
|
+
}
|
|
452
|
+
localStorage.setItem('videoResolutionResetMin', '1');
|
|
453
|
+
} catch {}
|
|
454
|
+
})();
|
|
455
|
+
|
|
442
456
|
const state = {
|
|
443
457
|
filmHandle: null,
|
|
444
458
|
currentBoard: null, // { kind, name, key, handle, metadata, urls }
|
|
@@ -449,7 +463,7 @@ const state = {
|
|
|
449
463
|
videoModel: localStorage.getItem('videoModel') || 'seedance-2', // 'seedance-2' | 'kling-o1' | 'kling-3.0' | ...
|
|
450
464
|
ttsModel: localStorage.getItem('ttsModel') || 'qwen/qwen3-tts', // qwen/elevenlabs/v3/minimax/speech-02-hd/gemini
|
|
451
465
|
videoDuration: +(localStorage.getItem('videoDuration') || 5),
|
|
452
|
-
videoResolution: localStorage.getItem('videoResolution') || '
|
|
466
|
+
videoResolution: localStorage.getItem('videoResolution') || '480p',
|
|
453
467
|
videoAspect: localStorage.getItem('videoAspect') || '9:16',
|
|
454
468
|
jobs: new Map(), // nodeId -> { boardKey, boardHandle, kind, taskId }
|
|
455
469
|
undoStack: [], // {type, ...payload}
|