kingkont 0.8.8 → 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/index.html +42 -3
- package/lib/providers.js +115 -2
- package/main.js +120 -0
- package/package.json +1 -1
- package/preload.js +38 -12
- package/renderer/board.js +285 -87
- package/renderer/cloudFs.js +271 -0
- package/renderer/cloudProjects.js +612 -0
- package/renderer/generate.js +7 -0
- package/renderer/state.js +5 -0
- package/renderer/styles.css +69 -5
- package/renderer/templates.js +8 -6
- package/renderer/timeline.js +149 -7
- package/server.js +55 -1
package/renderer/styles.css
CHANGED
|
@@ -288,6 +288,18 @@
|
|
|
288
288
|
color: #444; font-size: 32px;
|
|
289
289
|
}
|
|
290
290
|
.welcome-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
291
|
+
/* ☁-бейдж в углу обложки облачного проекта. Делает cloud/folder-проекты
|
|
292
|
+
визуально различимыми в общем grid'е (без необходимости второго рядa). */
|
|
293
|
+
.welcome-card-thumb { position: relative; }
|
|
294
|
+
.welcome-card-cloud-badge {
|
|
295
|
+
position: absolute; right: 6px; bottom: 6px;
|
|
296
|
+
background: rgba(20, 30, 50, 0.85); color: #9cf;
|
|
297
|
+
border: 1px solid rgba(160, 200, 255, 0.35);
|
|
298
|
+
border-radius: 50%; width: 22px; height: 22px;
|
|
299
|
+
display: flex; align-items: center; justify-content: center;
|
|
300
|
+
font-size: 13px; line-height: 1;
|
|
301
|
+
backdrop-filter: blur(2px);
|
|
302
|
+
}
|
|
291
303
|
.welcome-card-meta { padding: 8px 12px; }
|
|
292
304
|
.welcome-card-name { font-size: 13px; color: #ddd; word-break: break-all; }
|
|
293
305
|
.welcome-card-ts { font-size: 11px; color: #666; margin-top: 2px; }
|
|
@@ -735,8 +747,16 @@
|
|
|
735
747
|
.fs-modal #fsClose {
|
|
736
748
|
position: absolute; top: 16px; right: 16px;
|
|
737
749
|
background: rgba(0,0,0,0.6); border: 1px solid #444; color: #fff;
|
|
738
|
-
font-size: 22px; line-height: 1;
|
|
750
|
+
font-size: 22px; line-height: 1;
|
|
751
|
+
/* Увеличен hit-area: квадрат 40×40, центрированный «×». Раньше padding
|
|
752
|
+
был 4px 12px → размер ~30×30, и нижне-правые пиксели оказывались под
|
|
753
|
+
<img>/<video> в .fs-stage (одна стека, нет z-index). z-index:1100
|
|
754
|
+
гарантирует что × ВСЕГДА сверху всего внутри fs-modal. */
|
|
755
|
+
width: 40px; height: 40px;
|
|
756
|
+
display: flex; align-items: center; justify-content: center;
|
|
757
|
+
padding: 0; border-radius: 4px;
|
|
739
758
|
cursor: pointer;
|
|
759
|
+
z-index: 1100;
|
|
740
760
|
}
|
|
741
761
|
.fs-modal #fsClose:hover { background: rgba(255,68,68,0.7); }
|
|
742
762
|
|
|
@@ -783,6 +803,7 @@
|
|
|
783
803
|
font-size: 36px; line-height: 1; padding: 4px 14px; border-radius: 6px;
|
|
784
804
|
cursor: pointer; user-select: none;
|
|
785
805
|
transition: background 0.12s;
|
|
806
|
+
z-index: 1050;
|
|
786
807
|
}
|
|
787
808
|
.fs-modal .fs-nav:hover { background: rgba(0,0,0,0.85); }
|
|
788
809
|
.fs-modal .fs-nav:disabled { opacity: 0.3; cursor: default; }
|
|
@@ -949,6 +970,44 @@
|
|
|
949
970
|
.preview-panel .timeline-preview-img.hidden,
|
|
950
971
|
.preview-panel .timeline-preview.hidden { display: none; }
|
|
951
972
|
|
|
973
|
+
/* Overlay-контролы поверх preview-стейджа. Видимы пока есть клипы.
|
|
974
|
+
Полу-прозрачный фон + лёгкий blur — читаемо поверх любой картинки.
|
|
975
|
+
Контейнер не переносит на новую строку (white-space:nowrap) даже на
|
|
976
|
+
узком preview — лучше горизонтальный скролл, чем разорванный таймер. */
|
|
977
|
+
.preview-controls {
|
|
978
|
+
position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
|
|
979
|
+
display: flex; align-items: center; gap: 10px;
|
|
980
|
+
padding: 8px 14px; border-radius: 999px;
|
|
981
|
+
background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.1);
|
|
982
|
+
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
|
983
|
+
z-index: 10; opacity: 0.45; transition: opacity 0.15s;
|
|
984
|
+
white-space: nowrap; flex-wrap: nowrap;
|
|
985
|
+
max-width: calc(100% - 16px);
|
|
986
|
+
}
|
|
987
|
+
.preview-stage:hover .preview-controls,
|
|
988
|
+
.preview-controls.is-playing { opacity: 1; }
|
|
989
|
+
.preview-controls.hidden { display: none; }
|
|
990
|
+
.preview-controls button {
|
|
991
|
+
background: transparent; border: none; color: #fff;
|
|
992
|
+
font-size: 24px; line-height: 1;
|
|
993
|
+
width: 32px; height: 32px;
|
|
994
|
+
display: flex; align-items: center; justify-content: center;
|
|
995
|
+
padding: 0; cursor: pointer; border-radius: 50%;
|
|
996
|
+
flex-shrink: 0;
|
|
997
|
+
}
|
|
998
|
+
.preview-controls button:hover { background: rgba(255,255,255,0.15); }
|
|
999
|
+
.preview-controls .preview-time {
|
|
1000
|
+
font-family: ui-monospace, 'SF Mono', monospace;
|
|
1001
|
+
font-size: 15px; color: #fff;
|
|
1002
|
+
min-width: 90px; text-align: center;
|
|
1003
|
+
white-space: nowrap; flex-shrink: 0;
|
|
1004
|
+
}
|
|
1005
|
+
/* «×» закрытия — чуть тоньше play-кнопки чтобы не доминировал визуально. */
|
|
1006
|
+
.preview-controls #previewCloseBtn {
|
|
1007
|
+
font-size: 20px; opacity: 0.75;
|
|
1008
|
+
}
|
|
1009
|
+
.preview-controls #previewCloseBtn:hover { opacity: 1; background: rgba(255,68,68,0.55); }
|
|
1010
|
+
|
|
952
1011
|
/* === Таймлайн (нижняя панель, full-width) === */
|
|
953
1012
|
.timeline-panel {
|
|
954
1013
|
border-top: 1px solid #333; background: #1c1c1c;
|
|
@@ -1264,15 +1323,20 @@
|
|
|
1264
1323
|
}
|
|
1265
1324
|
.gen-error button { margin-top: 8px; }
|
|
1266
1325
|
|
|
1267
|
-
|
|
1326
|
+
/* Эта empty-overlay только для #emptyState (заглушка на пустом холсте).
|
|
1327
|
+
Раньше селектор был `.empty` (глобально) — он ловил, например,
|
|
1328
|
+
`.balance-info.empty` (модификатор «баланс=0») и делал её
|
|
1329
|
+
position:absolute; inset:0 — pill растекалась во весь viewport.
|
|
1330
|
+
Теперь специфично через id, конфликта нет. */
|
|
1331
|
+
#emptyState {
|
|
1268
1332
|
position: absolute; inset: 0;
|
|
1269
1333
|
display: flex; align-items: center; justify-content: center;
|
|
1270
1334
|
color: #666; font-size: 15px; flex-direction: column; gap: 14px;
|
|
1271
1335
|
text-align: center; padding: 24px; pointer-events: none;
|
|
1272
1336
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
.
|
|
1337
|
+
#emptyState h3 { color: #aaa; font-size: 18px; }
|
|
1338
|
+
#emptyState p { max-width: 420px; line-height: 1.6; }
|
|
1339
|
+
#emptyState.hidden { display: none; }
|
|
1276
1340
|
|
|
1277
1341
|
.unsupported { margin: auto; max-width: 540px; padding: 32px; background: #2a2a2a; border-radius: 12px; border: 1px solid #444; }
|
|
1278
1342
|
.unsupported h1 { margin-bottom: 12px; }
|
package/renderer/templates.js
CHANGED
|
@@ -117,12 +117,14 @@ async function refreshTemplatesList() {
|
|
|
117
117
|
}
|
|
118
118
|
const data = await r.json();
|
|
119
119
|
let items = Array.isArray(data) ? data : (data.items || data.templates || []);
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
// Скрываем scene/character/location-шаблоны — оставляем ТОЛЬКО проекты.
|
|
121
|
+
// (Раньше при открытом проекте можно было добавить scene-шаблон как
|
|
122
|
+
// новую сцену. Сейчас концепция упрощается до cloud-projects, но код
|
|
123
|
+
// scene-templates оставлен — может пригодиться позже.)
|
|
124
|
+
items = items.filter(t => t.kind === 'project');
|
|
125
|
+
// Когда проект открыт — open-project-template создаст НОВЫЙ проект,
|
|
126
|
+
// что в этот момент бессмысленно. Скрываем всё.
|
|
127
|
+
if (state.filmHandle) items = [];
|
|
126
128
|
renderTemplatesList(items);
|
|
127
129
|
} catch (e) {
|
|
128
130
|
list.innerHTML = '';
|
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);
|
|
@@ -252,6 +260,41 @@ async function handleTemplateUpdate(req, res, id) {
|
|
|
252
260
|
} catch (e) { sendError(res, e, 502); }
|
|
253
261
|
}
|
|
254
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
|
+
|
|
255
298
|
// =============================================================================
|
|
256
299
|
// Static files (renderer assets).
|
|
257
300
|
// =============================================================================
|
|
@@ -301,6 +344,17 @@ const server = createServer(async (req, res) => {
|
|
|
301
344
|
if (req.method === 'DELETE') return handleTemplateDelete(res, decodeURIComponent(m[1]));
|
|
302
345
|
}
|
|
303
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
|
+
}
|
|
304
358
|
if (req.method === 'GET') return serveStatic(res, url);
|
|
305
359
|
send(res, 404, 'not found');
|
|
306
360
|
} catch (e) {
|