kingkont 0.20.16 → 0.20.18
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/index.html +1 -0
- package/lib/providers.js +98 -8
- package/main.js +44 -0
- package/package.json +1 -1
- package/preload.js +6 -0
- package/renderer/board.js +142 -39
- package/renderer/cloudProjects.js +65 -0
- package/renderer/drawings.js +15 -3
- package/renderer/generate.js +77 -1
- package/renderer/media.js +125 -8
- package/renderer/settings.js +64 -19
- package/renderer/state.js +26 -0
- package/renderer/styles.css +77 -10
- package/renderer/templates.js +10 -1
- package/server.js +8 -1
- package/skill/SKILL.md +102 -1
package/renderer/generate.js
CHANGED
|
@@ -516,6 +516,13 @@ $('textGenSubmit').addEventListener('click', async () => {
|
|
|
516
516
|
// Собираем image-mentions из промпта (видение для модели). Видео/audio в
|
|
517
517
|
// OpenRouter chat не поддерживается — игнорируем.
|
|
518
518
|
const allMediaRefs = (typeof gatherMediaRefs === 'function') ? gatherMediaRefs(rawPrompt) : [];
|
|
519
|
+
// + источник pendingConnectionFrom (anchor-drag → новая text-нода): если
|
|
520
|
+
// юзер протянул из image-ноды в gen-text, картинка автоматически попадает
|
|
521
|
+
// в контекст. Видео-источники для text-gen бесполезны (OpenRouter chat
|
|
522
|
+
// их не съест), но мы оставляем в общем списке — фильтр ниже их уберёт.
|
|
523
|
+
for (const pf of _pendingConnectionFromRef()) {
|
|
524
|
+
if (!allMediaRefs.some(r => r.file === pf.file)) allMediaRefs.push(pf);
|
|
525
|
+
}
|
|
519
526
|
const imageRefs = allMediaRefs.filter(r => r.type === 'image' && r.file);
|
|
520
527
|
// Резолвим mentions: text-ноды → инлайн .md, image-ноды → маркеры [image N].
|
|
521
528
|
const resolvedPrompt = (typeof resolveMentions === 'function') ? resolveMentions(rawPrompt, imageRefs) : rawPrompt;
|
|
@@ -1202,6 +1209,63 @@ function presetPicksForBoard() {
|
|
|
1202
1209
|
if (b?.kind === 'location' && b.name) state.pickedLocName = b.name;
|
|
1203
1210
|
}
|
|
1204
1211
|
|
|
1212
|
+
// Собирает image/video ноды, ИЗ которых есть connection В targetNodeId.
|
|
1213
|
+
// Юзер: «если в ноду 'входит' несколько других нод-картинок или 'нод-видео'
|
|
1214
|
+
// — подавай их тоже как референсы». Когда граф уже выражает связь
|
|
1215
|
+
// (юзер протянул стрелку), не заставляем юзера ещё и писать @name в
|
|
1216
|
+
// промпте — автоматически подмешиваем источники к refs.
|
|
1217
|
+
// Используется в:
|
|
1218
|
+
// • genSubmit (новая нода) — для state.pendingConnectionFrom (anchor-drag)
|
|
1219
|
+
// это уже учитывается отдельно, т.к. там ещё нет node.id; здесь же
|
|
1220
|
+
// полезно когда юзер ставит несколько связей через drag-line ДО
|
|
1221
|
+
// запуска gen-modal'а (редкий кейс).
|
|
1222
|
+
// • regenerateInto (существующая нода) — все incoming-edges с
|
|
1223
|
+
// image/video-источниками. Главный кейс: юзер связал draft-ноду с
|
|
1224
|
+
// несколькими картинками и нажал «↻ Перегенерировать».
|
|
1225
|
+
function gatherIncomingMediaRefs(targetNodeId) {
|
|
1226
|
+
const refs = [];
|
|
1227
|
+
const board = state.currentBoard;
|
|
1228
|
+
if (!board || !targetNodeId) return refs;
|
|
1229
|
+
const conns = board.metadata.connections || [];
|
|
1230
|
+
const nodes = board.metadata.nodes || [];
|
|
1231
|
+
for (const c of conns) {
|
|
1232
|
+
if (c.to !== targetNodeId) continue;
|
|
1233
|
+
const src = nodes.find(n => n.id === c.from);
|
|
1234
|
+
if (!src) continue;
|
|
1235
|
+
if (src.type !== 'image' && src.type !== 'video') continue;
|
|
1236
|
+
if (!src.file) continue; // источник ещё не готов — не блокируем gen, просто пропускаем
|
|
1237
|
+
refs.push({
|
|
1238
|
+
key: `incoming:${src.id}`,
|
|
1239
|
+
name: src.name || src.file.split('/').pop(),
|
|
1240
|
+
type: src.type,
|
|
1241
|
+
file: src.file,
|
|
1242
|
+
boardHandle: board.handle,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
return refs;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Источник pendingConnectionFrom (для NEW ноды, когда node.id ещё не
|
|
1249
|
+
// существует). Возвращает массив с одним рефом или пустой.
|
|
1250
|
+
function _pendingConnectionFromRef() {
|
|
1251
|
+
const refs = [];
|
|
1252
|
+
if (!state.pendingConnectionFrom) return refs;
|
|
1253
|
+
const board = state.currentBoard;
|
|
1254
|
+
if (!board) return refs;
|
|
1255
|
+
const src = (board.metadata.nodes || []).find(n => n.id === state.pendingConnectionFrom);
|
|
1256
|
+
if (!src) return refs;
|
|
1257
|
+
if (src.type !== 'image' && src.type !== 'video') return refs;
|
|
1258
|
+
if (!src.file) return refs;
|
|
1259
|
+
refs.push({
|
|
1260
|
+
key: `incoming:${src.id}`,
|
|
1261
|
+
name: src.name || src.file.split('/').pop(),
|
|
1262
|
+
type: src.type,
|
|
1263
|
+
file: src.file,
|
|
1264
|
+
boardHandle: board.handle,
|
|
1265
|
+
});
|
|
1266
|
+
return refs;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1205
1269
|
// Собрать рефы из выбранных персонажей и локации (sheet'ы)
|
|
1206
1270
|
function gatherPickedSheetRefs() {
|
|
1207
1271
|
const refs = [];
|
|
@@ -1715,6 +1779,14 @@ $('genSubmit').addEventListener('click', async () => {
|
|
|
1715
1779
|
if (mediaRefs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
|
|
1716
1780
|
mediaRefs.push(pr);
|
|
1717
1781
|
}
|
|
1782
|
+
// Incoming-edge источник для НОВОЙ ноды (anchor-drag из image/video ноды).
|
|
1783
|
+
// Юзер: «если в ноду 'входит' нода-картинка/видео — подавай её тоже как
|
|
1784
|
+
// референс». Для NEW ноды есть только pendingConnectionFrom (один источник);
|
|
1785
|
+
// существующая нода с множественными incoming обрабатывается в regenerateInto.
|
|
1786
|
+
for (const pf of _pendingConnectionFromRef()) {
|
|
1787
|
+
if (mediaRefs.some(r => r.file === pf.file && r.boardHandle === pf.boardHandle)) continue;
|
|
1788
|
+
mediaRefs.push(pf);
|
|
1789
|
+
}
|
|
1718
1790
|
const missing = mediaRefs.filter(r => !r.file || r.status === 'generating');
|
|
1719
1791
|
if (missing.length) {
|
|
1720
1792
|
alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + m.name).join(', '));
|
|
@@ -1974,9 +2046,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
|
|
|
1974
2046
|
// Так per-board настройка перебивает глобальный фоллбэк.
|
|
1975
2047
|
const boardAspect = state.currentBoard?.metadata?.settings?.aspectRatio || null;
|
|
1976
2048
|
if (kind === 'video') {
|
|
1977
|
-
|
|
2049
|
+
// Явное приведение к Number — иначе если state.videoDuration хранится
|
|
2050
|
+
// строкой ("8") chatium-side ALLOWED_PARAMS пропустит, но Replicate
|
|
2051
|
+
// может молча проигнорировать и взять дефолт.
|
|
2052
|
+
submitBody.duration = +(node.generated?.duration ?? state.videoDuration) || 5;
|
|
1978
2053
|
submitBody.resolution = node.generated?.resolution ?? state.videoResolution;
|
|
1979
2054
|
submitBody.aspectRatio = node.generated?.aspectRatio ?? boardAspect ?? state.videoAspect;
|
|
2055
|
+
logJob(node.id, `video params: duration=${submitBody.duration}s resolution=${submitBody.resolution} aspect=${submitBody.aspectRatio} model=${modelKey}`);
|
|
1980
2056
|
} else if (kind === 'image') {
|
|
1981
2057
|
// Grok-imagine требует aspect_ratio из {2:3, 3:2, 1:1, 9:16, 16:9}.
|
|
1982
2058
|
// Остальные модели (nano-banana-2, seedream и др.) тоже принимают.
|
package/renderer/media.js
CHANGED
|
@@ -94,22 +94,117 @@ $('zoomIn').addEventListener('click', () => applyZoom(state.zoom * 1.25));
|
|
|
94
94
|
$('zoomOut').addEventListener('click', () => applyZoom(state.zoom / 1.25));
|
|
95
95
|
$('zoomLabel').addEventListener('click', () => applyZoom(1));
|
|
96
96
|
|
|
97
|
-
//
|
|
97
|
+
// =================== Pinch-zoom (touch) ===================
|
|
98
|
+
// Мобильный/таблет: pinch двумя пальцами → zoom вокруг midpoint двух тачей.
|
|
99
|
+
// Браузерный native pinch-zoom (visualViewport) масштабирует ВЕСЬ layout
|
|
100
|
+
// и ломает координаты — поэтому touch-action: pan-x pan-y отключает его в
|
|
101
|
+
// view-only-mode (см. styles.css), а мы делаем canvas-zoom вручную через
|
|
102
|
+
// applyZoom() с координатным якорем посередине pinch'а.
|
|
103
|
+
// Якорь = midpoint двух touch'ей в client-coords (как у wheel-ctrl-zoom).
|
|
104
|
+
// Ratio = currentDist / startDist → applyZoom(startZoom * ratio, midX, midY).
|
|
105
|
+
let _pinchStartDist = 0;
|
|
106
|
+
let _pinchStartZoom = 1;
|
|
107
|
+
let _pinchActive = false;
|
|
108
|
+
let _pinchRaf = null;
|
|
109
|
+
let _pinchPendingMid = null;
|
|
110
|
+
function _touchDist(t1, t2) {
|
|
111
|
+
const dx = t2.clientX - t1.clientX;
|
|
112
|
+
const dy = t2.clientY - t1.clientY;
|
|
113
|
+
return Math.hypot(dx, dy);
|
|
114
|
+
}
|
|
115
|
+
function _touchMid(t1, t2) {
|
|
116
|
+
return { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
117
|
+
}
|
|
118
|
+
canvasWrap.addEventListener('touchstart', e => {
|
|
119
|
+
if (e.touches.length === 2) {
|
|
120
|
+
_pinchActive = true;
|
|
121
|
+
_pinchStartDist = _touchDist(e.touches[0], e.touches[1]);
|
|
122
|
+
_pinchStartZoom = state.zoom;
|
|
123
|
+
// Предотвращаем native pinch только если у нас 2-touch жест.
|
|
124
|
+
// 1-touch (pan) пропускаем — native scroll работает.
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
}
|
|
127
|
+
}, { passive: false });
|
|
128
|
+
canvasWrap.addEventListener('touchmove', e => {
|
|
129
|
+
if (!_pinchActive || e.touches.length !== 2) return;
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const dist = _touchDist(e.touches[0], e.touches[1]);
|
|
132
|
+
const mid = _touchMid(e.touches[0], e.touches[1]);
|
|
133
|
+
if (_pinchStartDist <= 0) return;
|
|
134
|
+
const ratio = dist / _pinchStartDist;
|
|
135
|
+
_pinchPendingMid = { mid, target: _pinchStartZoom * ratio };
|
|
136
|
+
if (_pinchRaf) return;
|
|
137
|
+
_pinchRaf = requestAnimationFrame(() => {
|
|
138
|
+
_pinchRaf = null;
|
|
139
|
+
const p = _pinchPendingMid;
|
|
140
|
+
if (!p) return;
|
|
141
|
+
applyZoom(p.target, p.mid.x, p.mid.y);
|
|
142
|
+
});
|
|
143
|
+
}, { passive: false });
|
|
144
|
+
function _endPinch() {
|
|
145
|
+
_pinchActive = false;
|
|
146
|
+
_pinchStartDist = 0;
|
|
147
|
+
if (_pinchRaf) { cancelAnimationFrame(_pinchRaf); _pinchRaf = null; }
|
|
148
|
+
_pinchPendingMid = null;
|
|
149
|
+
}
|
|
150
|
+
canvasWrap.addEventListener('touchend', e => {
|
|
151
|
+
if (e.touches.length < 2) _endPinch();
|
|
152
|
+
});
|
|
153
|
+
canvasWrap.addEventListener('touchcancel', _endPinch);
|
|
154
|
+
|
|
155
|
+
// Сохранение pan/zoom для текущей доски.
|
|
156
|
+
// Раньше 400ms debounce: scroll → 400ms wait → set view → scheduleSave 300ms
|
|
157
|
+
// → write. Юзер закрывал таб быстрее → save не успевал → позиция терялась.
|
|
158
|
+
// Теперь 100ms debounce + явный flush на pagehide/beforeunload.
|
|
98
159
|
let viewSaveTimer = null;
|
|
160
|
+
function _updateViewFromScroll() {
|
|
161
|
+
if (!state.currentBoard) return;
|
|
162
|
+
state.currentBoard.metadata.view = {
|
|
163
|
+
scrollLeft: canvasWrap.scrollLeft,
|
|
164
|
+
scrollTop: canvasWrap.scrollTop,
|
|
165
|
+
zoom: state.zoom,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
99
168
|
function scheduleViewSave() {
|
|
100
169
|
if (!state.currentBoard) return;
|
|
101
170
|
clearTimeout(viewSaveTimer);
|
|
102
171
|
viewSaveTimer = setTimeout(() => {
|
|
103
|
-
|
|
104
|
-
state.currentBoard.metadata.view = {
|
|
105
|
-
scrollLeft: canvasWrap.scrollLeft,
|
|
106
|
-
scrollTop: canvasWrap.scrollTop,
|
|
107
|
-
zoom: state.zoom,
|
|
108
|
-
};
|
|
172
|
+
_updateViewFromScroll();
|
|
109
173
|
scheduleSave();
|
|
110
|
-
},
|
|
174
|
+
}, 100);
|
|
111
175
|
}
|
|
112
176
|
canvasWrap.addEventListener('scroll', scheduleViewSave);
|
|
177
|
+
// На выгрузке страницы: немедленно перенесём scroll в metadata + flush
|
|
178
|
+
// save СРАЗУ (минуя debounce). Без этого юзер скроллил, закрывал таб через
|
|
179
|
+
// 200мс — viewSaveTimer не успевал отстреляться. Используем 'pagehide'
|
|
180
|
+
// (срабатывает на close-tab/navigate AND back-forward-cache), плюс
|
|
181
|
+
// 'visibilitychange' с 'hidden' (более надёжно на mobile/system-sleep).
|
|
182
|
+
function _flushViewSave() {
|
|
183
|
+
if (!state.currentBoard) return;
|
|
184
|
+
clearTimeout(viewSaveTimer);
|
|
185
|
+
_updateViewFromScroll();
|
|
186
|
+
// Прямой синхронный вызов сохранения вместо scheduleSave (которое
|
|
187
|
+
// ставит ещё один 300ms timer и тоже может не успеть на unload).
|
|
188
|
+
clearTimeout(saveTimer); saveTimer = null;
|
|
189
|
+
_runSaveNow().catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
window.addEventListener('pagehide', _flushViewSave);
|
|
192
|
+
window.addEventListener('beforeunload', _flushViewSave);
|
|
193
|
+
document.addEventListener('visibilitychange', () => {
|
|
194
|
+
if (document.visibilityState === 'hidden') _flushViewSave();
|
|
195
|
+
});
|
|
196
|
+
// Глобальный flush — зовётся из closeProject ПЕРЕД обнулением state.currentBoard.
|
|
197
|
+
// Без этого pending viewSaveTimer/saveTimer срабатывали уже после того как
|
|
198
|
+
// state.currentBoard стал null → write скипался → позиция терялась.
|
|
199
|
+
// (closeProject уже вызывал `window.flushScheduledSave` через typeof-guard,
|
|
200
|
+
// но функция нигде не была определена — guard скрывал баг.)
|
|
201
|
+
window.flushScheduledSave = async function () {
|
|
202
|
+
if (!state.currentBoard) return;
|
|
203
|
+
clearTimeout(viewSaveTimer);
|
|
204
|
+
clearTimeout(saveTimer); saveTimer = null;
|
|
205
|
+
_updateViewFromScroll();
|
|
206
|
+
await _runSaveNow();
|
|
207
|
+
};
|
|
113
208
|
|
|
114
209
|
// =================== Скачивание аудио на выбранной скорости (ffmpeg.wasm atempo) ===================
|
|
115
210
|
let ffmpegInstance = null;
|
|
@@ -748,6 +843,17 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
|
|
|
748
843
|
if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
|
|
749
844
|
refs.push(pr);
|
|
750
845
|
}
|
|
846
|
+
// Incoming-edge refs: для text-нод OpenRouter chat принимает image-входы.
|
|
847
|
+
// Юзер: «если в ноду 'входит' нода-картинка/видео — подавай её как реф».
|
|
848
|
+
// Видео для text-gen бесполезно (модели не глотают video frames в chat
|
|
849
|
+
// API), поэтому фильтруем по image.
|
|
850
|
+
if (typeof gatherIncomingMediaRefs === 'function') {
|
|
851
|
+
for (const ir of gatherIncomingMediaRefs(node.id)) {
|
|
852
|
+
if (ir.type !== 'image') continue;
|
|
853
|
+
if (refs.some(r => r.file === ir.file && r.boardHandle === ir.boardHandle)) continue;
|
|
854
|
+
refs.push(ir);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
751
857
|
const missing = refs.filter(r => !r.file || r.status === 'generating');
|
|
752
858
|
if (missing.length) {
|
|
753
859
|
alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
|
|
@@ -780,6 +886,17 @@ async function regenerateInto(node, kind, rawPrompt, opts = {}) {
|
|
|
780
886
|
if (refs.some(r => r.file === pr.file && r.boardHandle === pr.boardHandle)) continue;
|
|
781
887
|
refs.push(pr);
|
|
782
888
|
}
|
|
889
|
+
// Incoming-edge refs (image/video → image/video нода). Юзер: «если в
|
|
890
|
+
// ноду 'входит' несколько других нод-картинок или 'нод-видео' —
|
|
891
|
+
// подавай их тоже как референсы». Граф уже выражает связь, не нужно
|
|
892
|
+
// дублировать через @name в промпте. Берём ВСЕ incoming media-источники
|
|
893
|
+
// — не только pending-anchor, как для NEW ноды.
|
|
894
|
+
if (typeof gatherIncomingMediaRefs === 'function') {
|
|
895
|
+
for (const ir of gatherIncomingMediaRefs(node.id)) {
|
|
896
|
+
if (refs.some(r => r.file === ir.file && r.boardHandle === ir.boardHandle)) continue;
|
|
897
|
+
refs.push(ir);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
783
900
|
const missing = refs.filter(r => !r.file || r.status === 'generating');
|
|
784
901
|
if (missing.length) {
|
|
785
902
|
alert('Эти ноды ещё не готовы: ' + missing.map(m => '@' + (m.name || m.id)).join(', '));
|
package/renderer/settings.js
CHANGED
|
@@ -552,23 +552,15 @@ async function renderNodeBody(node, body) {
|
|
|
552
552
|
playRow.appendChild(playBtn);
|
|
553
553
|
body.appendChild(playRow);
|
|
554
554
|
}
|
|
555
|
-
// Описание (опц.) —
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
desc.rows = 1;
|
|
565
|
-
desc.addEventListener('mousedown', e => e.stopPropagation());
|
|
566
|
-
desc.addEventListener('input', () => {
|
|
567
|
-
node.description = desc.value;
|
|
568
|
-
scheduleSave();
|
|
569
|
-
});
|
|
570
|
-
body.appendChild(desc);
|
|
571
|
-
}
|
|
555
|
+
// Описание (опц.) — read-only текст под медиа. Редактирование через
|
|
556
|
+
// ПКМ «📝 Изменить описание…» (открывает большую textarea-модалку).
|
|
557
|
+
// ВАЖНО: рендерится НЕ внутри .node (image/video задают фиксированный
|
|
558
|
+
// aspect-ratio высоты, всё ниже обрезается content-visibility:auto
|
|
559
|
+
// и paint-containment). Юзер: «давай попробуем показать описание
|
|
560
|
+
// ВНЕ этой ноды, но так чтобы оно с ней перемещалось». Описание —
|
|
561
|
+
// отдельный sibling-элемент `.node-description-outside` на canvas'е,
|
|
562
|
+
// позиционируется в (node.x, node.y+node.height+4), width=node.width.
|
|
563
|
+
// Перемещается через ensureNodeDescriptionEl (вызывается из drag/resize).
|
|
572
564
|
} else if (node.type === 'image' && !node.file && (node.generated?.rawPrompt || node.generated?.prompt)) {
|
|
573
565
|
// Image-нода с промптом, но без файла (ещё не генерилась).
|
|
574
566
|
// Кнопка ▶ Запустить — сверху (без декоративной 🖼-иконки и
|
|
@@ -589,6 +581,48 @@ async function renderNodeBody(node, body) {
|
|
|
589
581
|
}
|
|
590
582
|
}
|
|
591
583
|
|
|
584
|
+
// Описание image/video-ноды отрисовывается ВНЕ ноды (sibling на canvas'е).
|
|
585
|
+
// Раньше пробовали инлайн внутри .node — но image/video задают высоту через
|
|
586
|
+
// aspect-ratio + paint-containment у .node клипит всё что вне bbox. Юзер:
|
|
587
|
+
// «попробуем показать описание ВНЕ этой ноды, но так чтобы оно с ней
|
|
588
|
+
// перемещалось… совпадало с ней по ширине, а высота — сколько займет текст».
|
|
589
|
+
// Идемпотентно: пересоздаёт DOM, если описание изменилось/добавилось/удалилось.
|
|
590
|
+
function ensureNodeDescriptionEl(node) {
|
|
591
|
+
if (!node) return null;
|
|
592
|
+
const canvasEl = document.getElementById('canvas');
|
|
593
|
+
if (!canvasEl) return null;
|
|
594
|
+
const text = (node.type === 'image' || node.type === 'video') ? (node.description || '').trim() : '';
|
|
595
|
+
const sel = `.node-description-outside[data-desc-for="${node.id}"]`;
|
|
596
|
+
let el = canvasEl.querySelector(sel);
|
|
597
|
+
if (!text) {
|
|
598
|
+
// Описания нет — удаляем sidecar.
|
|
599
|
+
if (el) el.remove();
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
if (!el) {
|
|
603
|
+
el = document.createElement('div');
|
|
604
|
+
el.className = 'node-description-outside';
|
|
605
|
+
el.dataset.descFor = node.id;
|
|
606
|
+
canvasEl.appendChild(el);
|
|
607
|
+
}
|
|
608
|
+
if (el.textContent !== text) el.textContent = text;
|
|
609
|
+
positionNodeDescriptionEl(node, el);
|
|
610
|
+
return el;
|
|
611
|
+
}
|
|
612
|
+
function positionNodeDescriptionEl(node, el) {
|
|
613
|
+
if (!el) el = document.querySelector(`.node-description-outside[data-desc-for="${node.id}"]`);
|
|
614
|
+
if (!el) return;
|
|
615
|
+
const w = node.width || 280;
|
|
616
|
+
const h = node.height || 200;
|
|
617
|
+
const GAP = 4;
|
|
618
|
+
el.style.left = node.x + 'px';
|
|
619
|
+
el.style.top = (node.y + h + GAP) + 'px';
|
|
620
|
+
el.style.width = w + 'px';
|
|
621
|
+
}
|
|
622
|
+
function removeNodeDescriptionEl(nodeId) {
|
|
623
|
+
const el = document.querySelector(`.node-description-outside[data-desc-for="${nodeId}"]`);
|
|
624
|
+
if (el) el.remove();
|
|
625
|
+
}
|
|
592
626
|
// Подгоняет node.height под natural-aspect медиа (image/video). Сохраняет
|
|
593
627
|
// текущую node.width, пересчитывает height = width * (natH/natW) + chrome.
|
|
594
628
|
// Без этого после генерации (или history-undo на ноду с другим aspect'ом)
|
|
@@ -612,6 +646,7 @@ function fitNodeHeightToMedia(node, nodeEl, mediaEl) {
|
|
|
612
646
|
node.height = newH;
|
|
613
647
|
nodeEl.style.height = newH + 'px';
|
|
614
648
|
if (typeof renderConnections === 'function') renderConnections();
|
|
649
|
+
positionNodeDescriptionEl(node);
|
|
615
650
|
scheduleSave();
|
|
616
651
|
}
|
|
617
652
|
|
|
@@ -916,6 +951,8 @@ function attachResize(el, node, handle) {
|
|
|
916
951
|
// out, левая для in) считаются от node.x/y/width/height, поэтому при
|
|
917
952
|
// resize bezier должен следовать за новым размером.
|
|
918
953
|
renderConnections();
|
|
954
|
+
// Sidecar-описание подстраивается под новую ширину/высоту.
|
|
955
|
+
positionNodeDescriptionEl(node);
|
|
919
956
|
};
|
|
920
957
|
const onUp = () => {
|
|
921
958
|
document.removeEventListener('mousemove', onMove);
|
|
@@ -1055,10 +1092,15 @@ function makeDragHandler(el, node) {
|
|
|
1055
1092
|
const dxNode = (ev.clientX - startX) / state.zoom;
|
|
1056
1093
|
const dyNode = (ev.clientY - startY) / state.zoom;
|
|
1057
1094
|
for (const t of dragTargets) {
|
|
1058
|
-
|
|
1059
|
-
|
|
1095
|
+
// Раньше клампали в Math.max(0,...) — но canvas-frame отрисовывает
|
|
1096
|
+
// отрицательную зону вокруг canvas (padX=2000), и юзер видит её.
|
|
1097
|
+
// Юзер: «нужно позволить сдвигать выше и левее нуля».
|
|
1098
|
+
t.node.x = t.origX + dxNode;
|
|
1099
|
+
t.node.y = t.origY + dyNode;
|
|
1060
1100
|
t.el.style.left = t.node.x + 'px';
|
|
1061
1101
|
t.el.style.top = t.node.y + 'px';
|
|
1102
|
+
// Sidecar-описание движется вместе с нодой (тот же x, top чуть ниже).
|
|
1103
|
+
positionNodeDescriptionEl(t.node);
|
|
1062
1104
|
}
|
|
1063
1105
|
renderConnections();
|
|
1064
1106
|
};
|
|
@@ -1198,6 +1240,8 @@ async function deleteNode(node, el) {
|
|
|
1198
1240
|
renderConnections();
|
|
1199
1241
|
}
|
|
1200
1242
|
if (el) el.remove();
|
|
1243
|
+
// Sidecar-описание (если было) — уезжает вместе с нодой.
|
|
1244
|
+
removeNodeDescriptionEl(node.id);
|
|
1201
1245
|
// Записываем history ПОСЛЕ — но snapshot был снят ДО мутации
|
|
1202
1246
|
const h = _getHistory();
|
|
1203
1247
|
if (h) {
|
|
@@ -1286,6 +1330,7 @@ async function deleteSelectedNodes() {
|
|
|
1286
1330
|
for (const id of ids) {
|
|
1287
1331
|
const el = canvas.querySelector(`.node[data-id="${id}"]`);
|
|
1288
1332
|
if (el) el.remove();
|
|
1333
|
+
removeNodeDescriptionEl(id);
|
|
1289
1334
|
}
|
|
1290
1335
|
state.selectedNodeIds.clear();
|
|
1291
1336
|
renderConnections();
|
package/renderer/state.js
CHANGED
|
@@ -6,6 +6,32 @@
|
|
|
6
6
|
// видят друг друга по именам, без import/export. Порядок загрузки
|
|
7
7
|
// важен: см. <script> теги внизу index.html.
|
|
8
8
|
|
|
9
|
+
// === File-логгер (Electron only): дублируем console.* в userData/app.log,
|
|
10
|
+
// чтобы агент-помощник (Claude) мог анализировать историю. На web (без
|
|
11
|
+
// window.appLog) — noop. ====================================================
|
|
12
|
+
(function _hookConsoleToAppLog() {
|
|
13
|
+
if (!window.appLog?.write) return;
|
|
14
|
+
const fmt = a => {
|
|
15
|
+
if (a instanceof Error) return a.stack || a.message || String(a);
|
|
16
|
+
if (typeof a === 'string') return a;
|
|
17
|
+
try { return JSON.stringify(a); } catch { return String(a); }
|
|
18
|
+
};
|
|
19
|
+
const wrap = (orig, level) => (...args) => {
|
|
20
|
+
try { window.appLog.write(level, args.map(fmt).join(' ')); } catch {}
|
|
21
|
+
orig.apply(console, args);
|
|
22
|
+
};
|
|
23
|
+
console.log = wrap(console.log, 'log');
|
|
24
|
+
console.warn = wrap(console.warn, 'warn');
|
|
25
|
+
console.error = wrap(console.error, 'error');
|
|
26
|
+
// Подхватываем uncaught/unhandled — стек туда же.
|
|
27
|
+
window.addEventListener('error', e => {
|
|
28
|
+
try { window.appLog.write('uncaught', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}\n${e.error?.stack || ''}`); } catch {}
|
|
29
|
+
});
|
|
30
|
+
window.addEventListener('unhandledrejection', e => {
|
|
31
|
+
try { window.appLog.write('unhandled', `${e.reason?.message || e.reason}\n${e.reason?.stack || ''}`); } catch {}
|
|
32
|
+
});
|
|
33
|
+
})();
|
|
34
|
+
|
|
9
35
|
// =================== IndexedDB (хэндл папки фильма) ===================
|
|
10
36
|
const DB_NAME = 'video-editor';
|
|
11
37
|
const STORE = 'handles';
|
package/renderer/styles.css
CHANGED
|
@@ -110,18 +110,47 @@
|
|
|
110
110
|
width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
|
|
111
111
|
background: #1a1a1a; border-radius: 8px; padding: 4px;
|
|
112
112
|
}
|
|
113
|
-
/* Описание под image/video нодой.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
/* Описание под image/video нодой. Раньше пробовали рендерить ВНУТРИ .node,
|
|
114
|
+
но image/video задают фиксированный aspect-ratio высоту, а .node клипит
|
|
115
|
+
overflow (content-visibility:auto + paint-containment) — текст ниже не
|
|
116
|
+
был виден. Теперь описание — отдельный sibling-элемент на canvas'е
|
|
117
|
+
(`.node-description-outside`), позиционируется в (node.x, node.y+h+gap),
|
|
118
|
+
ширина = node.width, высота = auto по контенту. См. ensureNodeDescriptionEl
|
|
119
|
+
в settings.js (вызывается из renderCanvas / drag / resize / ПКМ-меню). */
|
|
120
|
+
.node-description-outside {
|
|
121
|
+
position: absolute;
|
|
122
|
+
padding: 6px 8px;
|
|
117
123
|
background: #1e1e1e; color: #ccc;
|
|
118
|
-
border:
|
|
119
|
-
font-
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
border-left: 2px solid #4a6a9a; border-radius: 2px;
|
|
125
|
+
font-size: 12px; line-height: 1.4;
|
|
126
|
+
white-space: pre-wrap; word-wrap: break-word;
|
|
127
|
+
box-sizing: border-box;
|
|
128
|
+
user-select: text; -webkit-user-select: text; cursor: text;
|
|
129
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
|
130
|
+
z-index: 2;
|
|
131
|
+
pointer-events: auto;
|
|
132
|
+
}
|
|
133
|
+
/* Legacy: оставляем класс для совместимости (вдруг где-то ещё ссылается). */
|
|
134
|
+
.node .node-description { display: none; }
|
|
135
|
+
|
|
136
|
+
/* Большая явная кнопка «Перейти → <имя сцены>» снизу ноды. Видна когда
|
|
137
|
+
node.linkedBoard.name задан (см. refreshNodeLinkBadge). Position:
|
|
138
|
+
absolute, чуть выше нижней границы ноды; перекрывает контент только
|
|
139
|
+
визуально (pointer-events:auto только на кнопке). */
|
|
140
|
+
.node .link-go-btn {
|
|
141
|
+
position: absolute; left: 8px; right: 8px; bottom: 6px;
|
|
142
|
+
padding: 5px 10px; font-size: 11px; font-weight: 500;
|
|
143
|
+
background: #2a3854; color: #aac8e6;
|
|
144
|
+
border: 1px solid #4a6a9a; border-radius: 4px;
|
|
145
|
+
cursor: pointer; text-align: center;
|
|
146
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
147
|
+
transition: background 0.12s;
|
|
148
|
+
z-index: 4;
|
|
149
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
|
122
150
|
}
|
|
123
|
-
.node .
|
|
124
|
-
|
|
151
|
+
.node .link-go-btn:hover { background: #3a5a8a; color: #fff; }
|
|
152
|
+
/* В drawing-нодах (тонкая стрелка) кнопка визуально странная — не показываем. */
|
|
153
|
+
.node.drawing-node .link-go-btn { display: none !important; }
|
|
125
154
|
|
|
126
155
|
/* Link-badge: значок 🔗 на ноде когда у неё есть linkedBoard. Лежит в
|
|
127
156
|
правом-верхнем углу, кликабельный (= dblclick: открыть target-сцену).
|
|
@@ -141,6 +170,44 @@
|
|
|
141
170
|
к левому-верху самой bbox'ы. */
|
|
142
171
|
.node.drawing-node .link-badge { top: 4px; right: 4px; }
|
|
143
172
|
|
|
173
|
+
/* Мобильный бургер для template/view-only. По умолчанию скрыт — показывается
|
|
174
|
+
только на узких экранах в view-only-mode (template-просмотр). В edit-режиме
|
|
175
|
+
юзер обычно на десктопе, sidebar нужен постоянно. */
|
|
176
|
+
.kk-mobile-burger {
|
|
177
|
+
display: none; padding: 6px 10px; font-size: 18px; line-height: 1;
|
|
178
|
+
background: transparent; color: #ccc; border: 1px solid #444;
|
|
179
|
+
border-radius: 4px; cursor: pointer;
|
|
180
|
+
}
|
|
181
|
+
.kk-mobile-burger:hover { background: #2a2a2a; color: #fff; }
|
|
182
|
+
@media (max-width: 768px) {
|
|
183
|
+
body.view-only-mode .kk-mobile-burger { display: inline-flex; }
|
|
184
|
+
/* Sidebar в шторку (slide-in). Position:fixed чтобы перекрывать canvas. */
|
|
185
|
+
body.view-only-mode .sidebar {
|
|
186
|
+
position: fixed; left: 0; top: 0; height: 100vh; z-index: 90;
|
|
187
|
+
transform: translateX(-100%); transition: transform 0.2s ease;
|
|
188
|
+
box-shadow: 4px 0 16px rgba(0,0,0,0.5);
|
|
189
|
+
}
|
|
190
|
+
body.view-only-mode.sidebar-open .sidebar { transform: translateX(0); }
|
|
191
|
+
/* Backdrop (затемнение) когда sidebar открыт. */
|
|
192
|
+
body.view-only-mode.sidebar-open::before {
|
|
193
|
+
content: ''; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
|
194
|
+
z-index: 89; cursor: pointer;
|
|
195
|
+
}
|
|
196
|
+
/* В template/mobile прячем ВСЁ из toolbar'а кроме бургера —
|
|
197
|
+
у юзера маленький экран, кнопки управления (zoom, banner) занимают
|
|
198
|
+
половину viewport'а. Юзер: «оставь только бургер». */
|
|
199
|
+
body.view-only-mode .toolbar > *:not(.kk-mobile-burger) { display: none !important; }
|
|
200
|
+
/* Сам toolbar тоже минимизируем — без растягивания и фона, чтобы canvas
|
|
201
|
+
занял максимум места. */
|
|
202
|
+
body.view-only-mode .toolbar {
|
|
203
|
+
padding: 4px; min-height: auto; border-bottom: none;
|
|
204
|
+
background: transparent;
|
|
205
|
+
}
|
|
206
|
+
/* Канвас должен принимать pinch-zoom через JS, native scroll и pan
|
|
207
|
+
по 1 пальцу остаются. */
|
|
208
|
+
body.view-only-mode .canvas-wrap { touch-action: pan-x pan-y; }
|
|
209
|
+
}
|
|
210
|
+
|
|
144
211
|
/* Brand-logo и welcome-logo всегда кликабельны (на главной → ничего,
|
|
145
212
|
в проекте → возврат на welcome, dblclick → настройки). cursor:pointer
|
|
146
213
|
ставится и в HTML inline, но в web chatium-минифайер иногда срывает
|
package/renderer/templates.js
CHANGED
|
@@ -528,8 +528,17 @@ async function collectProjectBoards(filmHandle) {
|
|
|
528
528
|
await b.handle.getFileHandle('scene.json');
|
|
529
529
|
// Исключаем scene.json и .thumbnails/ из mediaFiles — они либо
|
|
530
530
|
// manifest, либо генерятся клиентом.
|
|
531
|
+
// scene.json — manifest, отдельный канал.
|
|
532
|
+
// .thumbnails/ — старая папка миниатюр (legacy).
|
|
533
|
+
// thumbs/ — текущая папка миниатюр (генерятся клиентом
|
|
534
|
+
// через generateThumbnailIfMissing; их НЕ нужно
|
|
535
|
+
// заливать на сервер — получатель сам сгенерит).
|
|
536
|
+
// Без этого фильтра save отправлял 4 frames + 4 thumbs
|
|
537
|
+
// = 8 файлов вместо 4. См. логи.
|
|
531
538
|
b.mediaFiles = b.mediaFiles.filter(f =>
|
|
532
|
-
f.relPath !== 'scene.json' &&
|
|
539
|
+
f.relPath !== 'scene.json' &&
|
|
540
|
+
!f.relPath.startsWith('.thumbnails/') &&
|
|
541
|
+
!f.relPath.startsWith('thumbs/'));
|
|
533
542
|
validBoards.push(b);
|
|
534
543
|
} catch {}
|
|
535
544
|
}
|
package/server.js
CHANGED
|
@@ -139,7 +139,14 @@ async function handleProxy(res, url) {
|
|
|
139
139
|
try { u = new URL(target); } catch { return send(res, 400, { error: 'битый url' }); }
|
|
140
140
|
if (!/^https?:$/.test(u.protocol)) return send(res, 400, { error: 'разрешены только http/https' });
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// Auth-aware прокси: некоторые upstream-URL'ы (OpenRouter video content)
|
|
143
|
+
// требуют Bearer-токен. Добавляем здесь чтобы клиентский <video src="...">
|
|
144
|
+
// мог их подгрузить через /api/proxy без обхода CORS+auth в renderer'е.
|
|
145
|
+
const fetchHeaders = {};
|
|
146
|
+
if (u.hostname === 'openrouter.ai' && process.env.OPENROUTER_API_KEY) {
|
|
147
|
+
fetchHeaders.Authorization = 'Bearer ' + process.env.OPENROUTER_API_KEY;
|
|
148
|
+
}
|
|
149
|
+
const r = await fetch(target, { headers: fetchHeaders });
|
|
143
150
|
if (!r.ok) return send(res, r.status, { error: `upstream ${r.status}` });
|
|
144
151
|
const headers = {
|
|
145
152
|
'Content-Type': r.headers.get('content-type') || 'application/octet-stream',
|