evomeme 0.1.7 → 0.1.9
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/dist/canvas-public/app.js +1759 -1496
- package/dist/canvas-public/index.html +43 -99
- package/dist/canvas-public/smart-split.js +1 -1
- package/dist/canvas-public/styles.css +349 -378
- package/dist/index.js +1111 -507
- package/package.json +1 -1
- package/skills/evomeme-canvas/SKILL.md +52 -0
- package/skills/evomeme-canvas/agents/openai.yaml +7 -0
- package/skills/evomeme-game-maker/SKILL.md +83 -12
- package/dist/canvas-public/sequence-sheet.js +0 -59
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const DEFAULT_W = 220
|
|
1
|
+
const IMAGE_BASE_SIZE = 220
|
|
2
|
+
const DEFAULT_W = IMAGE_BASE_SIZE
|
|
4
3
|
const GEN_DIALOG_W = 268
|
|
5
4
|
const VIDEO_DIALOG_W = 288
|
|
5
|
+
const FRAME_EDITOR_W = 780
|
|
6
6
|
const GENERATOR_DIALOG_DRAG_OFFSET_Y = 18
|
|
7
|
+
const MIN_MEDIA_RESIZE_SIDE = 48
|
|
7
8
|
|
|
8
9
|
const state = {
|
|
9
10
|
project: null,
|
|
@@ -11,13 +12,14 @@ const state = {
|
|
|
11
12
|
meta: null,
|
|
12
13
|
images: [],
|
|
13
14
|
layoutItems: [],
|
|
15
|
+
layoutRevision: 0,
|
|
14
16
|
selectedIds: new Set(),
|
|
15
|
-
editingSeqId: null,
|
|
16
17
|
genDialogs: {}, // id -> image dialog state
|
|
17
18
|
videoDialogs: {}, // id -> video dialog state
|
|
18
19
|
fileInputDialogId: null,
|
|
19
20
|
mediaInputDialogId: null,
|
|
20
|
-
|
|
21
|
+
activeOperations: new Set(),
|
|
22
|
+
failedOperationIds: new Set(),
|
|
21
23
|
deleting: false,
|
|
22
24
|
refreshing: false,
|
|
23
25
|
externalSyncPending: false,
|
|
@@ -33,15 +35,42 @@ const state = {
|
|
|
33
35
|
smartSplitAnchor: 'center',
|
|
34
36
|
manualSplitImageId: null,
|
|
35
37
|
frameExtractVideoId: null,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
framePreviewImageId: null,
|
|
39
|
+
frameEditorSourceImageId: null,
|
|
38
40
|
videoCycleId: null,
|
|
39
41
|
videoCycleAbort: null,
|
|
40
|
-
|
|
41
|
-
playback: {}, // seqId -> { index, acc }
|
|
42
|
+
frameSheetPlayback: {}, // canvas item id -> { index, acc }
|
|
42
43
|
imageDims: {}, // id -> { w, h } | { loading: true }
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function hasActiveOperations() {
|
|
47
|
+
return state.activeOperations.size > 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createCanvasClientId(prefix) {
|
|
51
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function beginActiveOperation(kind) {
|
|
55
|
+
const token = createCanvasClientId(kind || 'canvas')
|
|
56
|
+
state.activeOperations.add(token)
|
|
57
|
+
return token
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function endActiveOperation(token) {
|
|
61
|
+
if (token) state.activeOperations.delete(token)
|
|
62
|
+
if (!hasActiveOperations() && state.externalSyncPending && !shouldDeferExternalSync()) {
|
|
63
|
+
setTimeout(() => syncExternalChanges(), 0)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let layoutSaveQueue = Promise.resolve()
|
|
68
|
+
let layoutSaveSequence = 0
|
|
69
|
+
let layoutSaveEpoch = 0
|
|
70
|
+
let layoutPendingSaveCount = 0
|
|
71
|
+
let layoutNextExpectedRevision = 0
|
|
72
|
+
let imageSizeSaveTimer = null
|
|
73
|
+
|
|
45
74
|
const els = {
|
|
46
75
|
welcome: document.getElementById('welcome'),
|
|
47
76
|
welcomeOpenBtn: document.getElementById('welcome-open-btn'),
|
|
@@ -102,6 +131,24 @@ const els = {
|
|
|
102
131
|
frameExtractConfirm: document.getElementById('frame-extract-confirm'),
|
|
103
132
|
frameExtractCount: document.getElementById('frame-extract-count'),
|
|
104
133
|
frameExtractStatus: document.getElementById('frame-extract-status'),
|
|
134
|
+
framePreviewModal: document.getElementById('frame-preview-modal'),
|
|
135
|
+
framePreviewClose: document.getElementById('frame-preview-close'),
|
|
136
|
+
framePreviewCancel: document.getElementById('frame-preview-cancel'),
|
|
137
|
+
framePreviewConfirm: document.getElementById('frame-preview-confirm'),
|
|
138
|
+
framePreviewRows: document.getElementById('frame-preview-rows'),
|
|
139
|
+
framePreviewColumns: document.getElementById('frame-preview-columns'),
|
|
140
|
+
framePreviewFps: document.getElementById('frame-preview-fps'),
|
|
141
|
+
framePreviewInfo: document.getElementById('frame-preview-info'),
|
|
142
|
+
framePreviewGrid: document.getElementById('frame-preview-grid'),
|
|
143
|
+
framePreviewStatus: document.getElementById('frame-preview-status'),
|
|
144
|
+
frameEditorSplitModal: document.getElementById('frame-editor-split-modal'),
|
|
145
|
+
frameEditorSplitClose: document.getElementById('frame-editor-split-close'),
|
|
146
|
+
frameEditorSplitCancel: document.getElementById('frame-editor-split-cancel'),
|
|
147
|
+
frameEditorSplitConfirm: document.getElementById('frame-editor-split-confirm'),
|
|
148
|
+
frameEditorSplitRows: document.getElementById('frame-editor-split-rows'),
|
|
149
|
+
frameEditorSplitColumns: document.getElementById('frame-editor-split-columns'),
|
|
150
|
+
frameEditorSplitInfo: document.getElementById('frame-editor-split-info'),
|
|
151
|
+
frameEditorSplitStatus: document.getElementById('frame-editor-split-status'),
|
|
105
152
|
videoCycleModal: document.getElementById('video-cycle-modal'),
|
|
106
153
|
videoCycleClose: document.getElementById('video-cycle-close'),
|
|
107
154
|
videoCycleCancel: document.getElementById('video-cycle-cancel'),
|
|
@@ -109,41 +156,6 @@ const els = {
|
|
|
109
156
|
videoCycleThreshold: document.getElementById('video-cycle-threshold'),
|
|
110
157
|
videoCycleMinGap: document.getElementById('video-cycle-min-gap'),
|
|
111
158
|
videoCycleStatus: document.getElementById('video-cycle-status'),
|
|
112
|
-
seqSheetModal: document.getElementById('seq-sheet-modal'),
|
|
113
|
-
seqSheetClose: document.getElementById('seq-sheet-close'),
|
|
114
|
-
seqSheetCancel: document.getElementById('seq-sheet-cancel'),
|
|
115
|
-
seqSheetConfirm: document.getElementById('seq-sheet-confirm'),
|
|
116
|
-
seqSheetRows: document.getElementById('seq-sheet-rows'),
|
|
117
|
-
seqSheetCols: document.getElementById('seq-sheet-cols'),
|
|
118
|
-
seqSheetFrameInfo: document.getElementById('seq-sheet-frame-info'),
|
|
119
|
-
seqSheetPreview: document.getElementById('seq-sheet-preview'),
|
|
120
|
-
seqSheetStatus: document.getElementById('seq-sheet-status'),
|
|
121
|
-
videoTrimModal: document.getElementById('video-trim-modal'),
|
|
122
|
-
videoTrimClose: document.getElementById('video-trim-close'),
|
|
123
|
-
videoTrimCancel: document.getElementById('video-trim-cancel'),
|
|
124
|
-
videoTrimConfirm: document.getElementById('video-trim-confirm'),
|
|
125
|
-
videoTrimPreview: document.getElementById('video-trim-preview'),
|
|
126
|
-
videoTrimTimeline: document.getElementById('video-trim-timeline'),
|
|
127
|
-
videoTrimRange: document.getElementById('video-trim-range'),
|
|
128
|
-
videoTrimTrack: document.getElementById('video-trim-track'),
|
|
129
|
-
videoTrimScrub: document.getElementById('video-trim-scrub'),
|
|
130
|
-
videoTrimPlay: document.getElementById('video-trim-play'),
|
|
131
|
-
videoTrimNowLabel: document.getElementById('video-trim-now-label'),
|
|
132
|
-
videoTrimTotalLabel: document.getElementById('video-trim-total-label'),
|
|
133
|
-
videoTrimHandleStart: document.getElementById('video-trim-handle-start'),
|
|
134
|
-
videoTrimHandleEnd: document.getElementById('video-trim-handle-end'),
|
|
135
|
-
videoTrimStartLabel: document.getElementById('video-trim-start-label'),
|
|
136
|
-
videoTrimEndLabel: document.getElementById('video-trim-end-label'),
|
|
137
|
-
videoTrimDurLabel: document.getElementById('video-trim-dur-label'),
|
|
138
|
-
videoTrimStatus: document.getElementById('video-trim-status'),
|
|
139
|
-
seqModal: document.getElementById('seq-modal'),
|
|
140
|
-
seqPanelTitle: document.getElementById('seq-panel-title'),
|
|
141
|
-
seqPanelClose: document.getElementById('seq-panel-close'),
|
|
142
|
-
seqPanelDone: document.getElementById('seq-panel-done'),
|
|
143
|
-
seqFps: document.getElementById('seq-fps'),
|
|
144
|
-
seqFpsVal: document.getElementById('seq-fps-val'),
|
|
145
|
-
seqLoop: document.getElementById('seq-loop'),
|
|
146
|
-
seqFrames: document.getElementById('seq-frames'),
|
|
147
159
|
marquee: document.getElementById('marquee'),
|
|
148
160
|
}
|
|
149
161
|
|
|
@@ -209,10 +221,6 @@ function transparentBackgroundWarning(dialogState) {
|
|
|
209
221
|
: TRANSPARENT_BACKGROUND_REFERENCE_ERROR
|
|
210
222
|
}
|
|
211
223
|
|
|
212
|
-
function isSequence(item) {
|
|
213
|
-
return item && item.type === 'sequence' && Array.isArray(item.frames)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
224
|
function isGenDialog(item) {
|
|
217
225
|
return item && item.type === 'gen_dialog'
|
|
218
226
|
}
|
|
@@ -249,11 +257,22 @@ function parseSizeAspect(sizeStr) {
|
|
|
249
257
|
return { w, h }
|
|
250
258
|
}
|
|
251
259
|
|
|
260
|
+
function imageDisplaySize(pixelWidth, pixelHeight) {
|
|
261
|
+
const width = Number(pixelWidth)
|
|
262
|
+
const height = Number(pixelHeight)
|
|
263
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
264
|
+
return { width: IMAGE_BASE_SIZE, height: IMAGE_BASE_SIZE }
|
|
265
|
+
}
|
|
266
|
+
const scale = IMAGE_BASE_SIZE / Math.min(width, height)
|
|
267
|
+
return {
|
|
268
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
269
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
252
273
|
function pendingDisplaySize(sizeStr) {
|
|
253
274
|
const { w, h } = parseSizeAspect(sizeStr)
|
|
254
|
-
|
|
255
|
-
const height = Math.max(120, Math.round((width * h) / w))
|
|
256
|
-
return { width, height }
|
|
275
|
+
return imageDisplaySize(w, h)
|
|
257
276
|
}
|
|
258
277
|
|
|
259
278
|
function videoDisplaySize(ratio) {
|
|
@@ -311,7 +330,7 @@ function ensureVideoDialogState(id) {
|
|
|
311
330
|
|
|
312
331
|
function createGeneratorDialog(kind, position) {
|
|
313
332
|
const isVideo = kind === 'video'
|
|
314
|
-
const id =
|
|
333
|
+
const id = createCanvasClientId(isVideo ? 'videodialog' : 'gendialog')
|
|
315
334
|
const item = {
|
|
316
335
|
id,
|
|
317
336
|
type: isVideo ? 'video_dialog' : 'gen_dialog',
|
|
@@ -363,24 +382,50 @@ function ensureImageDims(id) {
|
|
|
363
382
|
const img = new Image()
|
|
364
383
|
img.onload = () => {
|
|
365
384
|
state.imageDims[id] = { w: img.naturalWidth, h: img.naturalHeight }
|
|
366
|
-
if (state.selectedIds.has(id)
|
|
385
|
+
if (state.selectedIds.has(id)) {
|
|
367
386
|
renderInspector()
|
|
368
387
|
}
|
|
369
388
|
}
|
|
370
389
|
img.onerror = () => {
|
|
371
390
|
state.imageDims[id] = { w: 0, h: 0 }
|
|
372
|
-
if (state.selectedIds.has(id)
|
|
391
|
+
if (state.selectedIds.has(id)) {
|
|
373
392
|
renderInspector()
|
|
374
393
|
}
|
|
375
394
|
}
|
|
376
395
|
img.src = imageUrl(id)
|
|
377
396
|
}
|
|
378
397
|
|
|
379
|
-
function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
398
|
+
function scheduleImageSizeSave() {
|
|
399
|
+
clearTimeout(imageSizeSaveTimer)
|
|
400
|
+
imageSizeSaveTimer = setTimeout(() => {
|
|
401
|
+
imageSizeSaveTimer = null
|
|
402
|
+
saveLayout(state.layoutItems).catch((error) => {
|
|
403
|
+
console.warn('save image canvas size failed', error)
|
|
404
|
+
})
|
|
405
|
+
}, 120)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function applyImageDisplaySize(id, pixelWidth, pixelHeight) {
|
|
409
|
+
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
410
|
+
if (
|
|
411
|
+
!item ||
|
|
412
|
+
item.manualSize === true ||
|
|
413
|
+
(item.layer && typeof item.layer === 'object') ||
|
|
414
|
+
isPending(item) ||
|
|
415
|
+
isGenDialog(item) ||
|
|
416
|
+
isVideoDialog(item) ||
|
|
417
|
+
isFrameEditor(item)
|
|
418
|
+
) {
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
const display = imageDisplaySize(pixelWidth, pixelHeight)
|
|
422
|
+
if (Number(item.width) === display.width && Number(item.height) === display.height) return
|
|
423
|
+
item.width = display.width
|
|
424
|
+
item.height = display.height
|
|
425
|
+
const nodeElement = findNodeEl(id)
|
|
426
|
+
if (nodeElement) nodeElement.style.width = `${display.width}px`
|
|
427
|
+
requestDependencyEdgeUpdate()
|
|
428
|
+
scheduleImageSizeSave()
|
|
384
429
|
}
|
|
385
430
|
|
|
386
431
|
function getSelectedItem(id) {
|
|
@@ -412,7 +457,7 @@ function appendKv(parent, rows) {
|
|
|
412
457
|
function isPlainImageItem(item) {
|
|
413
458
|
if (
|
|
414
459
|
!item ||
|
|
415
|
-
|
|
460
|
+
isFrameEditor(item) ||
|
|
416
461
|
isGenDialog(item) ||
|
|
417
462
|
isVideoDialog(item) ||
|
|
418
463
|
isPending(item) ||
|
|
@@ -423,6 +468,153 @@ function isPlainImageItem(item) {
|
|
|
423
468
|
return state.images.some((image) => image.id === item.id && image.kind === 'image')
|
|
424
469
|
}
|
|
425
470
|
|
|
471
|
+
function isFrameEditor(item) {
|
|
472
|
+
return item && item.type === 'frame_editor'
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function normalizeFramePreviewConfig(value) {
|
|
476
|
+
if (!value || typeof value !== 'object') return null
|
|
477
|
+
const rows = Math.round(Number(value.rows))
|
|
478
|
+
const columns = Math.round(Number(value.columns))
|
|
479
|
+
const frameCount = Math.round(Number(value.frameCount))
|
|
480
|
+
const fps = Number(value.fps)
|
|
481
|
+
if (
|
|
482
|
+
!Number.isFinite(rows) ||
|
|
483
|
+
!Number.isFinite(columns) ||
|
|
484
|
+
!Number.isFinite(frameCount) ||
|
|
485
|
+
!Number.isFinite(fps) ||
|
|
486
|
+
rows < 1 ||
|
|
487
|
+
rows > 64 ||
|
|
488
|
+
columns < 1 ||
|
|
489
|
+
columns > 64 ||
|
|
490
|
+
(rows === 1 && columns === 1) ||
|
|
491
|
+
frameCount < 1 ||
|
|
492
|
+
frameCount > rows * columns ||
|
|
493
|
+
fps <= 0
|
|
494
|
+
) {
|
|
495
|
+
return null
|
|
496
|
+
}
|
|
497
|
+
return { enabled: value.enabled === true, rows, columns, frameCount, fps }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function sourceFramePreviewConfig(item) {
|
|
501
|
+
const provenance = item?.provenance
|
|
502
|
+
const metadata = provenance?.metadata
|
|
503
|
+
if (metadata && typeof metadata === 'object') {
|
|
504
|
+
const generated = normalizeFramePreviewConfig({
|
|
505
|
+
enabled: false,
|
|
506
|
+
rows: metadata.rows,
|
|
507
|
+
columns: metadata.columns,
|
|
508
|
+
frameCount: metadata.frameCount,
|
|
509
|
+
fps: Number(metadata.fps) || Number(metadata.frameCount) || 8,
|
|
510
|
+
})
|
|
511
|
+
if (
|
|
512
|
+
generated &&
|
|
513
|
+
(metadata.kind === 'frame_sheet' || provenance.operation === 'video_frame_extract')
|
|
514
|
+
) {
|
|
515
|
+
return generated
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function suggestedFramePreviewConfig(item) {
|
|
523
|
+
const saved = normalizeFramePreviewConfig(item?.framePreview)
|
|
524
|
+
if (saved) return saved
|
|
525
|
+
const generated = sourceFramePreviewConfig(item)
|
|
526
|
+
if (generated) return generated
|
|
527
|
+
|
|
528
|
+
return { enabled: false, rows: 2, columns: 2, frameCount: 4, fps: 8 }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function frameSheetPreviewMeta(item) {
|
|
532
|
+
const config = normalizeFramePreviewConfig(item?.framePreview)
|
|
533
|
+
if (!config?.enabled) return null
|
|
534
|
+
|
|
535
|
+
const metadata = item?.provenance?.metadata
|
|
536
|
+
const cellWidth = Math.round(Number(metadata?.cellWidth))
|
|
537
|
+
const cellHeight = Math.round(Number(metadata?.cellHeight))
|
|
538
|
+
return {
|
|
539
|
+
...config,
|
|
540
|
+
cellWidth: cellWidth > 0 ? cellWidth : null,
|
|
541
|
+
cellHeight: cellHeight > 0 ? cellHeight : null,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function applyFrameSheetPreviewFrame(preview, meta, index) {
|
|
546
|
+
const image = preview?.querySelector('img[data-role="frame-sheet-image"]')
|
|
547
|
+
if (!image) return
|
|
548
|
+
const frameIndex = Math.max(0, Math.min(meta.frameCount - 1, index % meta.frameCount))
|
|
549
|
+
const row = Math.floor(frameIndex / meta.columns)
|
|
550
|
+
const column = frameIndex % meta.columns
|
|
551
|
+
image.style.transform = `translate(${-100 * column / meta.columns}%, ${-100 * row / meta.rows}%)`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function normalizeFrameEditorFrame(value, sourceRows, sourceColumns) {
|
|
555
|
+
if (!value || typeof value !== 'object') return null
|
|
556
|
+
if (value.kind === 'source_cell') {
|
|
557
|
+
const index = Math.round(Number(value.index))
|
|
558
|
+
if (!Number.isFinite(index) || index < 0 || index >= sourceRows * sourceColumns) return null
|
|
559
|
+
return { kind: 'source_cell', index }
|
|
560
|
+
}
|
|
561
|
+
if (value.kind === 'asset') {
|
|
562
|
+
const assetId = String(value.assetId || value.asset_id || '').trim()
|
|
563
|
+
return assetId ? { kind: 'asset', assetId } : null
|
|
564
|
+
}
|
|
565
|
+
return null
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function normalizeFrameEditor(item) {
|
|
569
|
+
if (!isFrameEditor(item)) return null
|
|
570
|
+
const sourceId = String(item.sourceId || '').trim()
|
|
571
|
+
const sourceRows = Math.round(Number(item.sourceRows))
|
|
572
|
+
const sourceColumns = Math.round(Number(item.sourceColumns))
|
|
573
|
+
const rows = Math.round(Number(item.rows))
|
|
574
|
+
const columns = Math.round(Number(item.columns))
|
|
575
|
+
const fps = Number(item.fps)
|
|
576
|
+
if (
|
|
577
|
+
!sourceId ||
|
|
578
|
+
!Number.isFinite(sourceRows) ||
|
|
579
|
+
!Number.isFinite(sourceColumns) ||
|
|
580
|
+
!Number.isFinite(rows) ||
|
|
581
|
+
!Number.isFinite(columns) ||
|
|
582
|
+
!Number.isFinite(fps) ||
|
|
583
|
+
sourceRows < 1 ||
|
|
584
|
+
sourceRows > 64 ||
|
|
585
|
+
sourceColumns < 1 ||
|
|
586
|
+
sourceColumns > 64 ||
|
|
587
|
+
rows < 1 ||
|
|
588
|
+
rows > 64 ||
|
|
589
|
+
columns < 1 ||
|
|
590
|
+
columns > 64 ||
|
|
591
|
+
fps <= 0
|
|
592
|
+
) {
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
const frames = (Array.isArray(item.frames) ? item.frames : [])
|
|
596
|
+
.map((frame) => normalizeFrameEditorFrame(frame, sourceRows, sourceColumns))
|
|
597
|
+
.filter(Boolean)
|
|
598
|
+
.slice(0, rows * columns)
|
|
599
|
+
return { sourceId, sourceRows, sourceColumns, rows, columns, fps, frames }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function frameEditorWidth(value) {
|
|
603
|
+
const width = Number(value)
|
|
604
|
+
return Number.isFinite(width) ? Math.max(FRAME_EDITOR_W, width) : FRAME_EDITOR_W
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function frameEditorParentIds(item) {
|
|
608
|
+
const editor = normalizeFrameEditor(item)
|
|
609
|
+
if (!editor) return []
|
|
610
|
+
return [
|
|
611
|
+
editor.sourceId,
|
|
612
|
+
...editor.frames
|
|
613
|
+
.filter((frame) => frame.kind === 'asset')
|
|
614
|
+
.map((frame) => frame.assetId),
|
|
615
|
+
].filter((id, index, ids) => id && ids.indexOf(id) === index)
|
|
616
|
+
}
|
|
617
|
+
|
|
426
618
|
function resolveTosUrl(item) {
|
|
427
619
|
const value =
|
|
428
620
|
item?.provenance?.assetUrl ||
|
|
@@ -553,7 +745,7 @@ function normalizeOrigin(origin) {
|
|
|
553
745
|
const parentId = origin.parentId || origin.sourceId || null
|
|
554
746
|
if (!parentId && kind !== 'ai_generate') return null
|
|
555
747
|
|
|
556
|
-
if (kind === '
|
|
748
|
+
if (kind === 'video_cycle') {
|
|
557
749
|
const start = Number(origin.start)
|
|
558
750
|
const end = Number(origin.end)
|
|
559
751
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
|
@@ -570,14 +762,6 @@ function normalizeOrigin(origin) {
|
|
|
570
762
|
: null,
|
|
571
763
|
}
|
|
572
764
|
}
|
|
573
|
-
if (kind === 'video_sequence') {
|
|
574
|
-
return {
|
|
575
|
-
kind,
|
|
576
|
-
parentId,
|
|
577
|
-
parentName: origin.parentName || null,
|
|
578
|
-
frameCount: Math.max(0, Math.round(Number(origin.frameCount) || 0)),
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
765
|
if (kind === 'smart_split') {
|
|
582
766
|
return {
|
|
583
767
|
kind,
|
|
@@ -590,16 +774,6 @@ function normalizeOrigin(origin) {
|
|
|
590
774
|
),
|
|
591
775
|
}
|
|
592
776
|
}
|
|
593
|
-
if (kind === 'sequence_sheet') {
|
|
594
|
-
return {
|
|
595
|
-
kind,
|
|
596
|
-
parentId,
|
|
597
|
-
parentName: origin.parentName || null,
|
|
598
|
-
rows: Math.max(1, Math.round(Number(origin.rows) || 1)),
|
|
599
|
-
cols: Math.max(1, Math.round(Number(origin.cols) || 1)),
|
|
600
|
-
frameCount: Math.max(0, Math.round(Number(origin.frameCount) || 0)),
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
777
|
return null
|
|
604
778
|
}
|
|
605
779
|
|
|
@@ -607,21 +781,9 @@ function buildOrigin(kind, fields = {}) {
|
|
|
607
781
|
return normalizeOrigin({ kind, ...fields })
|
|
608
782
|
}
|
|
609
783
|
|
|
610
|
-
/** 解析元素产生方式:优先 origin;旧序列仅有 sourceId 时做兼容。 */
|
|
611
784
|
function resolveItemOrigin(item) {
|
|
612
785
|
if (!item) return null
|
|
613
|
-
|
|
614
|
-
if (origin) return origin
|
|
615
|
-
if (isSequence(item) && item.sourceId) {
|
|
616
|
-
const parentId = item.sourceId
|
|
617
|
-
const kind = String(parentId).startsWith('videos/') ? 'video_sequence' : 'smart_split'
|
|
618
|
-
return buildOrigin(kind, {
|
|
619
|
-
parentId,
|
|
620
|
-
parentName: imageName(parentId),
|
|
621
|
-
frameCount: Array.isArray(item.frames) ? item.frames.length : 0,
|
|
622
|
-
})
|
|
623
|
-
}
|
|
624
|
-
return null
|
|
786
|
+
return normalizeOrigin(item.origin)
|
|
625
787
|
}
|
|
626
788
|
|
|
627
789
|
function originParentLabel(origin) {
|
|
@@ -671,15 +833,6 @@ function appendOriginSection(section, item) {
|
|
|
671
833
|
}
|
|
672
834
|
|
|
673
835
|
const origin = resolveItemOrigin(item)
|
|
674
|
-
if (origin?.kind === 'video_trim') {
|
|
675
|
-
appendKv(section, [
|
|
676
|
-
['方式', '视频裁剪'],
|
|
677
|
-
['原素材', originParentLabel(origin)],
|
|
678
|
-
['开始时间', formatOriginTime(origin.start)],
|
|
679
|
-
['结束时间', formatOriginTime(origin.end)],
|
|
680
|
-
])
|
|
681
|
-
return
|
|
682
|
-
}
|
|
683
836
|
if (origin?.kind === 'video_cycle') {
|
|
684
837
|
const rows = [
|
|
685
838
|
['方式', '智能周期提取'],
|
|
@@ -694,14 +847,6 @@ function appendOriginSection(section, item) {
|
|
|
694
847
|
appendKv(section, rows)
|
|
695
848
|
return
|
|
696
849
|
}
|
|
697
|
-
if (origin?.kind === 'video_sequence') {
|
|
698
|
-
appendKv(section, [
|
|
699
|
-
['方式', '视频序列帧'],
|
|
700
|
-
['原视频', originParentLabel(origin)],
|
|
701
|
-
['抽取帧数', String(origin.frameCount || item.frames?.length || 0)],
|
|
702
|
-
])
|
|
703
|
-
return
|
|
704
|
-
}
|
|
705
850
|
if (origin?.kind === 'smart_split') {
|
|
706
851
|
appendKv(section, [
|
|
707
852
|
['方式', '智能切分'],
|
|
@@ -710,15 +855,6 @@ function appendOriginSection(section, item) {
|
|
|
710
855
|
])
|
|
711
856
|
return
|
|
712
857
|
}
|
|
713
|
-
if (origin?.kind === 'sequence_sheet') {
|
|
714
|
-
appendKv(section, [
|
|
715
|
-
['方式', '序列拼成单图'],
|
|
716
|
-
['原序列', originParentLabel(origin)],
|
|
717
|
-
['排布', `${origin.rows} 行 × ${origin.cols} 列`],
|
|
718
|
-
['帧数', String(origin.frameCount || 0)],
|
|
719
|
-
])
|
|
720
|
-
return
|
|
721
|
-
}
|
|
722
858
|
|
|
723
859
|
const gen = normalizeGenMeta(item.gen)
|
|
724
860
|
if (gen && (gen.prompt || gen.model)) {
|
|
@@ -754,6 +890,7 @@ function appendOriginSection(section, item) {
|
|
|
754
890
|
|
|
755
891
|
function attachSerializableMeta(out, item) {
|
|
756
892
|
if (typeof item.name === 'string' && item.name) out.name = item.name
|
|
893
|
+
if (item.manualSize === true) out.manualSize = true
|
|
757
894
|
const gen = normalizeGenMeta(item.gen)
|
|
758
895
|
if (gen) out.gen = gen
|
|
759
896
|
const origin = normalizeOrigin(item.origin)
|
|
@@ -761,30 +898,51 @@ function attachSerializableMeta(out, item) {
|
|
|
761
898
|
if (item.provenance && typeof item.provenance === 'object') {
|
|
762
899
|
out.provenance = item.provenance
|
|
763
900
|
}
|
|
901
|
+
const framePreview = normalizeFramePreviewConfig(item.framePreview)
|
|
902
|
+
if (framePreview) out.framePreview = framePreview
|
|
764
903
|
if (item.layer && typeof item.layer === 'object' && !Array.isArray(item.layer)) {
|
|
765
904
|
out.layer = item.layer
|
|
766
905
|
}
|
|
767
906
|
return out
|
|
768
907
|
}
|
|
769
908
|
|
|
909
|
+
function attachCanvasIdentity(out, item) {
|
|
910
|
+
const assetId = item.assetId || (String(item.id || '').startsWith('assets/') ? item.id : '')
|
|
911
|
+
const operationId = item.operationId || (
|
|
912
|
+
isPending(item) || String(item.id || '').startsWith('job_') || String(item.id || '').startsWith('pending_')
|
|
913
|
+
? item.id
|
|
914
|
+
: ''
|
|
915
|
+
)
|
|
916
|
+
out.nodeId = item.nodeId || (operationId
|
|
917
|
+
? `node:${operationId}`
|
|
918
|
+
: assetId
|
|
919
|
+
? `node:${assetId}`
|
|
920
|
+
: `node:${item.id}`)
|
|
921
|
+
if (assetId) out.assetId = assetId
|
|
922
|
+
if (operationId) out.operationId = operationId
|
|
923
|
+
return out
|
|
924
|
+
}
|
|
925
|
+
|
|
770
926
|
function serializeLayoutItem(item) {
|
|
771
|
-
if (
|
|
772
|
-
const
|
|
927
|
+
if (isFrameEditor(item)) {
|
|
928
|
+
const editor = normalizeFrameEditor(item)
|
|
929
|
+
return {
|
|
773
930
|
id: item.id,
|
|
774
|
-
type: '
|
|
775
|
-
name: item.name || '
|
|
931
|
+
type: 'frame_editor',
|
|
932
|
+
name: item.name || '序列帧编辑',
|
|
776
933
|
x: item.x,
|
|
777
934
|
y: item.y,
|
|
778
|
-
width: item.width
|
|
935
|
+
width: frameEditorWidth(item.width),
|
|
779
936
|
height: item.height ?? null,
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
937
|
+
sourceId: editor?.sourceId || item.sourceId,
|
|
938
|
+
sourceRows: editor?.sourceRows || item.sourceRows,
|
|
939
|
+
sourceColumns: editor?.sourceColumns || item.sourceColumns,
|
|
940
|
+
rows: editor?.rows || item.rows,
|
|
941
|
+
columns: editor?.columns || item.columns,
|
|
942
|
+
fps: editor?.fps || item.fps || 8,
|
|
943
|
+
frames: editor?.frames || [],
|
|
944
|
+
error: item.error || null,
|
|
784
945
|
}
|
|
785
|
-
const origin = normalizeOrigin(item.origin)
|
|
786
|
-
if (origin) out.origin = origin
|
|
787
|
-
return out
|
|
788
946
|
}
|
|
789
947
|
if (isGenDialog(item)) {
|
|
790
948
|
return {
|
|
@@ -995,21 +1153,26 @@ async function api(path, options) {
|
|
|
995
1153
|
const message = Array.isArray(detail)
|
|
996
1154
|
? detail.map((d) => d.msg || JSON.stringify(d)).join('; ')
|
|
997
1155
|
: detail || data.message || res.statusText || '请求失败'
|
|
998
|
-
|
|
1156
|
+
const error = new Error(message)
|
|
1157
|
+
error.status = res.status
|
|
1158
|
+
error.data = data
|
|
1159
|
+
throw error
|
|
999
1160
|
}
|
|
1000
1161
|
return data
|
|
1001
1162
|
}
|
|
1002
1163
|
|
|
1003
1164
|
/** 画布只显示 layout 里的元素;磁盘上的孤儿文件不会自动冒出来 */
|
|
1004
|
-
function mergeLayout(_images, layoutItems) {
|
|
1005
|
-
return (layoutItems || []).
|
|
1006
|
-
|
|
1165
|
+
function mergeLayout(_images, layoutItems) {
|
|
1166
|
+
return (layoutItems || []).filter((item) =>
|
|
1167
|
+
item?.type !== 'sequence' &&
|
|
1168
|
+
!(isPending(item) && state.failedOperationIds.has(item.operationId || item.id))
|
|
1169
|
+
).map((item) => {
|
|
1170
|
+
if (isFrameEditor(item)) {
|
|
1007
1171
|
return {
|
|
1008
1172
|
...item,
|
|
1009
|
-
type: '
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
loop: item.loop !== false,
|
|
1173
|
+
type: 'frame_editor',
|
|
1174
|
+
name: item.name || '序列帧编辑',
|
|
1175
|
+
width: frameEditorWidth(item.width),
|
|
1013
1176
|
}
|
|
1014
1177
|
}
|
|
1015
1178
|
if (isGenDialog(item)) {
|
|
@@ -1069,6 +1232,20 @@ function buildNodes() {
|
|
|
1069
1232
|
const nodes = []
|
|
1070
1233
|
|
|
1071
1234
|
state.layoutItems.forEach((item, index) => {
|
|
1235
|
+
if (isFrameEditor(item)) {
|
|
1236
|
+
const editor = normalizeFrameEditor(item)
|
|
1237
|
+
if (!editor) return
|
|
1238
|
+
nodes.push({
|
|
1239
|
+
...item,
|
|
1240
|
+
...editor,
|
|
1241
|
+
type: 'frame_editor',
|
|
1242
|
+
name: item.name || '序列帧编辑',
|
|
1243
|
+
x: item.x ?? 60 + (index % 5) * 540,
|
|
1244
|
+
y: item.y ?? 60 + Math.floor(index / 5) * 360,
|
|
1245
|
+
width: frameEditorWidth(item.width),
|
|
1246
|
+
})
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
1072
1249
|
if (isGenDialog(item)) {
|
|
1073
1250
|
nodes.push({
|
|
1074
1251
|
...item,
|
|
@@ -1124,25 +1301,6 @@ function buildNodes() {
|
|
|
1124
1301
|
})
|
|
1125
1302
|
return
|
|
1126
1303
|
}
|
|
1127
|
-
if (isSequence(item)) {
|
|
1128
|
-
const frames = item.frames.filter((fid) => imageMap[fid] || fid)
|
|
1129
|
-
if (!frames.length) return
|
|
1130
|
-
const play = state.playback[item.id] || { index: 0 }
|
|
1131
|
-
const frameId = frames[play.index % frames.length]
|
|
1132
|
-
nodes.push({
|
|
1133
|
-
...item,
|
|
1134
|
-
type: 'sequence',
|
|
1135
|
-
frames,
|
|
1136
|
-
url: imageUrl(frameId),
|
|
1137
|
-
name: item.name || `序列 (${frames.length})`,
|
|
1138
|
-
x: item.x ?? 60 + (index % 5) * 240,
|
|
1139
|
-
y: item.y ?? 60 + Math.floor(index / 5) * 260,
|
|
1140
|
-
width: item.width || DEFAULT_W,
|
|
1141
|
-
frameCount: frames.length,
|
|
1142
|
-
currentFrameId: frameId,
|
|
1143
|
-
})
|
|
1144
|
-
return
|
|
1145
|
-
}
|
|
1146
1304
|
const img = imageMap[item.id]
|
|
1147
1305
|
if (!img) return
|
|
1148
1306
|
nodes.push({
|
|
@@ -1158,18 +1316,55 @@ function buildNodes() {
|
|
|
1158
1316
|
return nodes
|
|
1159
1317
|
}
|
|
1160
1318
|
|
|
1161
|
-
|
|
1162
|
-
const next = (items || state.layoutItems).map(
|
|
1319
|
+
function saveLayout(items) {
|
|
1320
|
+
const next = (items || state.layoutItems).map((item) =>
|
|
1321
|
+
attachCanvasIdentity(serializeLayoutItem(item), item),
|
|
1322
|
+
)
|
|
1323
|
+
const sequence = ++layoutSaveSequence
|
|
1324
|
+
const epoch = layoutSaveEpoch
|
|
1325
|
+
const expectedRevision = layoutPendingSaveCount === 0
|
|
1326
|
+
? state.layoutRevision
|
|
1327
|
+
: layoutNextExpectedRevision
|
|
1328
|
+
layoutNextExpectedRevision = expectedRevision + 1
|
|
1329
|
+
layoutPendingSaveCount += 1
|
|
1163
1330
|
state.layoutItems = next
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1331
|
+
const request = layoutSaveQueue.then(async () => {
|
|
1332
|
+
try {
|
|
1333
|
+
if (epoch !== layoutSaveEpoch) {
|
|
1334
|
+
const staleError = new Error('画布已被其他任务更新,请重试刚才的操作。')
|
|
1335
|
+
staleError.status = 409
|
|
1336
|
+
throw staleError
|
|
1337
|
+
}
|
|
1338
|
+
const saved = await api('/api/layout', {
|
|
1339
|
+
method: 'PUT',
|
|
1340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1341
|
+
body: JSON.stringify({ items: next, revision: expectedRevision }),
|
|
1342
|
+
})
|
|
1343
|
+
state.layoutRevision = Number(saved.revision) || state.layoutRevision
|
|
1344
|
+
if (sequence === layoutSaveSequence && Array.isArray(saved.items)) {
|
|
1345
|
+
state.layoutItems = mergeLayout(state.images, saved.items)
|
|
1346
|
+
}
|
|
1347
|
+
return saved
|
|
1348
|
+
} catch (err) {
|
|
1349
|
+
console.warn('save layout failed', err)
|
|
1350
|
+
const firstFailureInEpoch = epoch === layoutSaveEpoch
|
|
1351
|
+
if (firstFailureInEpoch) {
|
|
1352
|
+
layoutSaveEpoch += 1
|
|
1353
|
+
}
|
|
1354
|
+
if (err?.status === 409 && firstFailureInEpoch) {
|
|
1355
|
+
state.error = '画布已被其他任务更新,请重试刚才的操作。'
|
|
1356
|
+
await reload().catch(() => undefined)
|
|
1357
|
+
}
|
|
1358
|
+
throw err
|
|
1359
|
+
} finally {
|
|
1360
|
+
layoutPendingSaveCount = Math.max(0, layoutPendingSaveCount - 1)
|
|
1361
|
+
if (layoutPendingSaveCount === 0) {
|
|
1362
|
+
layoutNextExpectedRevision = state.layoutRevision
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
})
|
|
1366
|
+
layoutSaveQueue = request.catch(() => undefined)
|
|
1367
|
+
return request
|
|
1173
1368
|
}
|
|
1174
1369
|
|
|
1175
1370
|
function applyCamera() {
|
|
@@ -1188,40 +1383,30 @@ function showCtxMenu(clientX, clientY, node) {
|
|
|
1188
1383
|
state.ctxCanvasPoint = null
|
|
1189
1384
|
const selectedIds = isSelected(node.id) ? [...state.selectedIds] : [node.id]
|
|
1190
1385
|
const isMulti = selectedIds.length > 1
|
|
1191
|
-
|
|
1192
|
-
isMulti &&
|
|
1193
|
-
selectedIds.every((id) =>
|
|
1194
|
-
isPlainImageItem(state.layoutItems.find((item) => item.id === id)),
|
|
1195
|
-
)
|
|
1196
|
-
if (isMulti) {
|
|
1386
|
+
if (isMulti || isFrameEditor(node)) {
|
|
1197
1387
|
for (const button of els.ctxMenu.querySelectorAll('button[data-action]')) {
|
|
1198
|
-
button.hidden =
|
|
1199
|
-
button.dataset.action === 'delete' ||
|
|
1200
|
-
(button.dataset.action === 'combine-sequence' && canCombineSequence)
|
|
1201
|
-
)
|
|
1388
|
+
button.hidden = button.dataset.action !== 'delete'
|
|
1202
1389
|
}
|
|
1203
1390
|
} else {
|
|
1204
|
-
const isSeq = isSequence(node)
|
|
1205
1391
|
const isDialog = isGenDialog(node) || isVideoDialog(node)
|
|
1206
1392
|
const pending = isPending(node)
|
|
1207
1393
|
const isVid = isVideoItem(node)
|
|
1208
|
-
const isImage = !
|
|
1394
|
+
const isImage = !isDialog && !pending && !isVid
|
|
1209
1395
|
els.ctxMenu.querySelector('[data-action="copy"]').hidden = pending || isDialog
|
|
1210
1396
|
els.ctxMenu.querySelector('[data-action="paste"]').hidden = true
|
|
1211
1397
|
els.ctxMenu.querySelector('[data-action="cutout"]').hidden = !isImage
|
|
1212
1398
|
els.ctxMenu.querySelector('[data-action="interactive-edit"]').hidden = !isImage
|
|
1213
1399
|
els.ctxMenu.querySelector('[data-action="layer-decomposition"]').hidden = !isImage
|
|
1214
|
-
els.ctxMenu.querySelector('[data-action="video-trim"]').hidden = !isVid
|
|
1215
1400
|
els.ctxMenu.querySelector('[data-action="video-cycle"]').hidden = !isVid
|
|
1216
|
-
els.ctxMenu.querySelector('[data-action="video-to-
|
|
1217
|
-
els.ctxMenu.querySelector('[data-action="smart-split"]').hidden =
|
|
1218
|
-
|
|
1219
|
-
els.ctxMenu.querySelector('[data-action="
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
els.ctxMenu.querySelector('[data-action="
|
|
1401
|
+
els.ctxMenu.querySelector('[data-action="video-to-frame-sheet"]').hidden = !isVid
|
|
1402
|
+
els.ctxMenu.querySelector('[data-action="smart-split"]').hidden = !isImage
|
|
1403
|
+
els.ctxMenu.querySelector('[data-action="manual-split"]').hidden = !isImage
|
|
1404
|
+
const framePreviewButton = els.ctxMenu.querySelector('[data-action="frame-sheet-preview"]')
|
|
1405
|
+
framePreviewButton.hidden = !isImage
|
|
1406
|
+
framePreviewButton.textContent = frameSheetPreviewMeta(node)
|
|
1407
|
+
? '停止序列帧预览'
|
|
1408
|
+
: '序列帧预览'
|
|
1409
|
+
els.ctxMenu.querySelector('[data-action="frame-sheet-edit"]').hidden = !isImage
|
|
1225
1410
|
els.ctxMenu.querySelector('[data-action="delete"]').hidden = false
|
|
1226
1411
|
}
|
|
1227
1412
|
positionCtxMenu(clientX, clientY)
|
|
@@ -1509,7 +1694,7 @@ async function submitGenDialog(dialogId) {
|
|
|
1509
1694
|
: undefined
|
|
1510
1695
|
const references = [...ds.refs]
|
|
1511
1696
|
const display = pendingDisplaySize(size)
|
|
1512
|
-
const pendingId =
|
|
1697
|
+
const pendingId = createCanvasClientId('pending')
|
|
1513
1698
|
const pendingItem = {
|
|
1514
1699
|
id: pendingId,
|
|
1515
1700
|
type: 'pending',
|
|
@@ -1529,14 +1714,14 @@ async function submitGenDialog(dialogId) {
|
|
|
1529
1714
|
ds.busy = true
|
|
1530
1715
|
state.error = ''
|
|
1531
1716
|
// 对话框立刻变成生成中图片节点
|
|
1532
|
-
state.layoutItems = state.layoutItems.map((it) => (it.id === dialogId ? pendingItem : it))
|
|
1533
|
-
setSelection([pendingId])
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
const result = await api('/api/generate', {
|
|
1717
|
+
state.layoutItems = state.layoutItems.map((it) => (it.id === dialogId ? pendingItem : it))
|
|
1718
|
+
setSelection([pendingId])
|
|
1719
|
+
const activeOperation = beginActiveOperation('image-generation')
|
|
1720
|
+
try {
|
|
1721
|
+
await saveLayout(state.layoutItems)
|
|
1722
|
+
renderCanvas()
|
|
1723
|
+
renderChrome()
|
|
1724
|
+
const result = await api('/api/generate', {
|
|
1540
1725
|
method: 'POST',
|
|
1541
1726
|
headers: { 'Content-Type': 'application/json' },
|
|
1542
1727
|
body: JSON.stringify({
|
|
@@ -1550,41 +1735,44 @@ async function submitGenDialog(dialogId) {
|
|
|
1550
1735
|
}),
|
|
1551
1736
|
})
|
|
1552
1737
|
// 占位节点替换为真实图片
|
|
1553
|
-
state.layoutItems = state.layoutItems.map((it) =>
|
|
1554
|
-
it.id === pendingId
|
|
1555
|
-
? {
|
|
1556
|
-
id: result.id,
|
|
1557
|
-
x: it.x,
|
|
1558
|
-
y: it.y,
|
|
1559
|
-
width: it.width,
|
|
1560
|
-
height: it.height,
|
|
1561
|
-
gen: buildImageGenMeta({
|
|
1562
|
-
prompt: text,
|
|
1563
|
-
model: result.model || model,
|
|
1564
|
-
size: result.size || size,
|
|
1565
|
-
references,
|
|
1566
|
-
}),
|
|
1567
|
-
}
|
|
1568
|
-
: it,
|
|
1569
|
-
)
|
|
1570
1738
|
delete state.genDialogs[dialogId]
|
|
1571
|
-
await saveLayout(state.layoutItems)
|
|
1572
1739
|
await reload()
|
|
1573
1740
|
setSelection([result.id])
|
|
1574
1741
|
renderCanvas()
|
|
1575
1742
|
renderChrome()
|
|
1576
|
-
} catch (err) {
|
|
1577
|
-
const detail = String(err.message || err)
|
|
1578
|
-
state.error = detail
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
const detail = String(err.message || err)
|
|
1745
|
+
state.error = detail
|
|
1746
|
+
ds.busy = false
|
|
1747
|
+
ds.prompt = text
|
|
1748
|
+
ds.messages.push({
|
|
1749
|
+
id: `e-${Date.now()}`,
|
|
1750
|
+
role: 'assistant',
|
|
1751
|
+
error: detail,
|
|
1752
|
+
})
|
|
1753
|
+
await discardFailedPendingOperation(pendingId)
|
|
1754
|
+
// Validation can fail before the server creates a job. In that case the
|
|
1755
|
+
// persisted pending node cannot be reconciled server-side, so replace it
|
|
1756
|
+
// explicitly with the original generator dialog.
|
|
1757
|
+
state.layoutItems = state.layoutItems.filter(
|
|
1758
|
+
(it) => it.id !== pendingId && it.id !== dialogId,
|
|
1759
|
+
)
|
|
1760
|
+
state.layoutItems.push({
|
|
1761
|
+
...dialog,
|
|
1762
|
+
id: dialogId,
|
|
1763
|
+
type: 'gen_dialog',
|
|
1764
|
+
name: dialog.name || '图片生成',
|
|
1765
|
+
width: dialog.width || GEN_DIALOG_W,
|
|
1766
|
+
height: null,
|
|
1767
|
+
})
|
|
1768
|
+
await saveLayout(state.layoutItems).catch((saveError) => {
|
|
1769
|
+
console.warn('保存图片生成失败回滚布局失败', saveError)
|
|
1770
|
+
})
|
|
1771
|
+
setSelection([dialogId])
|
|
1772
|
+
renderCanvas()
|
|
1773
|
+
renderChrome()
|
|
1774
|
+
} finally {
|
|
1775
|
+
endActiveOperation(activeOperation)
|
|
1588
1776
|
}
|
|
1589
1777
|
}
|
|
1590
1778
|
|
|
@@ -1811,7 +1999,7 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1811
1999
|
const text = String(ds.prompt || '').trim()
|
|
1812
2000
|
if (!text || ds.busy || !dialog) return
|
|
1813
2001
|
const display = videoDisplaySize(ds.ratio || '16:9')
|
|
1814
|
-
const pendingId =
|
|
2002
|
+
const pendingId = createCanvasClientId('pending')
|
|
1815
2003
|
const references = ds.refs.map((r) => ({
|
|
1816
2004
|
id: r.id,
|
|
1817
2005
|
role: r.role,
|
|
@@ -1839,14 +2027,14 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1839
2027
|
references,
|
|
1840
2028
|
}
|
|
1841
2029
|
: it,
|
|
1842
|
-
)
|
|
1843
|
-
setSelection([pendingId])
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
const seedRaw = String(ds.seed ?? '').trim()
|
|
2030
|
+
)
|
|
2031
|
+
setSelection([pendingId])
|
|
2032
|
+
const activeOperation = beginActiveOperation('video-generation')
|
|
2033
|
+
try {
|
|
2034
|
+
await saveLayout(state.layoutItems)
|
|
2035
|
+
renderCanvas()
|
|
2036
|
+
renderChrome()
|
|
2037
|
+
const seedRaw = String(ds.seed ?? '').trim()
|
|
1850
2038
|
const result = await api('/api/generate-video', {
|
|
1851
2039
|
method: 'POST',
|
|
1852
2040
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1862,30 +2050,7 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1862
2050
|
place: false,
|
|
1863
2051
|
}),
|
|
1864
2052
|
})
|
|
1865
|
-
state.layoutItems = state.layoutItems.map((it) =>
|
|
1866
|
-
it.id === pendingId
|
|
1867
|
-
? {
|
|
1868
|
-
id: result.id,
|
|
1869
|
-
type: 'video',
|
|
1870
|
-
x: it.x,
|
|
1871
|
-
y: it.y,
|
|
1872
|
-
width: it.width,
|
|
1873
|
-
height: null,
|
|
1874
|
-
gen: buildVideoGenMeta({
|
|
1875
|
-
prompt: text,
|
|
1876
|
-
model: result.model || 'seedance',
|
|
1877
|
-
ratio: result.ratio || ds.ratio,
|
|
1878
|
-
duration: result.duration ?? ds.duration,
|
|
1879
|
-
resolution: result.resolution || ds.resolution,
|
|
1880
|
-
generateAudio: ds.generateAudio === true,
|
|
1881
|
-
seed: seedRaw === '' ? null : Number(seedRaw),
|
|
1882
|
-
references: ds.refs,
|
|
1883
|
-
}),
|
|
1884
|
-
}
|
|
1885
|
-
: it,
|
|
1886
|
-
)
|
|
1887
2053
|
delete state.videoDialogs[dialogId]
|
|
1888
|
-
await saveLayout(state.layoutItems)
|
|
1889
2054
|
await reload()
|
|
1890
2055
|
setSelection([result.id])
|
|
1891
2056
|
renderCanvas()
|
|
@@ -1895,28 +2060,29 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1895
2060
|
state.error = detail
|
|
1896
2061
|
ds.busy = false
|
|
1897
2062
|
ds.prompt = text
|
|
1898
|
-
ds.messages.push({
|
|
2063
|
+
ds.messages.push({
|
|
1899
2064
|
id: `e-${Date.now()}`,
|
|
1900
2065
|
role: 'assistant',
|
|
1901
2066
|
error: detail,
|
|
1902
|
-
})
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
)
|
|
2067
|
+
})
|
|
2068
|
+
await discardFailedPendingOperation(pendingId)
|
|
2069
|
+
state.layoutItems = state.layoutItems.filter(
|
|
2070
|
+
(it) => it.id !== pendingId && it.id !== dialogId,
|
|
2071
|
+
)
|
|
2072
|
+
state.layoutItems.push({
|
|
2073
|
+
...dialog,
|
|
2074
|
+
id: dialogId,
|
|
2075
|
+
type: 'video_dialog',
|
|
2076
|
+
name: dialog.name || '视频生成',
|
|
2077
|
+
width: VIDEO_DIALOG_W,
|
|
2078
|
+
height: null,
|
|
2079
|
+
})
|
|
1916
2080
|
await saveLayout(state.layoutItems)
|
|
1917
2081
|
setSelection([dialogId])
|
|
1918
2082
|
renderCanvas()
|
|
1919
2083
|
renderChrome()
|
|
2084
|
+
} finally {
|
|
2085
|
+
endActiveOperation(activeOperation)
|
|
1920
2086
|
}
|
|
1921
2087
|
}
|
|
1922
2088
|
|
|
@@ -1985,6 +2151,9 @@ function dependencyParentIds(item) {
|
|
|
1985
2151
|
if (isVideoDialog(item)) {
|
|
1986
2152
|
for (const reference of ensureVideoDialogState(item.id).refs || []) add(reference.id)
|
|
1987
2153
|
}
|
|
2154
|
+
if (isFrameEditor(item)) {
|
|
2155
|
+
for (const parentId of frameEditorParentIds(item)) add(parentId)
|
|
2156
|
+
}
|
|
1988
2157
|
if (isPending(item)) {
|
|
1989
2158
|
for (const reference of item.references || item._refs || []) add(reference.id)
|
|
1990
2159
|
}
|
|
@@ -1997,11 +2166,7 @@ function dependencyParentIds(item) {
|
|
|
1997
2166
|
}
|
|
1998
2167
|
|
|
1999
2168
|
function resolveVisibleDependencyId(parentId, visibleIds) {
|
|
2000
|
-
|
|
2001
|
-
const sequence = state.layoutItems.find(
|
|
2002
|
-
(item) => isSequence(item) && item.frames.includes(parentId) && visibleIds.has(item.id),
|
|
2003
|
-
)
|
|
2004
|
-
return sequence?.id || null
|
|
2169
|
+
return visibleIds.has(parentId) ? parentId : null
|
|
2005
2170
|
}
|
|
2006
2171
|
|
|
2007
2172
|
function dependencyNodeBox(id) {
|
|
@@ -2113,73 +2278,614 @@ function requestDependencyEdgeGeometryUpdate() {
|
|
|
2113
2278
|
})
|
|
2114
2279
|
}
|
|
2115
2280
|
|
|
2116
|
-
function
|
|
2117
|
-
const
|
|
2118
|
-
|
|
2119
|
-
|
|
2281
|
+
function frameEditorLayoutItem(editorId) {
|
|
2282
|
+
const item = state.layoutItems.find((candidate) => candidate.id === editorId)
|
|
2283
|
+
return isFrameEditor(item) ? item : null
|
|
2284
|
+
}
|
|
2120
2285
|
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
isSequence(node) ? ' is-sequence' : ''
|
|
2129
|
-
}${isGenDialog(node) || isVideoDialog(node) ? ' is-gen-dialog' : ''}${
|
|
2130
|
-
isVideoDialog(node) ? ' is-video-dialog' : ''
|
|
2131
|
-
}${isVideoItem(node) ? ' is-video' : ''}`
|
|
2132
|
-
el.style.left = `${node.x}px`
|
|
2133
|
-
el.style.top = `${node.y}px`
|
|
2134
|
-
el.style.width = `${node.width}px`
|
|
2135
|
-
el.style.zIndex = isSelected(node.id) ? String(1000 + index) : String(1 + index)
|
|
2136
|
-
el.dataset.id = node.id
|
|
2286
|
+
async function commitFrameEditorChange(editorId) {
|
|
2287
|
+
await saveLayout()
|
|
2288
|
+
renderCanvas()
|
|
2289
|
+
renderChrome()
|
|
2290
|
+
setSelection([editorId])
|
|
2291
|
+
syncSelectionClass()
|
|
2292
|
+
}
|
|
2137
2293
|
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2294
|
+
function frameEditorThumbnail(editor, frame) {
|
|
2295
|
+
const preview = document.createElement('div')
|
|
2296
|
+
preview.className = 'frame-editor-thumb'
|
|
2297
|
+
const image = document.createElement('img')
|
|
2298
|
+
image.alt = '序列帧'
|
|
2299
|
+
image.draggable = false
|
|
2300
|
+
if (frame.kind === 'source_cell') {
|
|
2301
|
+
preview.classList.add('is-source-cell')
|
|
2302
|
+
image.src = imageUrl(editor.sourceId)
|
|
2303
|
+
image.style.width = `${editor.sourceColumns * 100}%`
|
|
2304
|
+
image.style.height = `${editor.sourceRows * 100}%`
|
|
2305
|
+
const row = Math.floor(frame.index / editor.sourceColumns)
|
|
2306
|
+
const column = frame.index % editor.sourceColumns
|
|
2307
|
+
image.style.transform = `translate(${-100 * column / editor.sourceColumns}%, ${-100 * row / editor.sourceRows}%)`
|
|
2308
|
+
} else {
|
|
2309
|
+
image.src = imageUrl(frame.assetId)
|
|
2310
|
+
}
|
|
2311
|
+
preview.appendChild(image)
|
|
2312
|
+
return preview
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function applyFrameEditorPreviewFrame(preview, editor, index) {
|
|
2316
|
+
if (!preview) return
|
|
2317
|
+
const frameCount = editor.frames.length
|
|
2318
|
+
const counter = preview
|
|
2319
|
+
.closest('.frame-editor-preview')
|
|
2320
|
+
?.querySelector('[data-role="frame-editor-preview-counter"]')
|
|
2321
|
+
if (!frameCount) {
|
|
2322
|
+
const empty = document.createElement('span')
|
|
2323
|
+
empty.className = 'frame-editor-preview-empty'
|
|
2324
|
+
empty.textContent = '暂无帧'
|
|
2325
|
+
preview.replaceChildren(empty)
|
|
2326
|
+
if (counter) counter.textContent = '0 / 0'
|
|
2327
|
+
return
|
|
2328
|
+
}
|
|
2153
2329
|
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
if (!isSelected(node.id)) setSelection([node.id])
|
|
2161
|
-
state.ctxTargetId = node.id
|
|
2162
|
-
syncSelectionClass()
|
|
2163
|
-
renderChrome()
|
|
2164
|
-
showCtxMenu(e.clientX, e.clientY, node)
|
|
2165
|
-
})
|
|
2166
|
-
els.world.appendChild(el)
|
|
2167
|
-
continue
|
|
2168
|
-
}
|
|
2330
|
+
const frameIndex = Math.max(0, index % frameCount)
|
|
2331
|
+
const frame = frameEditorThumbnail(editor, editor.frames[frameIndex])
|
|
2332
|
+
frame.classList.add('frame-editor-preview-frame')
|
|
2333
|
+
preview.replaceChildren(frame)
|
|
2334
|
+
if (counter) counter.textContent = `${frameIndex + 1} / ${frameCount}`
|
|
2335
|
+
}
|
|
2169
2336
|
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2337
|
+
function fitFrameEditorPreviewStage(preview, editor) {
|
|
2338
|
+
const source = new Image()
|
|
2339
|
+
source.onload = () => {
|
|
2340
|
+
if (!source.naturalWidth || !source.naturalHeight) return
|
|
2341
|
+
const ratio =
|
|
2342
|
+
(source.naturalWidth * editor.sourceRows) /
|
|
2343
|
+
(source.naturalHeight * editor.sourceColumns)
|
|
2344
|
+
if (!Number.isFinite(ratio) || ratio <= 0) return
|
|
2345
|
+
preview.style.aspectRatio = String(ratio)
|
|
2346
|
+
preview.style.width = `${Math.min(1, ratio) * 100}%`
|
|
2347
|
+
}
|
|
2348
|
+
source.src = imageUrl(editor.sourceId)
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
async function deleteFrameEditorFrame(editorId, frameIndex) {
|
|
2352
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2353
|
+
const editor = normalizeFrameEditor(item)
|
|
2354
|
+
if (!item || !editor || frameIndex < 0 || frameIndex >= editor.frames.length) return
|
|
2355
|
+
item.frames = editor.frames.filter((_, index) => index !== frameIndex)
|
|
2356
|
+
item.error = null
|
|
2357
|
+
await commitFrameEditorChange(editorId)
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
async function updateFrameEditorDimension(editorId, dimension, rawValue) {
|
|
2361
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2362
|
+
const editor = normalizeFrameEditor(item)
|
|
2363
|
+
if (!item || !editor) return
|
|
2364
|
+
const value = Number(rawValue)
|
|
2365
|
+
if (!Number.isInteger(value) || value < 1 || value > 64) {
|
|
2366
|
+
item.error = `${dimension === 'row' ? '行数' : '列数'}必须是 1–64 的整数`
|
|
2367
|
+
renderCanvas()
|
|
2368
|
+
return
|
|
2369
|
+
}
|
|
2370
|
+
if (dimension === 'row') {
|
|
2371
|
+
item.rows = value
|
|
2372
|
+
} else {
|
|
2373
|
+
item.columns = value
|
|
2374
|
+
}
|
|
2375
|
+
const capacity = Number(item.rows) * Number(item.columns)
|
|
2376
|
+
if (editor.frames.length > capacity) item.frames = editor.frames.slice(0, capacity)
|
|
2377
|
+
item.error = null
|
|
2378
|
+
await commitFrameEditorChange(editorId)
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
async function updateFrameEditorFps(editorId, rawValue) {
|
|
2382
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2383
|
+
const fps = Number(rawValue)
|
|
2384
|
+
if (!item || !Number.isFinite(fps) || fps <= 0) {
|
|
2385
|
+
if (item) {
|
|
2386
|
+
item.error = 'FPS 必须大于 0'
|
|
2387
|
+
renderCanvas()
|
|
2388
|
+
}
|
|
2389
|
+
return
|
|
2390
|
+
}
|
|
2391
|
+
item.fps = fps
|
|
2392
|
+
item.error = null
|
|
2393
|
+
await saveLayout()
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
async function dropOnFrameEditor(editorId, targetIndex, dataTransfer) {
|
|
2397
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2398
|
+
const editor = normalizeFrameEditor(item)
|
|
2399
|
+
if (!item || !editor) return
|
|
2400
|
+
|
|
2401
|
+
const internalRaw = dataTransfer.getData('application/x-frame-editor-frame')
|
|
2402
|
+
if (internalRaw) {
|
|
2403
|
+
try {
|
|
2404
|
+
const internal = JSON.parse(internalRaw)
|
|
2405
|
+
const fromIndex = Number(internal.index)
|
|
2406
|
+
if (
|
|
2407
|
+
internal.editorId !== editorId ||
|
|
2408
|
+
!Number.isInteger(fromIndex) ||
|
|
2409
|
+
fromIndex < 0 ||
|
|
2410
|
+
fromIndex >= editor.frames.length
|
|
2411
|
+
) return
|
|
2412
|
+
const frames = [...editor.frames]
|
|
2413
|
+
if (targetIndex >= frames.length) {
|
|
2414
|
+
const [moved] = frames.splice(fromIndex, 1)
|
|
2415
|
+
frames.push(moved)
|
|
2416
|
+
} else if (targetIndex !== fromIndex) {
|
|
2417
|
+
const targetFrame = frames[targetIndex]
|
|
2418
|
+
frames[targetIndex] = frames[fromIndex]
|
|
2419
|
+
frames[fromIndex] = targetFrame
|
|
2420
|
+
}
|
|
2421
|
+
item.frames = frames
|
|
2422
|
+
item.error = null
|
|
2423
|
+
await commitFrameEditorChange(editorId)
|
|
2424
|
+
} catch {
|
|
2425
|
+
/* ignore malformed internal drag data */
|
|
2426
|
+
}
|
|
2427
|
+
return
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
let raw = dataTransfer.getData('application/x-canvas-image')
|
|
2431
|
+
if (!raw) {
|
|
2432
|
+
const id = dataTransfer.getData('text/plain')
|
|
2433
|
+
if (id && id.includes('/')) raw = JSON.stringify({ id })
|
|
2434
|
+
}
|
|
2435
|
+
if (!raw) return
|
|
2436
|
+
try {
|
|
2437
|
+
const reference = JSON.parse(raw)
|
|
2438
|
+
const assetId = String(reference.id || '').trim()
|
|
2439
|
+
const image = state.images.find((candidate) => candidate.id === assetId)
|
|
2440
|
+
if (!image || image.kind !== 'image') {
|
|
2441
|
+
item.error = '只能加入图片素材'
|
|
2442
|
+
renderCanvas()
|
|
2443
|
+
return
|
|
2444
|
+
}
|
|
2445
|
+
if (editor.frames.length >= editor.rows * editor.columns) {
|
|
2446
|
+
if (editor.rows < 64) item.rows = editor.rows + 1
|
|
2447
|
+
else if (editor.columns < 64) item.columns = editor.columns + 1
|
|
2448
|
+
else {
|
|
2449
|
+
item.error = '网格已满,无法继续添加图片'
|
|
2450
|
+
renderCanvas()
|
|
2451
|
+
return
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
const frames = [...editor.frames]
|
|
2455
|
+
frames.splice(Math.min(targetIndex, frames.length), 0, { kind: 'asset', assetId })
|
|
2456
|
+
item.frames = frames
|
|
2457
|
+
item.error = null
|
|
2458
|
+
await commitFrameEditorChange(editorId)
|
|
2459
|
+
} catch {
|
|
2460
|
+
item.error = '无法读取拖入的图片'
|
|
2461
|
+
renderCanvas()
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
async function saveFrameEditorAsSheet(editorId) {
|
|
2466
|
+
if (hasActiveOperations()) return
|
|
2467
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2468
|
+
const editor = normalizeFrameEditor(item)
|
|
2469
|
+
if (!item || !editor) return
|
|
2470
|
+
if (editor.frames.length < 2 || editor.rows * editor.columns < 2) {
|
|
2471
|
+
item.error = '至少需要两帧和两个格子才能保存序列帧'
|
|
2472
|
+
renderCanvas()
|
|
2473
|
+
return
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
const activeOperation = beginActiveOperation('frame-sheet-save')
|
|
2477
|
+
state.error = ''
|
|
2478
|
+
item.error = null
|
|
2479
|
+
const editorSnapshot = serializeLayoutItem(item)
|
|
2480
|
+
const operationId = createCanvasClientId('pending_frame_sheet')
|
|
2481
|
+
const parentIds = frameEditorParentIds(item)
|
|
2482
|
+
try {
|
|
2483
|
+
state.layoutItems = state.layoutItems.map((candidate) =>
|
|
2484
|
+
candidate.id === editorId
|
|
2485
|
+
? {
|
|
2486
|
+
id: operationId,
|
|
2487
|
+
type: 'pending',
|
|
2488
|
+
name: '序列帧保存中',
|
|
2489
|
+
x: item.x,
|
|
2490
|
+
y: item.y,
|
|
2491
|
+
width: frameEditorWidth(item.width),
|
|
2492
|
+
height: item.height ?? null,
|
|
2493
|
+
media: 'image',
|
|
2494
|
+
references: parentIds.map((id) => ({ id, role: 'source' })),
|
|
2495
|
+
}
|
|
2496
|
+
: candidate,
|
|
2497
|
+
)
|
|
2498
|
+
setSelection([operationId])
|
|
2499
|
+
await saveLayout()
|
|
2500
|
+
renderCanvas()
|
|
2501
|
+
renderChrome()
|
|
2502
|
+
const result = await api('/api/frame-sheet/edit', {
|
|
2503
|
+
method: 'POST',
|
|
2504
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2505
|
+
body: JSON.stringify({
|
|
2506
|
+
source_id: editor.sourceId,
|
|
2507
|
+
source_rows: editor.sourceRows,
|
|
2508
|
+
source_columns: editor.sourceColumns,
|
|
2509
|
+
rows: editor.rows,
|
|
2510
|
+
columns: editor.columns,
|
|
2511
|
+
fps: editor.fps,
|
|
2512
|
+
frames: editor.frames.map((frame) =>
|
|
2513
|
+
frame.kind === 'source_cell'
|
|
2514
|
+
? { kind: 'source_cell', index: frame.index }
|
|
2515
|
+
: { kind: 'asset', asset_id: frame.assetId },
|
|
2516
|
+
),
|
|
2517
|
+
client_operation_id: operationId,
|
|
2518
|
+
}),
|
|
2519
|
+
})
|
|
2520
|
+
await reload()
|
|
2521
|
+
if (result.id) setSelection([result.id])
|
|
2522
|
+
renderCanvas()
|
|
2523
|
+
renderChrome()
|
|
2524
|
+
} catch (err) {
|
|
2525
|
+
state.error = String(err.message || err)
|
|
2526
|
+
await discardFailedPendingOperation(operationId)
|
|
2527
|
+
state.layoutItems = [
|
|
2528
|
+
...state.layoutItems.filter(
|
|
2529
|
+
(candidate) => candidate.id !== operationId && candidate.id !== editorId,
|
|
2530
|
+
),
|
|
2531
|
+
editorSnapshot,
|
|
2532
|
+
]
|
|
2533
|
+
await saveLayout()
|
|
2534
|
+
setSelection([editorId])
|
|
2535
|
+
renderCanvas()
|
|
2536
|
+
renderChrome()
|
|
2537
|
+
} finally {
|
|
2538
|
+
endActiveOperation(activeOperation)
|
|
2539
|
+
renderChrome()
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function mountFrameEditorNode(container, node) {
|
|
2544
|
+
const editor = normalizeFrameEditor(node)
|
|
2545
|
+
if (!editor) return
|
|
2546
|
+
container.classList.add('is-frame-editor')
|
|
2547
|
+
|
|
2548
|
+
const head = document.createElement('div')
|
|
2549
|
+
head.className = 'frame-editor-head'
|
|
2550
|
+
const title = document.createElement('strong')
|
|
2551
|
+
title.textContent = node.name || '序列帧编辑'
|
|
2552
|
+
const summary = document.createElement('span')
|
|
2553
|
+
summary.textContent = `${editor.frames.length} 帧 · ${editor.rows}×${editor.columns}`
|
|
2554
|
+
head.append(title, summary)
|
|
2555
|
+
|
|
2556
|
+
const toolbar = document.createElement('div')
|
|
2557
|
+
toolbar.className = 'frame-editor-toolbar frame-editor-interactive'
|
|
2558
|
+
const rowLabel = document.createElement('label')
|
|
2559
|
+
rowLabel.className = 'frame-editor-dimension-field'
|
|
2560
|
+
rowLabel.textContent = '行数'
|
|
2561
|
+
const rowInput = document.createElement('input')
|
|
2562
|
+
rowInput.type = 'number'
|
|
2563
|
+
rowInput.min = '1'
|
|
2564
|
+
rowInput.max = '64'
|
|
2565
|
+
rowInput.step = '1'
|
|
2566
|
+
rowInput.value = String(editor.rows)
|
|
2567
|
+
rowInput.disabled = hasActiveOperations()
|
|
2568
|
+
rowInput.dataset.role = 'frame-editor-rows'
|
|
2569
|
+
rowInput.addEventListener('change', () =>
|
|
2570
|
+
updateFrameEditorDimension(node.id, 'row', rowInput.value),
|
|
2571
|
+
)
|
|
2572
|
+
rowLabel.appendChild(rowInput)
|
|
2573
|
+
const columnLabel = document.createElement('label')
|
|
2574
|
+
columnLabel.className = 'frame-editor-dimension-field'
|
|
2575
|
+
columnLabel.textContent = '列数'
|
|
2576
|
+
const columnInput = document.createElement('input')
|
|
2577
|
+
columnInput.type = 'number'
|
|
2578
|
+
columnInput.min = '1'
|
|
2579
|
+
columnInput.max = '64'
|
|
2580
|
+
columnInput.step = '1'
|
|
2581
|
+
columnInput.value = String(editor.columns)
|
|
2582
|
+
columnInput.disabled = hasActiveOperations()
|
|
2583
|
+
columnInput.dataset.role = 'frame-editor-columns'
|
|
2584
|
+
columnInput.addEventListener('change', () =>
|
|
2585
|
+
updateFrameEditorDimension(node.id, 'column', columnInput.value),
|
|
2586
|
+
)
|
|
2587
|
+
columnLabel.appendChild(columnInput)
|
|
2588
|
+
const fpsLabel = document.createElement('label')
|
|
2589
|
+
fpsLabel.className = 'frame-editor-fps-field'
|
|
2590
|
+
fpsLabel.textContent = 'FPS'
|
|
2591
|
+
const fpsInput = document.createElement('input')
|
|
2592
|
+
fpsInput.type = 'number'
|
|
2593
|
+
fpsInput.min = '0.000001'
|
|
2594
|
+
fpsInput.step = 'any'
|
|
2595
|
+
fpsInput.value = String(editor.fps)
|
|
2596
|
+
fpsInput.disabled = hasActiveOperations()
|
|
2597
|
+
fpsInput.addEventListener('change', () => updateFrameEditorFps(node.id, fpsInput.value))
|
|
2598
|
+
fpsLabel.appendChild(fpsInput)
|
|
2599
|
+
toolbar.append(rowLabel, columnLabel, fpsLabel)
|
|
2600
|
+
|
|
2601
|
+
const scroll = document.createElement('div')
|
|
2602
|
+
scroll.className = 'frame-editor-grid-scroll frame-editor-interactive'
|
|
2603
|
+
const grid = document.createElement('div')
|
|
2604
|
+
grid.className = 'frame-editor-grid'
|
|
2605
|
+
grid.dataset.accept = 'image'
|
|
2606
|
+
grid.style.gridTemplateColumns = `repeat(${editor.columns}, 72px)`
|
|
2607
|
+
const capacity = editor.rows * editor.columns
|
|
2608
|
+
for (let index = 0; index < capacity; index += 1) {
|
|
2609
|
+
const cell = document.createElement('div')
|
|
2610
|
+
const frame = editor.frames[index]
|
|
2611
|
+
cell.className = `frame-editor-cell${frame ? ' has-frame' : ' is-empty'}`
|
|
2612
|
+
cell.dataset.index = String(index)
|
|
2613
|
+
cell.addEventListener('dragover', (event) => {
|
|
2614
|
+
event.preventDefault()
|
|
2615
|
+
event.stopPropagation()
|
|
2616
|
+
event.dataTransfer.dropEffect = Array.from(event.dataTransfer.types).includes('application/x-frame-editor-frame')
|
|
2617
|
+
? 'move'
|
|
2618
|
+
: 'copy'
|
|
2619
|
+
cell.classList.add('is-drop')
|
|
2620
|
+
})
|
|
2621
|
+
cell.addEventListener('dragleave', () => cell.classList.remove('is-drop'))
|
|
2622
|
+
cell.addEventListener('drop', async (event) => {
|
|
2623
|
+
event.preventDefault()
|
|
2624
|
+
event.stopPropagation()
|
|
2625
|
+
interaction.htmlDrag = false
|
|
2626
|
+
clearRefDragHighlight()
|
|
2627
|
+
await dropOnFrameEditor(node.id, index, event.dataTransfer)
|
|
2628
|
+
})
|
|
2629
|
+
if (frame) {
|
|
2630
|
+
cell.draggable = !hasActiveOperations()
|
|
2631
|
+
cell.appendChild(frameEditorThumbnail(editor, frame))
|
|
2632
|
+
const order = document.createElement('span')
|
|
2633
|
+
order.className = 'frame-editor-order'
|
|
2634
|
+
order.textContent = String(index + 1)
|
|
2635
|
+
const remove = document.createElement('button')
|
|
2636
|
+
remove.type = 'button'
|
|
2637
|
+
remove.className = 'frame-editor-remove'
|
|
2638
|
+
remove.textContent = '×'
|
|
2639
|
+
remove.title = '删除这一帧'
|
|
2640
|
+
remove.disabled = hasActiveOperations()
|
|
2641
|
+
remove.addEventListener('click', (event) => {
|
|
2642
|
+
event.stopPropagation()
|
|
2643
|
+
deleteFrameEditorFrame(node.id, index)
|
|
2644
|
+
})
|
|
2645
|
+
cell.append(order, remove)
|
|
2646
|
+
cell.addEventListener('dragstart', (event) => {
|
|
2647
|
+
event.stopPropagation()
|
|
2648
|
+
event.dataTransfer.setData(
|
|
2649
|
+
'application/x-frame-editor-frame',
|
|
2650
|
+
JSON.stringify({ editorId: node.id, index }),
|
|
2651
|
+
)
|
|
2652
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
2653
|
+
interaction.htmlDrag = true
|
|
2654
|
+
cell.classList.add('is-dragging')
|
|
2655
|
+
})
|
|
2656
|
+
cell.addEventListener('dragend', () => {
|
|
2657
|
+
interaction.htmlDrag = false
|
|
2658
|
+
cell.classList.remove('is-dragging')
|
|
2659
|
+
})
|
|
2660
|
+
} else {
|
|
2661
|
+
const empty = document.createElement('span')
|
|
2662
|
+
empty.className = 'frame-editor-empty-label'
|
|
2663
|
+
empty.textContent = '拖入'
|
|
2664
|
+
cell.appendChild(empty)
|
|
2665
|
+
}
|
|
2666
|
+
grid.appendChild(cell)
|
|
2667
|
+
}
|
|
2668
|
+
scroll.appendChild(grid)
|
|
2669
|
+
|
|
2670
|
+
const workspace = document.createElement('div')
|
|
2671
|
+
workspace.className = 'frame-editor-workspace frame-editor-interactive'
|
|
2672
|
+
const previewPanel = document.createElement('aside')
|
|
2673
|
+
previewPanel.className = 'frame-editor-preview'
|
|
2674
|
+
const previewHead = document.createElement('div')
|
|
2675
|
+
previewHead.className = 'frame-editor-preview-head'
|
|
2676
|
+
const previewTitle = document.createElement('strong')
|
|
2677
|
+
previewTitle.textContent = '实时预览'
|
|
2678
|
+
const previewCounter = document.createElement('span')
|
|
2679
|
+
previewCounter.dataset.role = 'frame-editor-preview-counter'
|
|
2680
|
+
previewHead.append(previewTitle, previewCounter)
|
|
2681
|
+
const previewViewport = document.createElement('div')
|
|
2682
|
+
previewViewport.className = 'frame-editor-preview-viewport'
|
|
2683
|
+
const previewStage = document.createElement('div')
|
|
2684
|
+
previewStage.className = 'frame-editor-preview-stage'
|
|
2685
|
+
previewStage.dataset.editorId = node.id
|
|
2686
|
+
previewViewport.appendChild(previewStage)
|
|
2687
|
+
const previewHint = document.createElement('span')
|
|
2688
|
+
previewHint.className = 'frame-editor-preview-hint'
|
|
2689
|
+
previewHint.textContent = '按照当前顺序循环播放'
|
|
2690
|
+
previewPanel.append(previewHead, previewViewport, previewHint)
|
|
2691
|
+
workspace.append(scroll, previewPanel)
|
|
2692
|
+
|
|
2693
|
+
const play = state.frameSheetPlayback[node.id] || { index: 0, acc: 0 }
|
|
2694
|
+
if (editor.frames.length) play.index %= editor.frames.length
|
|
2695
|
+
else play.index = 0
|
|
2696
|
+
state.frameSheetPlayback[node.id] = play
|
|
2697
|
+
applyFrameEditorPreviewFrame(previewStage, editor, play.index)
|
|
2698
|
+
fitFrameEditorPreviewStage(previewStage, editor)
|
|
2699
|
+
|
|
2700
|
+
const hint = document.createElement('p')
|
|
2701
|
+
hint.className = `frame-editor-hint${node.error ? ' is-error' : ''}`
|
|
2702
|
+
hint.textContent = node.error || '拖入其他图片可添加帧;拖动格子可交换顺序。'
|
|
2703
|
+
|
|
2704
|
+
const footer = document.createElement('div')
|
|
2705
|
+
footer.className = 'frame-editor-footer frame-editor-interactive'
|
|
2706
|
+
const saveButton = document.createElement('button')
|
|
2707
|
+
saveButton.type = 'button'
|
|
2708
|
+
saveButton.className = 'tool-btn'
|
|
2709
|
+
saveButton.textContent = hasActiveOperations() ? '正在保存…' : '保存成为序列帧'
|
|
2710
|
+
saveButton.disabled = hasActiveOperations() || editor.frames.length < 2 || capacity < 2
|
|
2711
|
+
saveButton.addEventListener('click', () => saveFrameEditorAsSheet(node.id))
|
|
2712
|
+
footer.appendChild(saveButton)
|
|
2713
|
+
|
|
2714
|
+
toolbar.addEventListener('pointerdown', (event) => event.stopPropagation())
|
|
2715
|
+
workspace.addEventListener('pointerdown', (event) => event.stopPropagation())
|
|
2716
|
+
footer.addEventListener('pointerdown', (event) => event.stopPropagation())
|
|
2717
|
+
container.append(head, toolbar, workspace, hint, footer)
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
function mediaWorldSize(id) {
|
|
2721
|
+
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
2722
|
+
const nodeElement = findNodeEl(id)
|
|
2723
|
+
const mediaElement = nodeElement?.querySelector(
|
|
2724
|
+
"img[data-role='frame'], video[data-role='frame'], .frame-sheet-preview",
|
|
2725
|
+
)
|
|
2726
|
+
const scale = Number(state.camera.scale) || 1
|
|
2727
|
+
const mediaRect = mediaElement?.getBoundingClientRect()
|
|
2728
|
+
const width = Number(item?.width) || (mediaRect?.width ? mediaRect.width / scale : DEFAULT_W)
|
|
2729
|
+
let height = Number(item?.height) || (mediaRect?.height ? mediaRect.height / scale : 0)
|
|
2730
|
+
if (!(height > 0)) {
|
|
2731
|
+
const dims = state.imageDims[id]
|
|
2732
|
+
if (dims?.w > 0 && dims?.h > 0) height = width * (dims.h / dims.w)
|
|
2733
|
+
}
|
|
2734
|
+
if (!(height > 0) && isVideoItem(item)) {
|
|
2735
|
+
const match = String(item?.gen?.ratio || item?.ratio || '16:9').match(/^(\d+)\s*:\s*(\d+)$/)
|
|
2736
|
+
if (match) height = width * (Number(match[2]) / Number(match[1]))
|
|
2737
|
+
}
|
|
2738
|
+
return { width, height: height > 0 ? height : width }
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function startMediaResize(event, node, corner) {
|
|
2742
|
+
if (event.button !== 0) return
|
|
2743
|
+
event.preventDefault()
|
|
2744
|
+
event.stopPropagation()
|
|
2745
|
+
hideCtxMenu()
|
|
2746
|
+
const current = state.layoutItems.find((item) => item.id === node.id)
|
|
2747
|
+
if (!current) return
|
|
2748
|
+
if (!isSelected(node.id) || state.selectedIds.size !== 1) {
|
|
2749
|
+
setSelection([node.id])
|
|
2750
|
+
syncSelectionClass()
|
|
2751
|
+
renderChrome()
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
const clickedSize = mediaWorldSize(node.id)
|
|
2755
|
+
const x = Number(current.x) || 0
|
|
2756
|
+
const y = Number(current.y) || 0
|
|
2757
|
+
const east = corner.includes('e')
|
|
2758
|
+
const south = corner.includes('s')
|
|
2759
|
+
const anchor = {
|
|
2760
|
+
x: east ? x : x + clickedSize.width,
|
|
2761
|
+
y: south ? y : y + clickedSize.height,
|
|
2762
|
+
}
|
|
2763
|
+
const diagonal = {
|
|
2764
|
+
x: east ? clickedSize.width : -clickedSize.width,
|
|
2765
|
+
y: south ? clickedSize.height : -clickedSize.height,
|
|
2766
|
+
}
|
|
2767
|
+
const originals = new Map()
|
|
2768
|
+
originals.set(current.id, {
|
|
2769
|
+
x,
|
|
2770
|
+
y,
|
|
2771
|
+
width: clickedSize.width,
|
|
2772
|
+
height: clickedSize.height,
|
|
2773
|
+
})
|
|
2774
|
+
|
|
2775
|
+
interaction.mode = 'resize'
|
|
2776
|
+
interaction.resize = {
|
|
2777
|
+
pointerId: event.pointerId,
|
|
2778
|
+
startWorld: clientToWorld(event.clientX, event.clientY),
|
|
2779
|
+
anchor,
|
|
2780
|
+
diagonal,
|
|
2781
|
+
originals,
|
|
2782
|
+
minimumScale: Math.min(
|
|
2783
|
+
1,
|
|
2784
|
+
MIN_MEDIA_RESIZE_SIDE / Math.max(1, Math.min(clickedSize.width, clickedSize.height)),
|
|
2785
|
+
),
|
|
2786
|
+
}
|
|
2787
|
+
els.viewport.classList.add('is-resizing')
|
|
2788
|
+
els.viewport.dataset.resizeCorner = corner
|
|
2789
|
+
els.viewport.setPointerCapture?.(event.pointerId)
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function appendMediaResizeHandles(container, node) {
|
|
2793
|
+
for (const corner of ['nw', 'ne', 'sw', 'se']) {
|
|
2794
|
+
const handle = document.createElement('button')
|
|
2795
|
+
handle.type = 'button'
|
|
2796
|
+
handle.className = 'canvas-resize-handle'
|
|
2797
|
+
handle.dataset.corner = corner
|
|
2798
|
+
handle.setAttribute('aria-label', '等比缩放画布元素')
|
|
2799
|
+
handle.addEventListener('pointerdown', (event) => startMediaResize(event, node, corner))
|
|
2800
|
+
container.appendChild(handle)
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function renderCanvas() {
|
|
2805
|
+
const nodes = buildNodes()
|
|
2806
|
+
const alive = new Set(nodes.map((n) => n.id))
|
|
2807
|
+
state.selectedIds = new Set([...state.selectedIds].filter((id) => alive.has(id)))
|
|
2808
|
+
for (const id of Object.keys(state.frameSheetPlayback)) {
|
|
2809
|
+
if (!alive.has(id)) delete state.frameSheetPlayback[id]
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
els.world.innerHTML = ''
|
|
2813
|
+
const dependencyLayer = createDependencyLayer()
|
|
2814
|
+
els.world.appendChild(dependencyLayer)
|
|
2815
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
2816
|
+
const node = nodes[index]
|
|
2817
|
+
const el = document.createElement('div')
|
|
2818
|
+
el.className = `canvas-node${isSelected(node.id) ? ' is-selected' : ''}${
|
|
2819
|
+
isGenDialog(node) || isVideoDialog(node) ? ' is-gen-dialog' : ''}${
|
|
2820
|
+
isVideoDialog(node) ? ' is-video-dialog' : ''
|
|
2821
|
+
}${isVideoItem(node) ? ' is-video' : ''}`
|
|
2822
|
+
el.style.left = `${node.x}px`
|
|
2823
|
+
el.style.top = `${node.y}px`
|
|
2824
|
+
el.style.width = `${node.width}px`
|
|
2825
|
+
el.style.zIndex = isSelected(node.id) ? String(1000 + index) : String(1 + index)
|
|
2826
|
+
el.dataset.id = node.id
|
|
2827
|
+
|
|
2828
|
+
if (isFrameEditor(node)) {
|
|
2829
|
+
mountFrameEditorNode(el, node)
|
|
2830
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2831
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2832
|
+
e.preventDefault()
|
|
2833
|
+
e.stopPropagation()
|
|
2834
|
+
if (!isSelected(node.id)) setSelection([node.id])
|
|
2835
|
+
state.ctxTargetId = node.id
|
|
2836
|
+
syncSelectionClass()
|
|
2837
|
+
renderChrome()
|
|
2838
|
+
showCtxMenu(e.clientX, e.clientY, node)
|
|
2839
|
+
})
|
|
2840
|
+
els.world.appendChild(el)
|
|
2841
|
+
continue
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
if (isGenDialog(node)) {
|
|
2845
|
+
mountGenDialogNode(el, node)
|
|
2846
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2847
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2848
|
+
e.preventDefault()
|
|
2849
|
+
e.stopPropagation()
|
|
2850
|
+
if (!isSelected(node.id)) setSelection([node.id])
|
|
2851
|
+
state.ctxTargetId = node.id
|
|
2852
|
+
syncSelectionClass()
|
|
2853
|
+
renderChrome()
|
|
2854
|
+
showCtxMenu(e.clientX, e.clientY, node)
|
|
2855
|
+
})
|
|
2856
|
+
els.world.appendChild(el)
|
|
2857
|
+
continue
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
if (isVideoDialog(node)) {
|
|
2861
|
+
mountVideoGenDialogNode(el, node)
|
|
2862
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2863
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2864
|
+
e.preventDefault()
|
|
2865
|
+
e.stopPropagation()
|
|
2866
|
+
if (!isSelected(node.id)) setSelection([node.id])
|
|
2867
|
+
state.ctxTargetId = node.id
|
|
2868
|
+
syncSelectionClass()
|
|
2869
|
+
renderChrome()
|
|
2870
|
+
showCtxMenu(e.clientX, e.clientY, node)
|
|
2871
|
+
})
|
|
2872
|
+
els.world.appendChild(el)
|
|
2873
|
+
continue
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
if (isPending(node)) {
|
|
2877
|
+
el.classList.add('is-pending')
|
|
2878
|
+
if (node.media === 'video') el.classList.add('is-pending-video')
|
|
2879
|
+
if (node.error) el.classList.add('is-pending-error')
|
|
2880
|
+
if (node.height) el.style.height = `${node.height}px`
|
|
2881
|
+
const stage = document.createElement('div')
|
|
2882
|
+
stage.className = 'pending-stage'
|
|
2883
|
+
stage.innerHTML = `
|
|
2884
|
+
<div class="pending-aura"></div>
|
|
2885
|
+
`
|
|
2886
|
+
el.appendChild(stage)
|
|
2887
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2888
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2183
2889
|
e.preventDefault()
|
|
2184
2890
|
e.stopPropagation()
|
|
2185
2891
|
if (!isSelected(node.id)) setSelection([node.id])
|
|
@@ -2235,7 +2941,7 @@ function renderCanvas() {
|
|
|
2235
2941
|
if (!video.videoWidth || !video.videoHeight) return
|
|
2236
2942
|
video.style.aspectRatio = `${video.videoWidth} / ${video.videoHeight}`
|
|
2237
2943
|
const layoutItem = state.layoutItems.find((it) => it.id === node.id)
|
|
2238
|
-
if (layoutItem && layoutItem.height != null) {
|
|
2944
|
+
if (layoutItem && layoutItem.manualSize !== true && layoutItem.height != null) {
|
|
2239
2945
|
layoutItem.height = null
|
|
2240
2946
|
}
|
|
2241
2947
|
requestDependencyEdgeUpdate()
|
|
@@ -2246,6 +2952,7 @@ function renderCanvas() {
|
|
|
2246
2952
|
caption.textContent = node.name
|
|
2247
2953
|
|
|
2248
2954
|
el.append(handle, video, caption)
|
|
2955
|
+
appendMediaResizeHandles(el, node)
|
|
2249
2956
|
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2250
2957
|
el.addEventListener('contextmenu', (e) => {
|
|
2251
2958
|
e.preventDefault()
|
|
@@ -2260,23 +2967,16 @@ function renderCanvas() {
|
|
|
2260
2967
|
continue
|
|
2261
2968
|
}
|
|
2262
2969
|
|
|
2263
|
-
if (isSequence(node)) {
|
|
2264
|
-
const badge = document.createElement('div')
|
|
2265
|
-
badge.className = 'seq-badge'
|
|
2266
|
-
badge.textContent = `${node.frameCount} 帧 · ${node.fps || 8}fps`
|
|
2267
|
-
el.appendChild(badge)
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
2970
|
el.classList.add('is-image')
|
|
2271
2971
|
|
|
2272
2972
|
const handle = document.createElement('div')
|
|
2273
2973
|
handle.className = 'ref-handle'
|
|
2274
|
-
handle.title =
|
|
2974
|
+
handle.title = '拖到生图对话框引用'
|
|
2275
2975
|
handle.textContent = '引用'
|
|
2276
2976
|
handle.draggable = true
|
|
2277
2977
|
handle.addEventListener('dragstart', (e) => {
|
|
2278
2978
|
e.stopPropagation()
|
|
2279
|
-
const refId =
|
|
2979
|
+
const refId = node.id
|
|
2280
2980
|
e.dataTransfer.setData(
|
|
2281
2981
|
'application/x-canvas-image',
|
|
2282
2982
|
JSON.stringify({
|
|
@@ -2296,21 +2996,66 @@ function renderCanvas() {
|
|
|
2296
2996
|
})
|
|
2297
2997
|
handle.addEventListener('pointerdown', (e) => e.stopPropagation())
|
|
2298
2998
|
|
|
2299
|
-
const
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2999
|
+
const frameSheet = frameSheetPreviewMeta(node)
|
|
3000
|
+
let mediaPreview
|
|
3001
|
+
if (frameSheet) {
|
|
3002
|
+
el.classList.add('is-frame-sheet-preview')
|
|
3003
|
+
const preview = document.createElement('div')
|
|
3004
|
+
preview.className = 'frame-sheet-preview'
|
|
3005
|
+
preview.dataset.assetId = node.id
|
|
3006
|
+
preview.style.aspectRatio =
|
|
3007
|
+
frameSheet.cellWidth && frameSheet.cellHeight
|
|
3008
|
+
? `${frameSheet.cellWidth} / ${frameSheet.cellHeight}`
|
|
3009
|
+
: '1 / 1'
|
|
3010
|
+
const image = document.createElement('img')
|
|
3011
|
+
image.src = node.url
|
|
3012
|
+
image.alt = node.name
|
|
3013
|
+
image.draggable = false
|
|
3014
|
+
image.dataset.role = 'frame-sheet-image'
|
|
3015
|
+
image.style.width = `${frameSheet.columns * 100}%`
|
|
3016
|
+
image.style.height = `${frameSheet.rows * 100}%`
|
|
3017
|
+
image.addEventListener('load', () => {
|
|
3018
|
+
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
|
3019
|
+
state.imageDims[node.id] = { w: image.naturalWidth, h: image.naturalHeight }
|
|
3020
|
+
preview.style.aspectRatio = `${image.naturalWidth * frameSheet.rows} / ${image.naturalHeight * frameSheet.columns}`
|
|
3021
|
+
const frameWidth = Number(frameSheet.cellWidth) > 0
|
|
3022
|
+
? Number(frameSheet.cellWidth)
|
|
3023
|
+
: image.naturalWidth / frameSheet.columns
|
|
3024
|
+
const frameHeight = Number(frameSheet.cellHeight) > 0
|
|
3025
|
+
? Number(frameSheet.cellHeight)
|
|
3026
|
+
: image.naturalHeight / frameSheet.rows
|
|
3027
|
+
applyImageDisplaySize(node.id, frameWidth, frameHeight)
|
|
3028
|
+
}
|
|
3029
|
+
requestDependencyEdgeUpdate()
|
|
3030
|
+
})
|
|
3031
|
+
preview.appendChild(image)
|
|
3032
|
+
const play = state.frameSheetPlayback[node.id] || { index: 0, acc: 0 }
|
|
3033
|
+
state.frameSheetPlayback[node.id] = play
|
|
3034
|
+
applyFrameSheetPreviewFrame(preview, frameSheet, play.index)
|
|
3035
|
+
mediaPreview = preview
|
|
3036
|
+
} else {
|
|
3037
|
+
const image = document.createElement('img')
|
|
3038
|
+
image.src = node.url
|
|
3039
|
+
image.alt = node.name
|
|
3040
|
+
image.draggable = false
|
|
3041
|
+
image.dataset.role = 'frame'
|
|
3042
|
+
image.addEventListener('load', () => {
|
|
3043
|
+
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
|
3044
|
+
state.imageDims[node.id] = { w: image.naturalWidth, h: image.naturalHeight }
|
|
3045
|
+
applyImageDisplaySize(node.id, image.naturalWidth, image.naturalHeight)
|
|
3046
|
+
if (state.selectedIds.has(node.id)) renderInspector()
|
|
3047
|
+
}
|
|
3048
|
+
requestDependencyEdgeUpdate()
|
|
3049
|
+
})
|
|
3050
|
+
mediaPreview = image
|
|
3051
|
+
}
|
|
2308
3052
|
|
|
2309
3053
|
const caption = document.createElement('div')
|
|
2310
3054
|
caption.className = 'canvas-node-caption'
|
|
2311
3055
|
caption.textContent = node.name
|
|
2312
3056
|
|
|
2313
|
-
el.append(handle,
|
|
3057
|
+
el.append(handle, mediaPreview, caption)
|
|
3058
|
+
appendMediaResizeHandles(el, node)
|
|
2314
3059
|
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2315
3060
|
el.addEventListener('contextmenu', (e) => {
|
|
2316
3061
|
e.preventDefault()
|
|
@@ -2327,24 +3072,6 @@ function renderCanvas() {
|
|
|
2327
3072
|
applyCamera()
|
|
2328
3073
|
}
|
|
2329
3074
|
|
|
2330
|
-
function updateSequenceFramesOnly() {
|
|
2331
|
-
// 轻量更新:只换序列节点的 img.src,避免拖拽时整树重绘打断
|
|
2332
|
-
for (const nodeEl of els.world.querySelectorAll('.canvas-node.is-sequence')) {
|
|
2333
|
-
const id = nodeEl.dataset.id
|
|
2334
|
-
const item = state.layoutItems.find((it) => it.id === id)
|
|
2335
|
-
if (!isSequence(item) || !item.frames.length) continue
|
|
2336
|
-
const play = state.playback[id] || { index: 0 }
|
|
2337
|
-
const frameId = item.frames[play.index % item.frames.length]
|
|
2338
|
-
const img = nodeEl.querySelector('img[data-role="frame"]')
|
|
2339
|
-
const nextUrl = imageUrl(frameId)
|
|
2340
|
-
if (img && img.src !== new URL(nextUrl, location.href).href) {
|
|
2341
|
-
img.src = nextUrl
|
|
2342
|
-
}
|
|
2343
|
-
const badge = nodeEl.querySelector('.seq-badge')
|
|
2344
|
-
if (badge) badge.textContent = `${item.frames.length} 帧 · ${item.fps || 8}fps`
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
3075
|
function inspSelectField(labelText, selectEl) {
|
|
2349
3076
|
const field = el('label', 'insp-field')
|
|
2350
3077
|
field.appendChild(document.createTextNode(labelText))
|
|
@@ -2553,122 +3280,6 @@ function renderInspectorImage(id) {
|
|
|
2553
3280
|
els.inspectorBody.appendChild(section)
|
|
2554
3281
|
}
|
|
2555
3282
|
|
|
2556
|
-
function renderInspectorSequence(item) {
|
|
2557
|
-
els.inspectorTitle.textContent = '序列'
|
|
2558
|
-
const section = el('div', 'insp-section')
|
|
2559
|
-
appendKv(section, [
|
|
2560
|
-
['名称', item.name || item.id],
|
|
2561
|
-
['ID', item.id],
|
|
2562
|
-
['帧数', String(item.frames.length)],
|
|
2563
|
-
['播放速率', `${item.fps || 8} FPS`],
|
|
2564
|
-
['循环', item.loop === false ? '关闭' : '开启'],
|
|
2565
|
-
])
|
|
2566
|
-
appendOriginSection(section, item)
|
|
2567
|
-
|
|
2568
|
-
const fpsField = el('label', 'insp-field')
|
|
2569
|
-
fpsField.appendChild(document.createTextNode('播放速率 (FPS)'))
|
|
2570
|
-
const fpsRow = el('div', 'insp-field-row')
|
|
2571
|
-
const fpsInput = document.createElement('input')
|
|
2572
|
-
fpsInput.type = 'range'
|
|
2573
|
-
fpsInput.min = '1'
|
|
2574
|
-
fpsInput.max = '24'
|
|
2575
|
-
fpsInput.value = String(item.fps || 8)
|
|
2576
|
-
const fpsVal = el('span', 'insp-field-val', String(item.fps || 8))
|
|
2577
|
-
fpsInput.addEventListener('input', () => {
|
|
2578
|
-
fpsVal.textContent = fpsInput.value
|
|
2579
|
-
})
|
|
2580
|
-
fpsInput.addEventListener('change', () => {
|
|
2581
|
-
patchSequence(item.id, { fps: Number(fpsInput.value) || 8 })
|
|
2582
|
-
})
|
|
2583
|
-
fpsRow.append(fpsInput, fpsVal)
|
|
2584
|
-
fpsField.appendChild(fpsRow)
|
|
2585
|
-
section.appendChild(fpsField)
|
|
2586
|
-
|
|
2587
|
-
const loopLabel = el('label', 'insp-check')
|
|
2588
|
-
const loopInput = document.createElement('input')
|
|
2589
|
-
loopInput.type = 'checkbox'
|
|
2590
|
-
loopInput.checked = item.loop !== false
|
|
2591
|
-
loopInput.addEventListener('change', () => {
|
|
2592
|
-
patchSequence(item.id, { loop: loopInput.checked })
|
|
2593
|
-
})
|
|
2594
|
-
loopLabel.append(loopInput, document.createTextNode('循环播放'))
|
|
2595
|
-
section.appendChild(loopLabel)
|
|
2596
|
-
els.inspectorBody.appendChild(section)
|
|
2597
|
-
|
|
2598
|
-
const framesWrap = el('div', 'insp-section')
|
|
2599
|
-
framesWrap.appendChild(el('div', 'insp-label', '构成帧'))
|
|
2600
|
-
const box = el('div', 'insp-frame-box')
|
|
2601
|
-
box.appendChild(el('p', 'insp-frame-hint', '拖动缩略图可调整顺序'))
|
|
2602
|
-
const grid = el('div', 'insp-frame-grid')
|
|
2603
|
-
let dragFrom = -1
|
|
2604
|
-
|
|
2605
|
-
item.frames.forEach((fid, index) => {
|
|
2606
|
-
ensureImageDims(fid)
|
|
2607
|
-
const card = el('div', 'insp-frame')
|
|
2608
|
-
card.draggable = true
|
|
2609
|
-
card.dataset.index = String(index)
|
|
2610
|
-
card.title = `${index + 1}. ${imageName(fid)}\n${fid}\n${formatDims(fid)}`
|
|
2611
|
-
|
|
2612
|
-
const badge = el('span', 'insp-frame-index', String(index + 1))
|
|
2613
|
-
const del = document.createElement('button')
|
|
2614
|
-
del.type = 'button'
|
|
2615
|
-
del.className = 'insp-frame-del'
|
|
2616
|
-
del.title = '删除此帧'
|
|
2617
|
-
del.textContent = '×'
|
|
2618
|
-
del.addEventListener('pointerdown', (e) => e.stopPropagation())
|
|
2619
|
-
del.addEventListener('click', (e) => {
|
|
2620
|
-
e.stopPropagation()
|
|
2621
|
-
removeFrame(item.id, index)
|
|
2622
|
-
})
|
|
2623
|
-
|
|
2624
|
-
const thumb = el('div', 'insp-frame-thumb')
|
|
2625
|
-
const img = document.createElement('img')
|
|
2626
|
-
img.src = imageUrl(fid)
|
|
2627
|
-
img.alt = imageName(fid)
|
|
2628
|
-
img.draggable = false
|
|
2629
|
-
thumb.appendChild(img)
|
|
2630
|
-
card.append(badge, del, thumb)
|
|
2631
|
-
|
|
2632
|
-
card.addEventListener('dragstart', (e) => {
|
|
2633
|
-
dragFrom = index
|
|
2634
|
-
card.classList.add('is-dragging')
|
|
2635
|
-
e.dataTransfer.effectAllowed = 'move'
|
|
2636
|
-
e.dataTransfer.setData('text/plain', String(index))
|
|
2637
|
-
})
|
|
2638
|
-
card.addEventListener('dragend', () => {
|
|
2639
|
-
dragFrom = -1
|
|
2640
|
-
card.classList.remove('is-dragging')
|
|
2641
|
-
for (const node of grid.querySelectorAll('.insp-frame.is-drop-target')) {
|
|
2642
|
-
node.classList.remove('is-drop-target')
|
|
2643
|
-
}
|
|
2644
|
-
})
|
|
2645
|
-
card.addEventListener('dragover', (e) => {
|
|
2646
|
-
e.preventDefault()
|
|
2647
|
-
e.dataTransfer.dropEffect = 'move'
|
|
2648
|
-
if (dragFrom === index) return
|
|
2649
|
-
for (const node of grid.querySelectorAll('.insp-frame.is-drop-target')) {
|
|
2650
|
-
node.classList.remove('is-drop-target')
|
|
2651
|
-
}
|
|
2652
|
-
card.classList.add('is-drop-target')
|
|
2653
|
-
})
|
|
2654
|
-
card.addEventListener('dragleave', () => {
|
|
2655
|
-
card.classList.remove('is-drop-target')
|
|
2656
|
-
})
|
|
2657
|
-
card.addEventListener('drop', (e) => {
|
|
2658
|
-
e.preventDefault()
|
|
2659
|
-
card.classList.remove('is-drop-target')
|
|
2660
|
-
const from = Number(e.dataTransfer.getData('text/plain'))
|
|
2661
|
-
if (!Number.isFinite(from) || from === index) return
|
|
2662
|
-
reorderFrame(item.id, from, index)
|
|
2663
|
-
})
|
|
2664
|
-
|
|
2665
|
-
grid.appendChild(card)
|
|
2666
|
-
})
|
|
2667
|
-
box.appendChild(grid)
|
|
2668
|
-
framesWrap.appendChild(box)
|
|
2669
|
-
els.inspectorBody.appendChild(framesWrap)
|
|
2670
|
-
}
|
|
2671
|
-
|
|
2672
3283
|
function renderInspectorMulti(ids) {
|
|
2673
3284
|
els.inspectorTitle.textContent = `已选中 ${ids.length} 项`
|
|
2674
3285
|
const list = el('div', 'insp-list')
|
|
@@ -2678,9 +3289,12 @@ function renderInspectorMulti(ids) {
|
|
|
2678
3289
|
btn.type = 'button'
|
|
2679
3290
|
const name = el('div', 'name')
|
|
2680
3291
|
const meta = el('div', 'meta')
|
|
2681
|
-
if (
|
|
2682
|
-
|
|
2683
|
-
|
|
3292
|
+
if (isFrameEditor(item)) {
|
|
3293
|
+
const editor = normalizeFrameEditor(item)
|
|
3294
|
+
name.textContent = item.name || '序列帧编辑'
|
|
3295
|
+
meta.textContent = editor
|
|
3296
|
+
? `编辑器 · ${editor.frames.length} 帧 · ${editor.rows}×${editor.columns}`
|
|
3297
|
+
: '序列帧编辑器'
|
|
2684
3298
|
} else if (isGenDialog(item)) {
|
|
2685
3299
|
name.textContent = item.name || '图片生成'
|
|
2686
3300
|
meta.textContent = '生图对话框'
|
|
@@ -2731,13 +3345,20 @@ function renderInspector() {
|
|
|
2731
3345
|
|
|
2732
3346
|
const id = ids[0]
|
|
2733
3347
|
const item = getSelectedItem(id)
|
|
2734
|
-
if (
|
|
2735
|
-
|
|
2736
|
-
|
|
3348
|
+
if (isFrameEditor(item)) {
|
|
3349
|
+
const editor = normalizeFrameEditor(item)
|
|
3350
|
+
els.inspectorTitle.textContent = '序列帧编辑'
|
|
3351
|
+
const section = el('div', 'insp-section')
|
|
3352
|
+
appendKv(section, [
|
|
3353
|
+
['来源', editor ? imageName(editor.sourceId) : '—'],
|
|
3354
|
+
['切分', editor ? `${editor.sourceRows}×${editor.sourceColumns}` : '—'],
|
|
3355
|
+
['输出网格', editor ? `${editor.rows}×${editor.columns}` : '—'],
|
|
3356
|
+
['帧数', editor?.frames.length ?? 0],
|
|
3357
|
+
['FPS', editor?.fps ?? '—'],
|
|
3358
|
+
])
|
|
3359
|
+
els.inspectorBody.appendChild(section)
|
|
2737
3360
|
return
|
|
2738
3361
|
}
|
|
2739
|
-
|
|
2740
|
-
state.editingSeqId = null
|
|
2741
3362
|
if (isGenDialog(item)) {
|
|
2742
3363
|
renderInspectorGenDialog(item)
|
|
2743
3364
|
return
|
|
@@ -2797,145 +3418,19 @@ function renderChrome() {
|
|
|
2797
3418
|
renderInspector()
|
|
2798
3419
|
}
|
|
2799
3420
|
|
|
2800
|
-
function openSeqModal(seqId) {
|
|
2801
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
2802
|
-
if (!isSequence(item)) return
|
|
2803
|
-
// 序列编辑改到左侧属性栏,不再弹出模态框
|
|
2804
|
-
state.editingSeqId = seqId
|
|
2805
|
-
setSelection([seqId])
|
|
2806
|
-
syncSelectionClass()
|
|
2807
|
-
renderChrome()
|
|
2808
|
-
}
|
|
2809
|
-
|
|
2810
|
-
function closeSeqModal() {
|
|
2811
|
-
els.seqModal.hidden = true
|
|
2812
|
-
state.editingSeqId = null
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
function renderSeqFrames(item) {
|
|
2816
|
-
els.seqFrames.innerHTML = ''
|
|
2817
|
-
item.frames.forEach((fid, index) => {
|
|
2818
|
-
const row = document.createElement('div')
|
|
2819
|
-
row.className = 'seq-frame-row'
|
|
2820
|
-
const img = document.createElement('img')
|
|
2821
|
-
img.src = imageUrl(fid)
|
|
2822
|
-
img.alt = imageName(fid)
|
|
2823
|
-
const name = document.createElement('div')
|
|
2824
|
-
name.className = 'seq-frame-name'
|
|
2825
|
-
name.textContent = `${index + 1}. ${imageName(fid)}`
|
|
2826
|
-
const actions = document.createElement('div')
|
|
2827
|
-
actions.className = 'seq-frame-actions'
|
|
2828
|
-
|
|
2829
|
-
const up = document.createElement('button')
|
|
2830
|
-
up.type = 'button'
|
|
2831
|
-
up.title = '上移'
|
|
2832
|
-
up.textContent = '↑'
|
|
2833
|
-
up.disabled = index === 0
|
|
2834
|
-
up.addEventListener('click', () => moveFrame(item.id, index, -1))
|
|
2835
|
-
|
|
2836
|
-
const down = document.createElement('button')
|
|
2837
|
-
down.type = 'button'
|
|
2838
|
-
down.title = '下移'
|
|
2839
|
-
down.textContent = '↓'
|
|
2840
|
-
down.disabled = index === item.frames.length - 1
|
|
2841
|
-
down.addEventListener('click', () => moveFrame(item.id, index, 1))
|
|
2842
|
-
|
|
2843
|
-
const del = document.createElement('button')
|
|
2844
|
-
del.type = 'button'
|
|
2845
|
-
del.title = '删除此帧'
|
|
2846
|
-
del.textContent = '×'
|
|
2847
|
-
del.addEventListener('click', () => removeFrame(item.id, index))
|
|
2848
|
-
|
|
2849
|
-
actions.append(up, down, del)
|
|
2850
|
-
row.append(img, name, actions)
|
|
2851
|
-
els.seqFrames.appendChild(row)
|
|
2852
|
-
})
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
function patchSequence(seqId, patch) {
|
|
2856
|
-
state.layoutItems = state.layoutItems.map((it) =>
|
|
2857
|
-
it.id === seqId && isSequence(it) ? { ...it, ...patch } : it,
|
|
2858
|
-
)
|
|
2859
|
-
saveLayout(state.layoutItems)
|
|
2860
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
2861
|
-
if (item && !els.seqModal.hidden) renderSeqFrames(item)
|
|
2862
|
-
renderChrome()
|
|
2863
|
-
updateSequenceFramesOnly()
|
|
2864
|
-
}
|
|
2865
|
-
|
|
2866
|
-
function moveFrame(seqId, index, delta) {
|
|
2867
|
-
reorderFrame(seqId, index, index + delta)
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
function reorderFrame(seqId, from, to) {
|
|
2871
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
2872
|
-
if (!isSequence(item)) return
|
|
2873
|
-
if (from === to || from < 0 || to < 0) return
|
|
2874
|
-
if (from >= item.frames.length || to >= item.frames.length) return
|
|
2875
|
-
const frames = [...item.frames]
|
|
2876
|
-
const [picked] = frames.splice(from, 1)
|
|
2877
|
-
frames.splice(to, 0, picked)
|
|
2878
|
-
// 播放索引跟随被拖动的帧
|
|
2879
|
-
const play = state.playback[seqId]
|
|
2880
|
-
if (play) {
|
|
2881
|
-
if (play.index === from) play.index = to
|
|
2882
|
-
else if (from < play.index && to >= play.index) play.index -= 1
|
|
2883
|
-
else if (from > play.index && to <= play.index) play.index += 1
|
|
2884
|
-
state.playback[seqId] = play
|
|
2885
|
-
}
|
|
2886
|
-
patchSequence(seqId, { frames })
|
|
2887
|
-
}
|
|
2888
|
-
|
|
2889
|
-
async function removeFrame(seqId, index) {
|
|
2890
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
2891
|
-
if (!isSequence(item)) return
|
|
2892
|
-
if (item.frames.length <= 1) {
|
|
2893
|
-
state.error = '序列至少保留 1 帧;可用「拆散为单图」或右键删除整个序列'
|
|
2894
|
-
renderChrome()
|
|
2895
|
-
return
|
|
2896
|
-
}
|
|
2897
|
-
const removedId = item.frames[index]
|
|
2898
|
-
const frames = item.frames.filter((_, i) => i !== index)
|
|
2899
|
-
const play = state.playback[seqId] || { index: 0, acc: 0 }
|
|
2900
|
-
play.index = play.index % frames.length
|
|
2901
|
-
state.playback[seqId] = play
|
|
2902
|
-
patchSequence(seqId, { frames })
|
|
2903
|
-
// 删了就是删了:从磁盘移除该帧,避免刷新后冒成独立图
|
|
2904
|
-
try {
|
|
2905
|
-
await api('/api/delete', {
|
|
2906
|
-
method: 'POST',
|
|
2907
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2908
|
-
body: JSON.stringify({ image_ids: [removedId] }),
|
|
2909
|
-
})
|
|
2910
|
-
state.images = state.images.filter((img) => img.id !== removedId)
|
|
2911
|
-
} catch (err) {
|
|
2912
|
-
state.error = String(err.message || err)
|
|
2913
|
-
renderChrome()
|
|
2914
|
-
}
|
|
2915
|
-
}
|
|
2916
|
-
|
|
2917
3421
|
async function copyCanvasItem(targetId) {
|
|
2918
3422
|
const item = state.layoutItems.find((it) => it.id === targetId)
|
|
2919
|
-
if (!item || isPending(item) || isGenDialog(item) || isVideoDialog(item)) return
|
|
3423
|
+
if (!item || isPending(item) || isGenDialog(item) || isVideoDialog(item) || isFrameEditor(item)) return
|
|
2920
3424
|
|
|
2921
|
-
const ids =
|
|
3425
|
+
const ids = [item.id]
|
|
2922
3426
|
if (!ids.length) return
|
|
2923
3427
|
|
|
2924
3428
|
try {
|
|
2925
3429
|
state.error = ''
|
|
2926
|
-
const sequence = isSequence(item)
|
|
2927
|
-
? {
|
|
2928
|
-
kind: 'sequence',
|
|
2929
|
-
name: item.name || '序列',
|
|
2930
|
-
fps: Number(item.fps) > 0 ? Number(item.fps) : 8,
|
|
2931
|
-
loop: item.loop !== false,
|
|
2932
|
-
frameCount: ids.length,
|
|
2933
|
-
}
|
|
2934
|
-
: undefined
|
|
2935
3430
|
await api('/api/clipboard/copy', {
|
|
2936
3431
|
method: 'POST',
|
|
2937
3432
|
headers: { 'Content-Type': 'application/json' },
|
|
2938
|
-
body: JSON.stringify({ ids
|
|
3433
|
+
body: JSON.stringify({ ids }),
|
|
2939
3434
|
})
|
|
2940
3435
|
renderChrome()
|
|
2941
3436
|
} catch (err) {
|
|
@@ -2945,8 +3440,8 @@ async function copyCanvasItem(targetId) {
|
|
|
2945
3440
|
}
|
|
2946
3441
|
|
|
2947
3442
|
async function pasteClipboardFiles(point) {
|
|
2948
|
-
if (!point ||
|
|
2949
|
-
|
|
3443
|
+
if (!point || hasActiveOperations()) return
|
|
3444
|
+
const activeOperation = beginActiveOperation('clipboard-paste')
|
|
2950
3445
|
state.error = ''
|
|
2951
3446
|
renderChrome()
|
|
2952
3447
|
try {
|
|
@@ -2957,29 +3452,7 @@ async function pasteClipboardFiles(point) {
|
|
|
2957
3452
|
})
|
|
2958
3453
|
const assets = Array.isArray(result.assets) ? result.assets : []
|
|
2959
3454
|
if (!assets.length) throw new Error('剪贴板中没有可粘贴的素材文件')
|
|
2960
|
-
const sequence = result.sequence?.kind === 'sequence' ? result.sequence : null
|
|
2961
3455
|
const ids = assets.map((asset) => asset.id)
|
|
2962
|
-
if (sequence && assets.every((asset) => asset.kind === 'image')) {
|
|
2963
|
-
const sequenceId = `seq_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
2964
|
-
state.layoutItems.push({
|
|
2965
|
-
id: sequenceId,
|
|
2966
|
-
type: 'sequence',
|
|
2967
|
-
name: `${sequence.name || '序列'} 副本`,
|
|
2968
|
-
x: point.x,
|
|
2969
|
-
y: point.y,
|
|
2970
|
-
width: DEFAULT_W,
|
|
2971
|
-
height: null,
|
|
2972
|
-
frames: ids,
|
|
2973
|
-
fps: Number(sequence.fps) > 0 ? Number(sequence.fps) : 8,
|
|
2974
|
-
loop: sequence.loop !== false,
|
|
2975
|
-
})
|
|
2976
|
-
await saveLayout(state.layoutItems)
|
|
2977
|
-
await reload()
|
|
2978
|
-
setSelection([sequenceId])
|
|
2979
|
-
renderCanvas()
|
|
2980
|
-
return
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
3456
|
const columns = Math.min(5, Math.max(1, Math.ceil(Math.sqrt(assets.length))))
|
|
2984
3457
|
assets.forEach((asset, index) => {
|
|
2985
3458
|
const width = asset.kind === 'video' ? 280 : DEFAULT_W
|
|
@@ -3000,7 +3473,7 @@ async function pasteClipboardFiles(point) {
|
|
|
3000
3473
|
} catch (err) {
|
|
3001
3474
|
state.error = String(err.message || err)
|
|
3002
3475
|
} finally {
|
|
3003
|
-
|
|
3476
|
+
endActiveOperation(activeOperation)
|
|
3004
3477
|
renderChrome()
|
|
3005
3478
|
}
|
|
3006
3479
|
}
|
|
@@ -3026,7 +3499,8 @@ async function deleteCanvasItems(targetIds, { confirm = true } = {}) {
|
|
|
3026
3499
|
.filter(Boolean)
|
|
3027
3500
|
const imageIds = ids.filter((id) => {
|
|
3028
3501
|
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
3029
|
-
return !item ||
|
|
3502
|
+
return !item ||
|
|
3503
|
+
(!isGenDialog(item) && !isVideoDialog(item) && !isPending(item) && !isFrameEditor(item))
|
|
3030
3504
|
})
|
|
3031
3505
|
|
|
3032
3506
|
state.deleting = true
|
|
@@ -3041,11 +3515,9 @@ async function deleteCanvasItems(targetIds, { confirm = true } = {}) {
|
|
|
3041
3515
|
})
|
|
3042
3516
|
|
|
3043
3517
|
for (const item of items) {
|
|
3044
|
-
if (isSequence(item)) delete state.playback[item.id]
|
|
3045
3518
|
if (isGenDialog(item)) delete state.genDialogs[item.id]
|
|
3046
3519
|
if (isVideoDialog(item)) delete state.videoDialogs[item.id]
|
|
3047
3520
|
}
|
|
3048
|
-
if (state.editingSeqId && selected.has(state.editingSeqId)) closeSeqModal()
|
|
3049
3521
|
for (const ds of Object.values(state.genDialogs)) {
|
|
3050
3522
|
ds.refs = ds.refs.filter((reference) => !selected.has(reference.id))
|
|
3051
3523
|
}
|
|
@@ -3067,16 +3539,21 @@ async function deleteCanvasItems(targetIds, { confirm = true } = {}) {
|
|
|
3067
3539
|
}
|
|
3068
3540
|
|
|
3069
3541
|
function resetSessionState() {
|
|
3542
|
+
clearTimeout(imageSizeSaveTimer)
|
|
3543
|
+
imageSizeSaveTimer = null
|
|
3070
3544
|
state.meta = null
|
|
3071
3545
|
state.images = []
|
|
3072
3546
|
state.layoutItems = []
|
|
3547
|
+
state.layoutRevision = 0
|
|
3548
|
+
layoutNextExpectedRevision = 0
|
|
3549
|
+
layoutSaveEpoch += 1
|
|
3073
3550
|
state.selectedIds = new Set()
|
|
3074
|
-
state.editingSeqId = null
|
|
3075
3551
|
state.genDialogs = {}
|
|
3076
3552
|
state.videoDialogs = {}
|
|
3077
3553
|
state.fileInputDialogId = null
|
|
3078
3554
|
state.mediaInputDialogId = null
|
|
3079
|
-
state.
|
|
3555
|
+
state.activeOperations.clear()
|
|
3556
|
+
state.failedOperationIds.clear()
|
|
3080
3557
|
state.deleting = false
|
|
3081
3558
|
state.error = ''
|
|
3082
3559
|
state.camera = { x: 0, y: 0, scale: 1 }
|
|
@@ -3084,12 +3561,15 @@ function resetSessionState() {
|
|
|
3084
3561
|
state.ctxCanvasPoint = null
|
|
3085
3562
|
state.interactiveEditImageId = null
|
|
3086
3563
|
state.layerDecompositionImageId = null
|
|
3087
|
-
state.
|
|
3564
|
+
state.framePreviewImageId = null
|
|
3565
|
+
state.frameEditorSourceImageId = null
|
|
3566
|
+
state.frameSheetPlayback = {}
|
|
3088
3567
|
state.imageDims = {}
|
|
3089
3568
|
hideCtxMenu()
|
|
3090
3569
|
closeInteractiveEditModal(true)
|
|
3091
3570
|
closeLayerDecompositionModal(true)
|
|
3092
|
-
|
|
3571
|
+
closeFramePreviewModal(true)
|
|
3572
|
+
closeFrameEditorSplitModal(true)
|
|
3093
3573
|
if (els.world) els.world.innerHTML = ''
|
|
3094
3574
|
if (els.inspector) els.inspector.hidden = true
|
|
3095
3575
|
}
|
|
@@ -3244,6 +3724,8 @@ async function fetchCanvasSnapshot() {
|
|
|
3244
3724
|
return {
|
|
3245
3725
|
images: imagesData.images || [],
|
|
3246
3726
|
items: layoutData.items || [],
|
|
3727
|
+
version: layoutData.version,
|
|
3728
|
+
revision: layoutData.revision,
|
|
3247
3729
|
}
|
|
3248
3730
|
}
|
|
3249
3731
|
}
|
|
@@ -3251,6 +3733,11 @@ async function fetchCanvasSnapshot() {
|
|
|
3251
3733
|
function applyCanvasSnapshot(snapshot, force = false) {
|
|
3252
3734
|
const nextImages = snapshot.images || []
|
|
3253
3735
|
const nextItems = mergeLayout(nextImages, snapshot.items || [])
|
|
3736
|
+
const nextRevision = Number(snapshot.revision)
|
|
3737
|
+
if (Number.isInteger(nextRevision) && nextRevision >= 0) {
|
|
3738
|
+
state.layoutRevision = nextRevision
|
|
3739
|
+
if (layoutPendingSaveCount === 0) layoutNextExpectedRevision = nextRevision
|
|
3740
|
+
}
|
|
3254
3741
|
if (!force && JSON.stringify([state.images, state.layoutItems]) === JSON.stringify([nextImages, nextItems])) {
|
|
3255
3742
|
return false
|
|
3256
3743
|
}
|
|
@@ -3258,11 +3745,6 @@ function applyCanvasSnapshot(snapshot, force = false) {
|
|
|
3258
3745
|
state.layoutItems = nextItems
|
|
3259
3746
|
renderCanvas()
|
|
3260
3747
|
renderChrome()
|
|
3261
|
-
if (!els.seqModal.hidden && state.editingSeqId) {
|
|
3262
|
-
const item = state.layoutItems.find((it) => it.id === state.editingSeqId)
|
|
3263
|
-
if (isSequence(item)) openSeqModal(item.id)
|
|
3264
|
-
else closeSeqModal()
|
|
3265
|
-
}
|
|
3266
3748
|
return true
|
|
3267
3749
|
}
|
|
3268
3750
|
|
|
@@ -3285,7 +3767,7 @@ function hasEditableFocus() {
|
|
|
3285
3767
|
function shouldDeferExternalSync() {
|
|
3286
3768
|
return Boolean(
|
|
3287
3769
|
!state.project ||
|
|
3288
|
-
|
|
3770
|
+
hasActiveOperations() ||
|
|
3289
3771
|
state.refreshing ||
|
|
3290
3772
|
interaction.mode ||
|
|
3291
3773
|
document.hidden ||
|
|
@@ -3362,11 +3844,12 @@ async function boot() {
|
|
|
3362
3844
|
}
|
|
3363
3845
|
|
|
3364
3846
|
const interaction = {
|
|
3365
|
-
mode: null, // null | 'pan' | 'marquee' | 'move' | 'click' | 'generator-tool-drag'
|
|
3847
|
+
mode: null, // null | 'pan' | 'marquee' | 'move' | 'resize' | 'click' | 'generator-tool-drag'
|
|
3366
3848
|
htmlDrag: false,
|
|
3367
3849
|
pan: null,
|
|
3368
3850
|
marquee: null,
|
|
3369
3851
|
move: null,
|
|
3852
|
+
resize: null,
|
|
3370
3853
|
click: null,
|
|
3371
3854
|
}
|
|
3372
3855
|
|
|
@@ -3524,41 +4007,33 @@ function ensureLayoutItem(node) {
|
|
|
3524
4007
|
? 280
|
|
3525
4008
|
: DEFAULT_W),
|
|
3526
4009
|
height: isPending(node) || isVideoItem(node) ? node.height ?? null : null,
|
|
3527
|
-
...(
|
|
4010
|
+
...(isGenDialog(node)
|
|
3528
4011
|
? {
|
|
3529
|
-
type: '
|
|
3530
|
-
name: node.name,
|
|
3531
|
-
frames: [...node.frames],
|
|
3532
|
-
fps: node.fps || 8,
|
|
3533
|
-
loop: node.loop !== false,
|
|
4012
|
+
type: 'gen_dialog',
|
|
4013
|
+
name: node.name || '图片生成',
|
|
3534
4014
|
}
|
|
3535
|
-
:
|
|
4015
|
+
: isVideoDialog(node)
|
|
3536
4016
|
? {
|
|
3537
|
-
type: '
|
|
3538
|
-
name: node.name || '
|
|
4017
|
+
type: 'video_dialog',
|
|
4018
|
+
name: node.name || '视频生成',
|
|
3539
4019
|
}
|
|
3540
|
-
:
|
|
4020
|
+
: isPending(node)
|
|
3541
4021
|
? {
|
|
3542
|
-
type: '
|
|
3543
|
-
name: node.name || '
|
|
4022
|
+
type: 'pending',
|
|
4023
|
+
name: node.name || '生成中',
|
|
4024
|
+
prompt: node.prompt || '',
|
|
4025
|
+
size: node.size || '',
|
|
4026
|
+
model: node.model || '',
|
|
4027
|
+
media: node.media || 'image',
|
|
4028
|
+
ratio: node.ratio || '',
|
|
4029
|
+
error: node.error || null,
|
|
4030
|
+
references: (node.references || []).map((reference) => ({ ...reference })),
|
|
3544
4031
|
}
|
|
3545
|
-
:
|
|
4032
|
+
: isVideoItem(node)
|
|
3546
4033
|
? {
|
|
3547
|
-
type: '
|
|
3548
|
-
name: node.name || '生成中',
|
|
3549
|
-
prompt: node.prompt || '',
|
|
3550
|
-
size: node.size || '',
|
|
3551
|
-
model: node.model || '',
|
|
3552
|
-
media: node.media || 'image',
|
|
3553
|
-
ratio: node.ratio || '',
|
|
3554
|
-
error: node.error || null,
|
|
3555
|
-
references: (node.references || []).map((reference) => ({ ...reference })),
|
|
4034
|
+
type: 'video',
|
|
3556
4035
|
}
|
|
3557
|
-
:
|
|
3558
|
-
? {
|
|
3559
|
-
type: 'video',
|
|
3560
|
-
}
|
|
3561
|
-
: {}),
|
|
4036
|
+
: {}),
|
|
3562
4037
|
})
|
|
3563
4038
|
return getNodePosition(node.id, node)
|
|
3564
4039
|
}
|
|
@@ -3648,6 +4123,7 @@ function startMarquee(e) {
|
|
|
3648
4123
|
|
|
3649
4124
|
function onNodePointerDown(e, node) {
|
|
3650
4125
|
if (e.target?.closest?.('.ref-handle')) return
|
|
4126
|
+
if (e.target?.closest?.('.canvas-resize-handle')) return
|
|
3651
4127
|
if (e.target?.closest?.('.gen-dialog-chrome .ghost-btn')) return
|
|
3652
4128
|
if (e.button === 2) return
|
|
3653
4129
|
|
|
@@ -3789,6 +4265,49 @@ els.viewport.addEventListener('pointermove', (e) => {
|
|
|
3789
4265
|
return
|
|
3790
4266
|
}
|
|
3791
4267
|
|
|
4268
|
+
if (interaction.mode === 'resize' && interaction.resize) {
|
|
4269
|
+
const resize = interaction.resize
|
|
4270
|
+
if (resize.pointerId !== e.pointerId) return
|
|
4271
|
+
const world = clientToWorld(e.clientX, e.clientY)
|
|
4272
|
+
const pointerDelta = {
|
|
4273
|
+
x: world.x - resize.startWorld.x,
|
|
4274
|
+
y: world.y - resize.startWorld.y,
|
|
4275
|
+
}
|
|
4276
|
+
const vector = {
|
|
4277
|
+
x: resize.diagonal.x + pointerDelta.x,
|
|
4278
|
+
y: resize.diagonal.y + pointerDelta.y,
|
|
4279
|
+
}
|
|
4280
|
+
const denominator =
|
|
4281
|
+
resize.diagonal.x * resize.diagonal.x + resize.diagonal.y * resize.diagonal.y
|
|
4282
|
+
const projectedScale = denominator > 0
|
|
4283
|
+
? (vector.x * resize.diagonal.x + vector.y * resize.diagonal.y) / denominator
|
|
4284
|
+
: 1
|
|
4285
|
+
const scale = Math.max(resize.minimumScale, projectedScale)
|
|
4286
|
+
|
|
4287
|
+
for (const [id, original] of resize.originals) {
|
|
4288
|
+
const x = resize.anchor.x + (original.x - resize.anchor.x) * scale
|
|
4289
|
+
const y = resize.anchor.y + (original.y - resize.anchor.y) * scale
|
|
4290
|
+
const width = Math.max(1, original.width * scale)
|
|
4291
|
+
const height = Math.max(1, original.height * scale)
|
|
4292
|
+
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
4293
|
+
if (item) {
|
|
4294
|
+
item.x = x
|
|
4295
|
+
item.y = y
|
|
4296
|
+
item.width = width
|
|
4297
|
+
item.height = height
|
|
4298
|
+
item.manualSize = true
|
|
4299
|
+
}
|
|
4300
|
+
const nodeElement = findNodeEl(id)
|
|
4301
|
+
if (nodeElement) {
|
|
4302
|
+
nodeElement.style.left = `${x}px`
|
|
4303
|
+
nodeElement.style.top = `${y}px`
|
|
4304
|
+
nodeElement.style.width = `${width}px`
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
requestDependencyEdgeUpdate()
|
|
4308
|
+
return
|
|
4309
|
+
}
|
|
4310
|
+
|
|
3792
4311
|
if (interaction.mode === 'marquee' && interaction.marquee) {
|
|
3793
4312
|
updateMarquee(
|
|
3794
4313
|
interaction.marquee.x0,
|
|
@@ -3802,7 +4321,7 @@ els.viewport.addEventListener('pointermove', (e) => {
|
|
|
3802
4321
|
function endPointer(e) {
|
|
3803
4322
|
const mode = interaction.mode
|
|
3804
4323
|
|
|
3805
|
-
if (mode === 'move') {
|
|
4324
|
+
if (mode === 'move' || mode === 'resize') {
|
|
3806
4325
|
saveLayout(state.layoutItems)
|
|
3807
4326
|
}
|
|
3808
4327
|
|
|
@@ -3860,10 +4379,13 @@ function endPointer(e) {
|
|
|
3860
4379
|
}
|
|
3861
4380
|
|
|
3862
4381
|
els.viewport.classList.remove('is-panning')
|
|
4382
|
+
els.viewport.classList.remove('is-resizing')
|
|
4383
|
+
delete els.viewport.dataset.resizeCorner
|
|
3863
4384
|
interaction.mode = null
|
|
3864
4385
|
interaction.pan = null
|
|
3865
4386
|
interaction.marquee = null
|
|
3866
4387
|
interaction.move = null
|
|
4388
|
+
interaction.resize = null
|
|
3867
4389
|
interaction.click = null
|
|
3868
4390
|
}
|
|
3869
4391
|
|
|
@@ -3889,14 +4411,13 @@ window.addEventListener('keydown', async (e) => {
|
|
|
3889
4411
|
hideCtxMenu()
|
|
3890
4412
|
// 弹窗只允许点关闭/取消按钮退出,Esc 不关
|
|
3891
4413
|
if (
|
|
3892
|
-
!els.seqModal.hidden ||
|
|
3893
4414
|
!els.interactiveEditModal.hidden ||
|
|
3894
4415
|
!els.layerDecompositionModal.hidden ||
|
|
3895
4416
|
!els.smartSplitModal.hidden ||
|
|
3896
4417
|
!els.manualSplitModal.hidden ||
|
|
3897
4418
|
!els.frameExtractModal.hidden ||
|
|
3898
|
-
!els.
|
|
3899
|
-
!els.
|
|
4419
|
+
!els.framePreviewModal.hidden ||
|
|
4420
|
+
!els.frameEditorSplitModal.hidden ||
|
|
3900
4421
|
!els.videoCycleModal.hidden
|
|
3901
4422
|
) {
|
|
3902
4423
|
return
|
|
@@ -3914,7 +4435,6 @@ window.addEventListener('click', (e) => {
|
|
|
3914
4435
|
|
|
3915
4436
|
els.inspectorClose.addEventListener('click', () => {
|
|
3916
4437
|
clearSelection()
|
|
3917
|
-
state.editingSeqId = null
|
|
3918
4438
|
syncSelectionClass()
|
|
3919
4439
|
renderChrome()
|
|
3920
4440
|
})
|
|
@@ -3988,15 +4508,11 @@ els.ctxMenu.addEventListener('click', async (e) => {
|
|
|
3988
4508
|
openLayerDecompositionModal(targetId)
|
|
3989
4509
|
return
|
|
3990
4510
|
}
|
|
3991
|
-
if (action === 'video-trim') {
|
|
3992
|
-
openVideoTrimModal(targetId)
|
|
3993
|
-
return
|
|
3994
|
-
}
|
|
3995
4511
|
if (action === 'video-cycle') {
|
|
3996
4512
|
openVideoCycleModal(targetId)
|
|
3997
4513
|
return
|
|
3998
4514
|
}
|
|
3999
|
-
if (action === 'video-to-
|
|
4515
|
+
if (action === 'video-to-frame-sheet') {
|
|
4000
4516
|
openFrameExtractModal(targetId)
|
|
4001
4517
|
return
|
|
4002
4518
|
}
|
|
@@ -4008,20 +4524,14 @@ els.ctxMenu.addEventListener('click', async (e) => {
|
|
|
4008
4524
|
openManualSplitModal(targetId)
|
|
4009
4525
|
return
|
|
4010
4526
|
}
|
|
4011
|
-
if (action === '
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
if (action === 'sequence-to-sheet') {
|
|
4016
|
-
openSeqSheetModal(targetId)
|
|
4017
|
-
return
|
|
4018
|
-
}
|
|
4019
|
-
if (action === 'ungroup-sequence') {
|
|
4020
|
-
await ungroupSequence(targetId)
|
|
4527
|
+
if (action === 'frame-sheet-preview') {
|
|
4528
|
+
const node = state.layoutItems.find((item) => item.id === targetId)
|
|
4529
|
+
if (frameSheetPreviewMeta(node)) await stopFrameSheetPreview(targetId)
|
|
4530
|
+
else openFramePreviewModal(targetId)
|
|
4021
4531
|
return
|
|
4022
4532
|
}
|
|
4023
|
-
if (action === '
|
|
4024
|
-
|
|
4533
|
+
if (action === 'frame-sheet-edit') {
|
|
4534
|
+
openFrameEditorSplitModal(targetId)
|
|
4025
4535
|
return
|
|
4026
4536
|
}
|
|
4027
4537
|
if (action === 'delete') {
|
|
@@ -4030,9 +4540,6 @@ els.ctxMenu.addEventListener('click', async (e) => {
|
|
|
4030
4540
|
}
|
|
4031
4541
|
})
|
|
4032
4542
|
|
|
4033
|
-
els.seqPanelClose.addEventListener('click', closeSeqModal)
|
|
4034
|
-
els.seqPanelDone.addEventListener('click', closeSeqModal)
|
|
4035
|
-
|
|
4036
4543
|
els.interactiveEditClose.addEventListener('click', closeInteractiveEditModal)
|
|
4037
4544
|
els.interactiveEditCancel.addEventListener('click', closeInteractiveEditModal)
|
|
4038
4545
|
els.interactiveEditSave.addEventListener('click', () => {
|
|
@@ -4091,13 +4598,41 @@ els.frameExtractCount.addEventListener('keydown', (e) => {
|
|
|
4091
4598
|
}
|
|
4092
4599
|
})
|
|
4093
4600
|
|
|
4094
|
-
els.
|
|
4095
|
-
els.
|
|
4096
|
-
els.
|
|
4097
|
-
|
|
4601
|
+
els.framePreviewClose.addEventListener('click', () => closeFramePreviewModal())
|
|
4602
|
+
els.framePreviewCancel.addEventListener('click', () => closeFramePreviewModal())
|
|
4603
|
+
els.framePreviewConfirm.addEventListener('click', () => {
|
|
4604
|
+
confirmFramePreview()
|
|
4605
|
+
})
|
|
4606
|
+
els.framePreviewRows.addEventListener('input', updateFramePreviewBounds)
|
|
4607
|
+
els.framePreviewColumns.addEventListener('input', updateFramePreviewBounds)
|
|
4608
|
+
for (const input of [
|
|
4609
|
+
els.framePreviewRows,
|
|
4610
|
+
els.framePreviewColumns,
|
|
4611
|
+
els.framePreviewFps,
|
|
4612
|
+
]) {
|
|
4613
|
+
input.addEventListener('keydown', (e) => {
|
|
4614
|
+
if (e.key === 'Enter') {
|
|
4615
|
+
e.preventDefault()
|
|
4616
|
+
confirmFramePreview()
|
|
4617
|
+
}
|
|
4618
|
+
})
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
els.frameEditorSplitClose.addEventListener('click', () => closeFrameEditorSplitModal())
|
|
4622
|
+
els.frameEditorSplitCancel.addEventListener('click', () => closeFrameEditorSplitModal())
|
|
4623
|
+
els.frameEditorSplitConfirm.addEventListener('click', () => {
|
|
4624
|
+
confirmFrameEditorSplit()
|
|
4098
4625
|
})
|
|
4099
|
-
els.
|
|
4100
|
-
els.
|
|
4626
|
+
els.frameEditorSplitRows.addEventListener('input', updateFrameEditorSplitInfo)
|
|
4627
|
+
els.frameEditorSplitColumns.addEventListener('input', updateFrameEditorSplitInfo)
|
|
4628
|
+
for (const input of [els.frameEditorSplitRows, els.frameEditorSplitColumns]) {
|
|
4629
|
+
input.addEventListener('keydown', (event) => {
|
|
4630
|
+
if (event.key === 'Enter') {
|
|
4631
|
+
event.preventDefault()
|
|
4632
|
+
confirmFrameEditorSplit()
|
|
4633
|
+
}
|
|
4634
|
+
})
|
|
4635
|
+
}
|
|
4101
4636
|
|
|
4102
4637
|
els.videoCycleClose.addEventListener('click', () => closeVideoCycleModal())
|
|
4103
4638
|
els.videoCycleCancel.addEventListener('click', () => closeVideoCycleModal())
|
|
@@ -4105,124 +4640,6 @@ els.videoCycleConfirm.addEventListener('click', () => {
|
|
|
4105
4640
|
confirmVideoCycle()
|
|
4106
4641
|
})
|
|
4107
4642
|
|
|
4108
|
-
els.videoTrimClose.addEventListener('click', closeVideoTrimModal)
|
|
4109
|
-
els.videoTrimCancel.addEventListener('click', closeVideoTrimModal)
|
|
4110
|
-
els.videoTrimConfirm.addEventListener('click', () => {
|
|
4111
|
-
confirmVideoTrim()
|
|
4112
|
-
})
|
|
4113
|
-
bindVideoTrimHandles()
|
|
4114
|
-
|
|
4115
|
-
els.seqFps.addEventListener('input', () => {
|
|
4116
|
-
els.seqFpsVal.textContent = els.seqFps.value
|
|
4117
|
-
})
|
|
4118
|
-
els.seqFps.addEventListener('change', () => {
|
|
4119
|
-
if (!state.editingSeqId) return
|
|
4120
|
-
patchSequence(state.editingSeqId, { fps: Number(els.seqFps.value) || 8 })
|
|
4121
|
-
})
|
|
4122
|
-
els.seqLoop.addEventListener('change', () => {
|
|
4123
|
-
if (!state.editingSeqId) return
|
|
4124
|
-
patchSequence(state.editingSeqId, { loop: els.seqLoop.checked })
|
|
4125
|
-
})
|
|
4126
|
-
|
|
4127
|
-
function formatTrimClock(sec) {
|
|
4128
|
-
const s = Math.max(0, Number(sec) || 0)
|
|
4129
|
-
const m = Math.floor(s / 60)
|
|
4130
|
-
const rest = s - m * 60
|
|
4131
|
-
const whole = Math.floor(rest)
|
|
4132
|
-
const ms = Math.round((rest - whole) * 1000)
|
|
4133
|
-
const msClamped = ms === 1000 ? 999 : ms
|
|
4134
|
-
return `${m}:${String(whole).padStart(2, '0')}.${String(msClamped).padStart(3, '0')}`
|
|
4135
|
-
}
|
|
4136
|
-
|
|
4137
|
-
function clampTrimTime(t) {
|
|
4138
|
-
const dur = Math.max(0, state.videoTrim.duration || 0)
|
|
4139
|
-
return Math.min(Math.max(0, Number(t) || 0), dur)
|
|
4140
|
-
}
|
|
4141
|
-
|
|
4142
|
-
function syncVideoTrimUi() {
|
|
4143
|
-
const { duration, start, end } = state.videoTrim
|
|
4144
|
-
const dur = Math.max(duration, 1e-6)
|
|
4145
|
-
const left = (start / dur) * 100
|
|
4146
|
-
const right = (end / dur) * 100
|
|
4147
|
-
els.videoTrimHandleStart.style.left = `${left}%`
|
|
4148
|
-
els.videoTrimHandleEnd.style.left = `${right}%`
|
|
4149
|
-
els.videoTrimRange.style.left = `${left}%`
|
|
4150
|
-
els.videoTrimRange.style.width = `${Math.max(0, right - left)}%`
|
|
4151
|
-
els.videoTrimStartLabel.textContent = formatTrimClock(start)
|
|
4152
|
-
els.videoTrimEndLabel.textContent = formatTrimClock(end)
|
|
4153
|
-
els.videoTrimDurLabel.textContent = formatTrimClock(Math.max(0, end - start))
|
|
4154
|
-
els.videoTrimTotalLabel.textContent = formatTrimClock(duration)
|
|
4155
|
-
els.videoTrimScrub.max = String(duration || 0)
|
|
4156
|
-
els.videoTrimScrub.step = '0.001'
|
|
4157
|
-
syncVideoTrimNow()
|
|
4158
|
-
}
|
|
4159
|
-
|
|
4160
|
-
function syncVideoTrimNow(forcedTime) {
|
|
4161
|
-
const video = els.videoTrimPreview
|
|
4162
|
-
const t =
|
|
4163
|
-
forcedTime != null
|
|
4164
|
-
? clampTrimTime(forcedTime)
|
|
4165
|
-
: state.videoTrim.uiTime != null
|
|
4166
|
-
? clampTrimTime(state.videoTrim.uiTime)
|
|
4167
|
-
: clampTrimTime(video?.currentTime)
|
|
4168
|
-
els.videoTrimNowLabel.textContent = formatTrimClock(t)
|
|
4169
|
-
if (!state.videoTrim.scrubbing) {
|
|
4170
|
-
els.videoTrimScrub.value = String(t)
|
|
4171
|
-
}
|
|
4172
|
-
}
|
|
4173
|
-
|
|
4174
|
-
function setTrimVideoTime(t) {
|
|
4175
|
-
const video = els.videoTrimPreview
|
|
4176
|
-
const next = clampTrimTime(t)
|
|
4177
|
-
state.videoTrim.uiTime = next
|
|
4178
|
-
syncVideoTrimNow(next)
|
|
4179
|
-
if (!video) return
|
|
4180
|
-
try {
|
|
4181
|
-
if (Math.abs((Number(video.currentTime) || 0) - next) > 0.0005) {
|
|
4182
|
-
video.currentTime = next
|
|
4183
|
-
}
|
|
4184
|
-
} catch {
|
|
4185
|
-
/* ignore */
|
|
4186
|
-
}
|
|
4187
|
-
}
|
|
4188
|
-
|
|
4189
|
-
function updateTrimPlayButton() {
|
|
4190
|
-
const video = els.videoTrimPreview
|
|
4191
|
-
const playing = Boolean(video && !video.paused && !video.ended)
|
|
4192
|
-
els.videoTrimPlay.textContent = playing ? '❚❚' : '▶'
|
|
4193
|
-
els.videoTrimPlay.title = playing ? '暂停' : '播放'
|
|
4194
|
-
}
|
|
4195
|
-
|
|
4196
|
-
function toggleTrimPlayback() {
|
|
4197
|
-
const video = els.videoTrimPreview
|
|
4198
|
-
if (!video || !state.videoTrim.duration) return
|
|
4199
|
-
if (!video.paused && !video.ended) {
|
|
4200
|
-
video.pause()
|
|
4201
|
-
updateTrimPlayButton()
|
|
4202
|
-
return
|
|
4203
|
-
}
|
|
4204
|
-
// 若 UI 已拖到某处但解码器尚未到位,先落到该时间再播(避免从 0 起播)
|
|
4205
|
-
const want =
|
|
4206
|
-
state.videoTrim.uiTime != null
|
|
4207
|
-
? clampTrimTime(state.videoTrim.uiTime)
|
|
4208
|
-
: clampTrimTime(Number(els.videoTrimScrub.value) || video.currentTime || 0)
|
|
4209
|
-
const dur = state.videoTrim.duration
|
|
4210
|
-
const atEnd = video.ended || want >= dur - 0.01
|
|
4211
|
-
const startAt = atEnd ? 0 : want
|
|
4212
|
-
state.videoTrim.uiTime = null
|
|
4213
|
-
state.videoTrim.scrubbing = false
|
|
4214
|
-
try {
|
|
4215
|
-
if (Math.abs((Number(video.currentTime) || 0) - startAt) > 0.01) {
|
|
4216
|
-
video.currentTime = startAt
|
|
4217
|
-
}
|
|
4218
|
-
} catch {
|
|
4219
|
-
/* ignore */
|
|
4220
|
-
}
|
|
4221
|
-
syncVideoTrimNow(startAt)
|
|
4222
|
-
video.play().catch(() => {})
|
|
4223
|
-
updateTrimPlayButton()
|
|
4224
|
-
}
|
|
4225
|
-
|
|
4226
4643
|
function openVideoCycleModal(videoId) {
|
|
4227
4644
|
const item = state.layoutItems.find((it) => it.id === videoId)
|
|
4228
4645
|
if (!isVideoItem(item) && !String(videoId || '').startsWith('videos/')) {
|
|
@@ -4248,7 +4665,7 @@ function closeVideoCycleModal(force = false) {
|
|
|
4248
4665
|
state.videoCycleAbort.abort()
|
|
4249
4666
|
state.videoCycleAbort = null
|
|
4250
4667
|
}
|
|
4251
|
-
if (!force &&
|
|
4668
|
+
if (!force && hasActiveOperations() && state.videoCycleId) {
|
|
4252
4669
|
els.videoCycleStatus.hidden = false
|
|
4253
4670
|
els.videoCycleStatus.textContent = '正在取消…'
|
|
4254
4671
|
return
|
|
@@ -4264,353 +4681,91 @@ function readVideoCycleParams() {
|
|
|
4264
4681
|
}
|
|
4265
4682
|
const minGapSec = Number(els.videoCycleMinGap.value)
|
|
4266
4683
|
if (!Number.isFinite(minGapSec) || minGapSec < 0.1 || minGapSec > 10) {
|
|
4267
|
-
throw new Error('最短周期需在 0.1–10 秒之间')
|
|
4268
|
-
}
|
|
4269
|
-
return { threshold: thresholdPct / 100, minGapSec }
|
|
4270
|
-
}
|
|
4271
|
-
|
|
4272
|
-
async function confirmVideoCycle() {
|
|
4273
|
-
const videoId = state.videoCycleId
|
|
4274
|
-
if (!videoId || state.busy) return
|
|
4275
|
-
|
|
4276
|
-
let params
|
|
4277
|
-
try {
|
|
4278
|
-
params = readVideoCycleParams()
|
|
4279
|
-
} catch (err) {
|
|
4280
|
-
els.videoCycleStatus.hidden = false
|
|
4281
|
-
els.videoCycleStatus.textContent = String(err.message || err)
|
|
4282
|
-
return
|
|
4283
|
-
}
|
|
4284
|
-
|
|
4285
|
-
const node = state.layoutItems.find((it) => it.id === videoId)
|
|
4286
|
-
const abort = new AbortController()
|
|
4287
|
-
state.videoCycleAbort = abort
|
|
4288
|
-
state.busy = true
|
|
4289
|
-
state.error = ''
|
|
4290
|
-
els.videoCycleConfirm.disabled = true
|
|
4291
|
-
els.videoCycleCancel.disabled = false
|
|
4292
|
-
els.videoCycleClose.disabled = false
|
|
4293
|
-
els.videoCycleThreshold.disabled = true
|
|
4294
|
-
els.videoCycleMinGap.disabled = true
|
|
4295
|
-
els.videoCycleStatus.hidden = false
|
|
4296
|
-
els.videoCycleStatus.textContent = '正在扫描周期…'
|
|
4297
|
-
renderChrome()
|
|
4298
|
-
|
|
4299
|
-
try {
|
|
4300
|
-
const operationId = await createProcessingPlaceholder(node, {
|
|
4301
|
-
name: '循环提取中',
|
|
4302
|
-
media: 'video',
|
|
4303
|
-
references: [videoId],
|
|
4304
|
-
})
|
|
4305
|
-
els.videoCycleModal.hidden = true
|
|
4306
|
-
state.videoCycleId = null
|
|
4307
|
-
const result = await api('/api/video-cycle', {
|
|
4308
|
-
method: 'POST',
|
|
4309
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4310
|
-
body: JSON.stringify({
|
|
4311
|
-
id: videoId,
|
|
4312
|
-
threshold: params.threshold,
|
|
4313
|
-
min_gap_sec: params.minGapSec,
|
|
4314
|
-
client_operation_id: operationId,
|
|
4315
|
-
}),
|
|
4316
|
-
signal: abort.signal,
|
|
4317
|
-
})
|
|
4318
|
-
|
|
4319
|
-
await reload()
|
|
4320
|
-
setSelection([result.id])
|
|
4321
|
-
closeVideoCycleModal(true)
|
|
4322
|
-
renderCanvas()
|
|
4323
|
-
renderChrome()
|
|
4324
|
-
} catch (err) {
|
|
4325
|
-
if (err?.name === 'AbortError') {
|
|
4326
|
-
els.videoCycleModal.hidden = true
|
|
4327
|
-
state.videoCycleId = null
|
|
4328
|
-
els.videoCycleStatus.hidden = true
|
|
4329
|
-
els.videoCycleStatus.textContent = ''
|
|
4330
|
-
} else {
|
|
4331
|
-
state.error = String(err.message || err)
|
|
4332
|
-
els.videoCycleStatus.hidden = false
|
|
4333
|
-
els.videoCycleStatus.textContent = state.error
|
|
4334
|
-
renderChrome()
|
|
4335
|
-
}
|
|
4336
|
-
} finally {
|
|
4337
|
-
state.busy = false
|
|
4338
|
-
state.videoCycleAbort = null
|
|
4339
|
-
els.videoCycleConfirm.disabled = false
|
|
4340
|
-
els.videoCycleCancel.disabled = false
|
|
4341
|
-
els.videoCycleClose.disabled = false
|
|
4342
|
-
els.videoCycleThreshold.disabled = false
|
|
4343
|
-
els.videoCycleMinGap.disabled = false
|
|
4344
|
-
renderChrome()
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
|
|
4348
|
-
function openVideoTrimModal(videoId) {
|
|
4349
|
-
const item = state.layoutItems.find((it) => it.id === videoId)
|
|
4350
|
-
if (!isVideoItem(item) && !String(videoId || '').startsWith('videos/')) {
|
|
4351
|
-
state.error = '请对视频元素使用裁剪'
|
|
4352
|
-
renderChrome()
|
|
4353
|
-
return
|
|
4354
|
-
}
|
|
4355
|
-
state.videoTrimId = videoId
|
|
4356
|
-
state.videoTrim = {
|
|
4357
|
-
duration: 0,
|
|
4358
|
-
start: 0,
|
|
4359
|
-
end: 0,
|
|
4360
|
-
drag: null,
|
|
4361
|
-
uiTime: 0,
|
|
4362
|
-
scrubbing: false,
|
|
4363
|
-
}
|
|
4364
|
-
els.videoTrimStatus.hidden = true
|
|
4365
|
-
els.videoTrimStatus.textContent = ''
|
|
4366
|
-
els.videoTrimConfirm.disabled = true
|
|
4367
|
-
els.videoTrimCancel.disabled = false
|
|
4368
|
-
els.videoTrimClose.disabled = false
|
|
4369
|
-
els.videoTrimPlay.disabled = true
|
|
4370
|
-
els.videoTrimScrub.disabled = true
|
|
4371
|
-
els.videoTrimModal.hidden = false
|
|
4372
|
-
syncVideoTrimNow(0)
|
|
4373
|
-
|
|
4374
|
-
const video = els.videoTrimPreview
|
|
4375
|
-
video.pause()
|
|
4376
|
-
updateTrimPlayButton()
|
|
4377
|
-
video.muted = true
|
|
4378
|
-
video.controls = false
|
|
4379
|
-
video.removeAttribute('controls')
|
|
4380
|
-
video.playsInline = true
|
|
4381
|
-
video.preload = 'auto'
|
|
4382
|
-
video.src = `${imageUrl(videoId)}?t=${Date.now()}`
|
|
4383
|
-
|
|
4384
|
-
const onMeta = () => {
|
|
4385
|
-
const duration = Number(video.duration)
|
|
4386
|
-
if (!Number.isFinite(duration) || duration <= 0) {
|
|
4387
|
-
els.videoTrimStatus.hidden = false
|
|
4388
|
-
els.videoTrimStatus.textContent = '无法读取视频时长'
|
|
4389
|
-
return
|
|
4390
|
-
}
|
|
4391
|
-
state.videoTrim.duration = duration
|
|
4392
|
-
state.videoTrim.start = 0
|
|
4393
|
-
state.videoTrim.end = duration
|
|
4394
|
-
state.videoTrim.uiTime = 0
|
|
4395
|
-
els.videoTrimConfirm.disabled = false
|
|
4396
|
-
els.videoTrimPlay.disabled = false
|
|
4397
|
-
els.videoTrimScrub.disabled = false
|
|
4398
|
-
syncVideoTrimUi()
|
|
4399
|
-
setTrimVideoTime(0)
|
|
4400
|
-
}
|
|
4401
|
-
const onTime = () => {
|
|
4402
|
-
if (!state.videoTrimId || state.videoTrim.drag || state.videoTrim.scrubbing) return
|
|
4403
|
-
if (state.videoTrim.uiTime != null) {
|
|
4404
|
-
if (video.seeking) return
|
|
4405
|
-
const ui = state.videoTrim.uiTime
|
|
4406
|
-
const cur = Number(video.currentTime) || 0
|
|
4407
|
-
if (Math.abs(cur - ui) > 0.05) return
|
|
4408
|
-
state.videoTrim.uiTime = null
|
|
4409
|
-
}
|
|
4410
|
-
syncVideoTrimNow()
|
|
4411
|
-
}
|
|
4412
|
-
const onPlayPause = () => updateTrimPlayButton()
|
|
4413
|
-
const onEnded = () => {
|
|
4414
|
-
state.videoTrim.uiTime = null
|
|
4415
|
-
updateTrimPlayButton()
|
|
4416
|
-
syncVideoTrimNow()
|
|
4417
|
-
}
|
|
4418
|
-
|
|
4419
|
-
video.addEventListener('loadedmetadata', onMeta, { once: true })
|
|
4420
|
-
video.addEventListener('timeupdate', onTime)
|
|
4421
|
-
video.addEventListener('seeked', onTime)
|
|
4422
|
-
video.addEventListener('play', onPlayPause)
|
|
4423
|
-
video.addEventListener('pause', onPlayPause)
|
|
4424
|
-
video.addEventListener('ended', onEnded)
|
|
4425
|
-
video._trimCleanups = () => {
|
|
4426
|
-
video.removeEventListener('timeupdate', onTime)
|
|
4427
|
-
video.removeEventListener('seeked', onTime)
|
|
4428
|
-
video.removeEventListener('play', onPlayPause)
|
|
4429
|
-
video.removeEventListener('pause', onPlayPause)
|
|
4430
|
-
video.removeEventListener('ended', onEnded)
|
|
4431
|
-
}
|
|
4432
|
-
video.load()
|
|
4433
|
-
}
|
|
4434
|
-
|
|
4435
|
-
function closeVideoTrimModal(force = false) {
|
|
4436
|
-
if (!force && state.busy && state.videoTrimId) return
|
|
4437
|
-
const video = els.videoTrimPreview
|
|
4438
|
-
video.pause()
|
|
4439
|
-
video._trimCleanups?.()
|
|
4440
|
-
video._trimCleanups = null
|
|
4441
|
-
video.removeAttribute('src')
|
|
4442
|
-
video.load()
|
|
4443
|
-
els.videoTrimModal.hidden = true
|
|
4444
|
-
state.videoTrimId = null
|
|
4445
|
-
state.videoTrim.drag = null
|
|
4446
|
-
state.videoTrim.uiTime = null
|
|
4447
|
-
state.videoTrim.scrubbing = false
|
|
4448
|
-
updateTrimPlayButton()
|
|
4449
|
-
}
|
|
4450
|
-
|
|
4451
|
-
function bindVideoTrimHandles() {
|
|
4452
|
-
const MIN_GAP = 0.04
|
|
4453
|
-
|
|
4454
|
-
const timeFromClientX = (clientX) => {
|
|
4455
|
-
const rect = els.videoTrimTrack.getBoundingClientRect()
|
|
4456
|
-
const width = Math.max(rect.width, 1)
|
|
4457
|
-
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / width))
|
|
4458
|
-
return clampTrimTime(ratio * state.videoTrim.duration)
|
|
4459
|
-
}
|
|
4460
|
-
|
|
4461
|
-
const beginHandleDrag = (which, e) => {
|
|
4462
|
-
if (e.button != null && e.button !== 0) return
|
|
4463
|
-
e.preventDefault()
|
|
4464
|
-
e.stopPropagation()
|
|
4465
|
-
if (state.busy || !state.videoTrim.duration) return
|
|
4466
|
-
|
|
4467
|
-
const video = els.videoTrimPreview
|
|
4468
|
-
video.pause()
|
|
4469
|
-
updateTrimPlayButton()
|
|
4470
|
-
state.videoTrim.drag = which
|
|
4471
|
-
|
|
4472
|
-
const apply = (t) => {
|
|
4473
|
-
if (which === 'start') {
|
|
4474
|
-
state.videoTrim.start = Math.min(Math.max(0, t), state.videoTrim.end - MIN_GAP)
|
|
4475
|
-
state.videoTrim.uiTime = state.videoTrim.start
|
|
4476
|
-
syncVideoTrimUi()
|
|
4477
|
-
} else {
|
|
4478
|
-
state.videoTrim.end = Math.max(
|
|
4479
|
-
Math.min(t, state.videoTrim.duration),
|
|
4480
|
-
state.videoTrim.start + MIN_GAP,
|
|
4481
|
-
)
|
|
4482
|
-
state.videoTrim.uiTime = state.videoTrim.end
|
|
4483
|
-
syncVideoTrimUi()
|
|
4484
|
-
}
|
|
4485
|
-
}
|
|
4486
|
-
|
|
4487
|
-
apply(timeFromClientX(e.clientX))
|
|
4488
|
-
|
|
4489
|
-
const onMove = (ev) => {
|
|
4490
|
-
if (state.videoTrim.drag !== which) return
|
|
4491
|
-
if (typeof ev.clientX !== 'number') return
|
|
4492
|
-
apply(timeFromClientX(ev.clientX))
|
|
4493
|
-
}
|
|
4494
|
-
|
|
4495
|
-
const finish = () => {
|
|
4496
|
-
if (state.videoTrim.drag !== which) return
|
|
4497
|
-
const finalT = which === 'start' ? state.videoTrim.start : state.videoTrim.end
|
|
4498
|
-
state.videoTrim.drag = null
|
|
4499
|
-
setTrimVideoTime(finalT)
|
|
4500
|
-
window.removeEventListener('pointermove', onMove, true)
|
|
4501
|
-
window.removeEventListener('pointerup', finish, true)
|
|
4502
|
-
window.removeEventListener('pointercancel', finish, true)
|
|
4503
|
-
}
|
|
4504
|
-
|
|
4505
|
-
window.addEventListener('pointermove', onMove, true)
|
|
4506
|
-
window.addEventListener('pointerup', finish, true)
|
|
4507
|
-
window.addEventListener('pointercancel', finish, true)
|
|
4508
|
-
}
|
|
4509
|
-
|
|
4510
|
-
els.videoTrimHandleStart.addEventListener('pointerdown', (e) => beginHandleDrag('start', e))
|
|
4511
|
-
els.videoTrimHandleEnd.addEventListener('pointerdown', (e) => beginHandleDrag('end', e))
|
|
4512
|
-
|
|
4513
|
-
const onScrubInput = () => {
|
|
4514
|
-
if (!state.videoTrim.duration) return
|
|
4515
|
-
const t = clampTrimTime(Number(els.videoTrimScrub.value))
|
|
4516
|
-
state.videoTrim.scrubbing = true
|
|
4517
|
-
state.videoTrim.uiTime = t
|
|
4518
|
-
els.videoTrimPreview.pause()
|
|
4519
|
-
updateTrimPlayButton()
|
|
4520
|
-
syncVideoTrimNow(t)
|
|
4521
|
-
try {
|
|
4522
|
-
els.videoTrimPreview.currentTime = t
|
|
4523
|
-
} catch {
|
|
4524
|
-
/* ignore */
|
|
4525
|
-
}
|
|
4526
|
-
}
|
|
4527
|
-
const onScrubEnd = () => {
|
|
4528
|
-
const t = clampTrimTime(Number(els.videoTrimScrub.value))
|
|
4529
|
-
state.videoTrim.scrubbing = false
|
|
4530
|
-
setTrimVideoTime(t)
|
|
4531
|
-
}
|
|
4532
|
-
|
|
4533
|
-
els.videoTrimScrub.addEventListener('input', onScrubInput)
|
|
4534
|
-
els.videoTrimScrub.addEventListener('change', onScrubEnd)
|
|
4535
|
-
els.videoTrimScrub.addEventListener('pointerup', onScrubEnd)
|
|
4536
|
-
els.videoTrimScrub.addEventListener('keyup', onScrubEnd)
|
|
4537
|
-
|
|
4538
|
-
els.videoTrimTimeline.addEventListener(
|
|
4539
|
-
'wheel',
|
|
4540
|
-
(e) => {
|
|
4541
|
-
if (!state.videoTrim.duration || state.busy) return
|
|
4542
|
-
e.preventDefault()
|
|
4543
|
-
const step = e.shiftKey ? 0.001 : 1 / 24
|
|
4544
|
-
const delta = e.deltaY > 0 ? step : -step
|
|
4545
|
-
const base =
|
|
4546
|
-
state.videoTrim.uiTime != null
|
|
4547
|
-
? state.videoTrim.uiTime
|
|
4548
|
-
: Number(els.videoTrimPreview.currentTime) || 0
|
|
4549
|
-
els.videoTrimPreview.pause()
|
|
4550
|
-
updateTrimPlayButton()
|
|
4551
|
-
setTrimVideoTime(base + delta)
|
|
4552
|
-
},
|
|
4553
|
-
{ passive: false },
|
|
4554
|
-
)
|
|
4555
|
-
|
|
4556
|
-
els.videoTrimPlay.addEventListener('click', () => {
|
|
4557
|
-
toggleTrimPlayback()
|
|
4558
|
-
})
|
|
4684
|
+
throw new Error('最短周期需在 0.1–10 秒之间')
|
|
4685
|
+
}
|
|
4686
|
+
return { threshold: thresholdPct / 100, minGapSec }
|
|
4559
4687
|
}
|
|
4560
4688
|
|
|
4561
|
-
async function
|
|
4562
|
-
const videoId = state.
|
|
4563
|
-
if (!videoId ||
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4689
|
+
async function confirmVideoCycle() {
|
|
4690
|
+
const videoId = state.videoCycleId
|
|
4691
|
+
if (!videoId || hasActiveOperations()) return
|
|
4692
|
+
|
|
4693
|
+
let params
|
|
4694
|
+
try {
|
|
4695
|
+
params = readVideoCycleParams()
|
|
4696
|
+
} catch (err) {
|
|
4697
|
+
els.videoCycleStatus.hidden = false
|
|
4698
|
+
els.videoCycleStatus.textContent = String(err.message || err)
|
|
4568
4699
|
return
|
|
4569
4700
|
}
|
|
4570
4701
|
|
|
4571
4702
|
const node = state.layoutItems.find((it) => it.id === videoId)
|
|
4572
|
-
|
|
4703
|
+
const abort = new AbortController()
|
|
4704
|
+
state.videoCycleAbort = abort
|
|
4705
|
+
const activeOperation = beginActiveOperation('video-cycle')
|
|
4573
4706
|
state.error = ''
|
|
4574
|
-
els.
|
|
4575
|
-
els.
|
|
4576
|
-
els.
|
|
4577
|
-
els.
|
|
4578
|
-
els.
|
|
4707
|
+
els.videoCycleConfirm.disabled = true
|
|
4708
|
+
els.videoCycleCancel.disabled = false
|
|
4709
|
+
els.videoCycleClose.disabled = false
|
|
4710
|
+
els.videoCycleThreshold.disabled = true
|
|
4711
|
+
els.videoCycleMinGap.disabled = true
|
|
4712
|
+
els.videoCycleStatus.hidden = false
|
|
4713
|
+
els.videoCycleStatus.textContent = '正在扫描周期…'
|
|
4579
4714
|
renderChrome()
|
|
4580
4715
|
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4716
|
+
let operationId = ''
|
|
4717
|
+
try {
|
|
4718
|
+
operationId = await createProcessingPlaceholder(node, {
|
|
4719
|
+
name: '循环提取中',
|
|
4584
4720
|
media: 'video',
|
|
4585
4721
|
references: [videoId],
|
|
4586
4722
|
})
|
|
4587
|
-
|
|
4588
|
-
|
|
4723
|
+
els.videoCycleModal.hidden = true
|
|
4724
|
+
state.videoCycleId = null
|
|
4725
|
+
const result = await api('/api/video-cycle', {
|
|
4589
4726
|
method: 'POST',
|
|
4590
4727
|
headers: { 'Content-Type': 'application/json' },
|
|
4591
|
-
body: JSON.stringify({
|
|
4728
|
+
body: JSON.stringify({
|
|
4729
|
+
id: videoId,
|
|
4730
|
+
threshold: params.threshold,
|
|
4731
|
+
min_gap_sec: params.minGapSec,
|
|
4732
|
+
client_operation_id: operationId,
|
|
4733
|
+
}),
|
|
4734
|
+
signal: abort.signal,
|
|
4592
4735
|
})
|
|
4736
|
+
|
|
4593
4737
|
await reload()
|
|
4594
4738
|
setSelection([result.id])
|
|
4595
|
-
|
|
4739
|
+
closeVideoCycleModal(true)
|
|
4596
4740
|
renderCanvas()
|
|
4597
4741
|
renderChrome()
|
|
4598
|
-
} catch (err) {
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4742
|
+
} catch (err) {
|
|
4743
|
+
await discardFailedPendingOperation(operationId)
|
|
4744
|
+
if (err?.name === 'AbortError') {
|
|
4745
|
+
els.videoCycleModal.hidden = true
|
|
4746
|
+
state.videoCycleId = null
|
|
4747
|
+
els.videoCycleStatus.hidden = true
|
|
4748
|
+
els.videoCycleStatus.textContent = ''
|
|
4749
|
+
} else {
|
|
4750
|
+
state.error = String(err.message || err)
|
|
4751
|
+
els.videoCycleStatus.hidden = false
|
|
4752
|
+
els.videoCycleStatus.textContent = state.error
|
|
4753
|
+
renderChrome()
|
|
4754
|
+
}
|
|
4603
4755
|
} finally {
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
els.
|
|
4607
|
-
els.
|
|
4756
|
+
endActiveOperation(activeOperation)
|
|
4757
|
+
state.videoCycleAbort = null
|
|
4758
|
+
els.videoCycleConfirm.disabled = false
|
|
4759
|
+
els.videoCycleCancel.disabled = false
|
|
4760
|
+
els.videoCycleClose.disabled = false
|
|
4761
|
+
els.videoCycleThreshold.disabled = false
|
|
4762
|
+
els.videoCycleMinGap.disabled = false
|
|
4608
4763
|
renderChrome()
|
|
4609
4764
|
}
|
|
4610
4765
|
}
|
|
4611
4766
|
|
|
4612
|
-
async function createProcessingPlaceholder(source, { name, media = 'image', references = [] } = {}) {
|
|
4613
|
-
const id =
|
|
4767
|
+
async function createProcessingPlaceholder(source, { name, media = 'image', references = [] } = {}) {
|
|
4768
|
+
const id = createCanvasClientId('pending')
|
|
4614
4769
|
const width = source?.width || (media === 'video' ? 280 : DEFAULT_W)
|
|
4615
4770
|
const item = {
|
|
4616
4771
|
id,
|
|
@@ -4625,12 +4780,42 @@ async function createProcessingPlaceholder(source, { name, media = 'image', refe
|
|
|
4625
4780
|
id: referenceId,
|
|
4626
4781
|
role: 'source',
|
|
4627
4782
|
})),
|
|
4628
|
-
}
|
|
4629
|
-
state.layoutItems.push(item)
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4783
|
+
}
|
|
4784
|
+
state.layoutItems.push(item)
|
|
4785
|
+
try {
|
|
4786
|
+
await saveLayout(state.layoutItems)
|
|
4787
|
+
} catch (error) {
|
|
4788
|
+
state.layoutItems = state.layoutItems.filter((candidate) => candidate.id !== id)
|
|
4789
|
+
throw error
|
|
4790
|
+
}
|
|
4791
|
+
renderCanvas()
|
|
4792
|
+
return id
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
async function discardFailedPendingOperation(operationId) {
|
|
4796
|
+
if (!operationId) return
|
|
4797
|
+
state.failedOperationIds.add(operationId)
|
|
4798
|
+
state.layoutItems = state.layoutItems.filter(
|
|
4799
|
+
(item) => !(isPending(item) && (item.id === operationId || item.operationId === operationId)),
|
|
4800
|
+
)
|
|
4801
|
+
state.selectedIds.delete(operationId)
|
|
4802
|
+
renderCanvas()
|
|
4803
|
+
renderChrome()
|
|
4804
|
+
|
|
4805
|
+
try {
|
|
4806
|
+
await api('/api/delete', {
|
|
4807
|
+
method: 'POST',
|
|
4808
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4809
|
+
body: JSON.stringify({ layout_ids: [operationId], image_ids: [] }),
|
|
4810
|
+
})
|
|
4811
|
+
await reload().catch(() => undefined)
|
|
4812
|
+
} catch (error) {
|
|
4813
|
+
console.warn('后端清理失败的载入节点失败', error)
|
|
4814
|
+
await saveLayout(state.layoutItems).catch((error) => {
|
|
4815
|
+
console.warn('保存失败节点清理结果失败', error)
|
|
4816
|
+
})
|
|
4817
|
+
}
|
|
4818
|
+
}
|
|
4634
4819
|
|
|
4635
4820
|
const INTERACTIVE_EDITOR_TRANSLATION = {
|
|
4636
4821
|
name: 'zh-CN-canvas',
|
|
@@ -4961,7 +5146,7 @@ function installInteractiveEditorInteractions(editor) {
|
|
|
4961
5146
|
}
|
|
4962
5147
|
|
|
4963
5148
|
function closeInteractiveEditModal(force = false) {
|
|
4964
|
-
if (!force &&
|
|
5149
|
+
if (!force && hasActiveOperations() && state.interactiveEditImageId) return
|
|
4965
5150
|
els.interactiveEditModal.hidden = true
|
|
4966
5151
|
state.interactiveEditImageId = null
|
|
4967
5152
|
destroyInteractiveEditor()
|
|
@@ -4992,14 +5177,14 @@ async function saveInteractiveEditToCanvas() {
|
|
|
4992
5177
|
const imageId = state.interactiveEditImageId
|
|
4993
5178
|
const node = state.layoutItems.find((item) => item.id === imageId)
|
|
4994
5179
|
const editor = interactiveEditor
|
|
4995
|
-
if (!imageId || !editor ||
|
|
5180
|
+
if (!imageId || !editor || hasActiveOperations()) return
|
|
4996
5181
|
if (!isPlainImageItem(node)) {
|
|
4997
5182
|
state.error = '请对普通图片使用交互编辑'
|
|
4998
5183
|
renderChrome()
|
|
4999
5184
|
return
|
|
5000
5185
|
}
|
|
5001
5186
|
|
|
5002
|
-
|
|
5187
|
+
const activeOperation = beginActiveOperation('interactive-edit-save')
|
|
5003
5188
|
state.error = ''
|
|
5004
5189
|
setInteractiveEditControlsDisabled(true)
|
|
5005
5190
|
els.interactiveEditStatus.hidden = false
|
|
@@ -5040,7 +5225,7 @@ async function saveInteractiveEditToCanvas() {
|
|
|
5040
5225
|
els.interactiveEditStatus.hidden = false
|
|
5041
5226
|
els.interactiveEditStatus.textContent = `保存编辑图片失败:${String(error.message || error)}`
|
|
5042
5227
|
} finally {
|
|
5043
|
-
|
|
5228
|
+
endActiveOperation(activeOperation)
|
|
5044
5229
|
if (!els.interactiveEditModal.hidden) setInteractiveEditControlsDisabled(false)
|
|
5045
5230
|
renderChrome()
|
|
5046
5231
|
}
|
|
@@ -5051,7 +5236,7 @@ async function confirmInteractiveEdit() {
|
|
|
5051
5236
|
const node = state.layoutItems.find((item) => item.id === imageId)
|
|
5052
5237
|
const prompt = els.interactiveEditPrompt.value.trim()
|
|
5053
5238
|
const editor = interactiveEditor
|
|
5054
|
-
if (!imageId || !editor ||
|
|
5239
|
+
if (!imageId || !editor || hasActiveOperations()) return
|
|
5055
5240
|
if (!isPlainImageItem(node)) {
|
|
5056
5241
|
state.error = '请对普通图片使用交互编辑'
|
|
5057
5242
|
renderChrome()
|
|
@@ -5078,7 +5263,7 @@ async function confirmInteractiveEdit() {
|
|
|
5078
5263
|
return
|
|
5079
5264
|
}
|
|
5080
5265
|
|
|
5081
|
-
|
|
5266
|
+
const activeOperation = beginActiveOperation('interactive-edit')
|
|
5082
5267
|
state.error = ''
|
|
5083
5268
|
setInteractiveEditControlsDisabled(true)
|
|
5084
5269
|
els.interactiveEditStatus.hidden = false
|
|
@@ -5107,16 +5292,12 @@ async function confirmInteractiveEdit() {
|
|
|
5107
5292
|
if (result.id) setSelection([result.id])
|
|
5108
5293
|
renderCanvas()
|
|
5109
5294
|
renderChrome()
|
|
5110
|
-
} catch (error) {
|
|
5111
|
-
state.error = String(error.message || error)
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
await saveLayout(state.layoutItems)
|
|
5115
|
-
}
|
|
5116
|
-
await reload().catch(() => undefined)
|
|
5117
|
-
renderChrome()
|
|
5295
|
+
} catch (error) {
|
|
5296
|
+
state.error = String(error.message || error)
|
|
5297
|
+
await discardFailedPendingOperation(operationId)
|
|
5298
|
+
renderChrome()
|
|
5118
5299
|
} finally {
|
|
5119
|
-
|
|
5300
|
+
endActiveOperation(activeOperation)
|
|
5120
5301
|
renderChrome()
|
|
5121
5302
|
}
|
|
5122
5303
|
}
|
|
@@ -5147,7 +5328,7 @@ function openLayerDecompositionModal(imageId) {
|
|
|
5147
5328
|
}
|
|
5148
5329
|
|
|
5149
5330
|
function closeLayerDecompositionModal(force = false) {
|
|
5150
|
-
if (!force &&
|
|
5331
|
+
if (!force && hasActiveOperations() && state.layerDecompositionImageId) return
|
|
5151
5332
|
els.layerDecompositionModal.hidden = true
|
|
5152
5333
|
state.layerDecompositionImageId = null
|
|
5153
5334
|
els.layerDecompositionImage.removeAttribute('src')
|
|
@@ -5156,7 +5337,7 @@ function closeLayerDecompositionModal(force = false) {
|
|
|
5156
5337
|
async function confirmLayerDecomposition() {
|
|
5157
5338
|
const imageId = state.layerDecompositionImageId
|
|
5158
5339
|
const prompt = els.layerDecompositionPrompt.value.trim()
|
|
5159
|
-
if (!imageId ||
|
|
5340
|
+
if (!imageId || hasActiveOperations()) return
|
|
5160
5341
|
if (!prompt) {
|
|
5161
5342
|
els.layerDecompositionStatus.hidden = false
|
|
5162
5343
|
els.layerDecompositionStatus.textContent = '请输入需要模型执行的图层拆分要求'
|
|
@@ -5171,7 +5352,7 @@ async function confirmLayerDecomposition() {
|
|
|
5171
5352
|
return
|
|
5172
5353
|
}
|
|
5173
5354
|
|
|
5174
|
-
|
|
5355
|
+
const activeOperation = beginActiveOperation('layer-decomposition')
|
|
5175
5356
|
state.error = ''
|
|
5176
5357
|
setLayerDecompositionControlsDisabled(true)
|
|
5177
5358
|
els.layerDecompositionStatus.hidden = false
|
|
@@ -5202,16 +5383,12 @@ async function confirmLayerDecomposition() {
|
|
|
5202
5383
|
setSelection(images.map((image) => image.id).filter(Boolean))
|
|
5203
5384
|
renderCanvas()
|
|
5204
5385
|
renderChrome()
|
|
5205
|
-
} catch (err) {
|
|
5206
|
-
state.error = String(err.message || err)
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
await saveLayout(state.layoutItems)
|
|
5210
|
-
}
|
|
5211
|
-
await reload().catch(() => undefined)
|
|
5212
|
-
renderChrome()
|
|
5386
|
+
} catch (err) {
|
|
5387
|
+
state.error = String(err.message || err)
|
|
5388
|
+
await discardFailedPendingOperation(operationId)
|
|
5389
|
+
renderChrome()
|
|
5213
5390
|
} finally {
|
|
5214
|
-
|
|
5391
|
+
endActiveOperation(activeOperation)
|
|
5215
5392
|
setLayerDecompositionControlsDisabled(false)
|
|
5216
5393
|
renderChrome()
|
|
5217
5394
|
}
|
|
@@ -5220,7 +5397,7 @@ async function confirmLayerDecomposition() {
|
|
|
5220
5397
|
function openFrameExtractModal(videoId) {
|
|
5221
5398
|
const item = state.layoutItems.find((it) => it.id === videoId)
|
|
5222
5399
|
if (!isVideoItem(item) && !String(videoId || '').startsWith('videos/')) {
|
|
5223
|
-
state.error = '
|
|
5400
|
+
state.error = '请对视频元素使用视频转序列帧图'
|
|
5224
5401
|
renderChrome()
|
|
5225
5402
|
return
|
|
5226
5403
|
}
|
|
@@ -5236,7 +5413,7 @@ function openFrameExtractModal(videoId) {
|
|
|
5236
5413
|
}
|
|
5237
5414
|
|
|
5238
5415
|
function closeFrameExtractModal() {
|
|
5239
|
-
if (
|
|
5416
|
+
if (hasActiveOperations() && state.frameExtractVideoId) return
|
|
5240
5417
|
els.frameExtractModal.hidden = true
|
|
5241
5418
|
state.frameExtractVideoId = null
|
|
5242
5419
|
}
|
|
@@ -5244,14 +5421,14 @@ function closeFrameExtractModal() {
|
|
|
5244
5421
|
function readFrameExtractCount() {
|
|
5245
5422
|
const n = Math.round(Number(els.frameExtractCount.value))
|
|
5246
5423
|
if (!Number.isFinite(n) || n < 2 || n > 30) {
|
|
5247
|
-
throw new Error('
|
|
5424
|
+
throw new Error('抽取帧数需在 2–30 之间')
|
|
5248
5425
|
}
|
|
5249
5426
|
return n
|
|
5250
5427
|
}
|
|
5251
5428
|
|
|
5252
5429
|
async function confirmFrameExtract() {
|
|
5253
5430
|
const videoId = state.frameExtractVideoId
|
|
5254
|
-
if (!videoId ||
|
|
5431
|
+
if (!videoId || hasActiveOperations()) return
|
|
5255
5432
|
let count
|
|
5256
5433
|
try {
|
|
5257
5434
|
count = readFrameExtractCount()
|
|
@@ -5262,19 +5439,20 @@ async function confirmFrameExtract() {
|
|
|
5262
5439
|
}
|
|
5263
5440
|
|
|
5264
5441
|
const node = state.layoutItems.find((it) => it.id === videoId)
|
|
5265
|
-
|
|
5442
|
+
const activeOperation = beginActiveOperation('video-frame-extract')
|
|
5266
5443
|
state.error = ''
|
|
5267
5444
|
els.frameExtractConfirm.disabled = true
|
|
5268
5445
|
els.frameExtractCancel.disabled = true
|
|
5269
5446
|
els.frameExtractClose.disabled = true
|
|
5270
5447
|
els.frameExtractCount.disabled = true
|
|
5271
5448
|
els.frameExtractStatus.hidden = false
|
|
5272
|
-
els.frameExtractStatus.textContent =
|
|
5449
|
+
els.frameExtractStatus.textContent = `正在抽取 ${count} 帧并拼接 PNG…`
|
|
5273
5450
|
renderChrome()
|
|
5274
5451
|
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5452
|
+
let operationId = ''
|
|
5453
|
+
try {
|
|
5454
|
+
operationId = await createProcessingPlaceholder(node, {
|
|
5455
|
+
name: '视频序列帧图生成中',
|
|
5278
5456
|
references: [videoId],
|
|
5279
5457
|
})
|
|
5280
5458
|
els.frameExtractModal.hidden = true
|
|
@@ -5285,18 +5463,19 @@ async function confirmFrameExtract() {
|
|
|
5285
5463
|
body: JSON.stringify({ id: videoId, count, client_operation_id: operationId }),
|
|
5286
5464
|
})
|
|
5287
5465
|
await reload()
|
|
5288
|
-
if (result.
|
|
5466
|
+
if (result.id) setSelection([result.id])
|
|
5289
5467
|
els.frameExtractModal.hidden = true
|
|
5290
5468
|
state.frameExtractVideoId = null
|
|
5291
5469
|
renderCanvas()
|
|
5292
5470
|
renderChrome()
|
|
5293
|
-
} catch (err) {
|
|
5294
|
-
state.error = String(err.message || err)
|
|
5295
|
-
|
|
5471
|
+
} catch (err) {
|
|
5472
|
+
state.error = String(err.message || err)
|
|
5473
|
+
await discardFailedPendingOperation(operationId)
|
|
5474
|
+
els.frameExtractStatus.hidden = false
|
|
5296
5475
|
els.frameExtractStatus.textContent = state.error
|
|
5297
5476
|
renderChrome()
|
|
5298
5477
|
} finally {
|
|
5299
|
-
|
|
5478
|
+
endActiveOperation(activeOperation)
|
|
5300
5479
|
els.frameExtractConfirm.disabled = false
|
|
5301
5480
|
els.frameExtractCancel.disabled = false
|
|
5302
5481
|
els.frameExtractClose.disabled = false
|
|
@@ -5305,6 +5484,291 @@ async function confirmFrameExtract() {
|
|
|
5305
5484
|
}
|
|
5306
5485
|
}
|
|
5307
5486
|
|
|
5487
|
+
function openFramePreviewModal(imageId) {
|
|
5488
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5489
|
+
if (!isPlainImageItem(item)) {
|
|
5490
|
+
state.error = '请对普通图片使用序列帧预览'
|
|
5491
|
+
renderChrome()
|
|
5492
|
+
return
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
const config = suggestedFramePreviewConfig(item)
|
|
5496
|
+
state.framePreviewImageId = imageId
|
|
5497
|
+
els.framePreviewRows.value = String(config.rows)
|
|
5498
|
+
els.framePreviewColumns.value = String(config.columns)
|
|
5499
|
+
els.framePreviewFps.value = String(config.fps)
|
|
5500
|
+
els.framePreviewStatus.hidden = true
|
|
5501
|
+
els.framePreviewStatus.textContent = ''
|
|
5502
|
+
els.framePreviewConfirm.disabled = false
|
|
5503
|
+
els.framePreviewModal.hidden = false
|
|
5504
|
+
updateFramePreviewBounds()
|
|
5505
|
+
els.framePreviewRows.focus()
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
function closeFramePreviewModal(force = false) {
|
|
5509
|
+
if (!force && els.framePreviewConfirm.disabled) return
|
|
5510
|
+
els.framePreviewModal.hidden = true
|
|
5511
|
+
state.framePreviewImageId = null
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5514
|
+
function updateFramePreviewBounds() {
|
|
5515
|
+
const rows = Number(els.framePreviewRows.value)
|
|
5516
|
+
const columns = Number(els.framePreviewColumns.value)
|
|
5517
|
+
const valid =
|
|
5518
|
+
Number.isInteger(rows) &&
|
|
5519
|
+
Number.isInteger(columns) &&
|
|
5520
|
+
rows >= 1 &&
|
|
5521
|
+
columns >= 1 &&
|
|
5522
|
+
rows <= 64 &&
|
|
5523
|
+
columns <= 64 &&
|
|
5524
|
+
rows * columns >= 2
|
|
5525
|
+
const frameCount = valid ? rows * columns : 0
|
|
5526
|
+
els.framePreviewInfo.textContent = valid
|
|
5527
|
+
? `网格 ${rows}×${columns} · 按从左到右、从上到下播放`
|
|
5528
|
+
: '请输入有效的行列数(至少两格)'
|
|
5529
|
+
els.framePreviewConfirm.disabled = !valid
|
|
5530
|
+
|
|
5531
|
+
const preview = els.framePreviewGrid
|
|
5532
|
+
preview.innerHTML = ''
|
|
5533
|
+
if (!valid) return
|
|
5534
|
+
preview.style.gridTemplateColumns = `repeat(${columns}, 18px)`
|
|
5535
|
+
preview.style.gridTemplateRows = `repeat(${rows}, 18px)`
|
|
5536
|
+
for (let index = 0; index < frameCount; index += 1) {
|
|
5537
|
+
const cell = document.createElement('div')
|
|
5538
|
+
cell.className = 'seq-sheet-cell is-filled'
|
|
5539
|
+
cell.textContent = String(index + 1)
|
|
5540
|
+
cell.title = `帧 ${index + 1}`
|
|
5541
|
+
preview.appendChild(cell)
|
|
5542
|
+
}
|
|
5543
|
+
}
|
|
5544
|
+
|
|
5545
|
+
function readFramePreviewConfig() {
|
|
5546
|
+
const rows = Number(els.framePreviewRows.value)
|
|
5547
|
+
const columns = Number(els.framePreviewColumns.value)
|
|
5548
|
+
const fps = Number(els.framePreviewFps.value)
|
|
5549
|
+
if (
|
|
5550
|
+
!Number.isInteger(rows) ||
|
|
5551
|
+
!Number.isInteger(columns) ||
|
|
5552
|
+
rows < 1 ||
|
|
5553
|
+
columns < 1 ||
|
|
5554
|
+
rows > 64 ||
|
|
5555
|
+
columns > 64 ||
|
|
5556
|
+
rows * columns < 2
|
|
5557
|
+
) {
|
|
5558
|
+
throw new Error('行列数需为 1–64 的整数,且至少两格')
|
|
5559
|
+
}
|
|
5560
|
+
const config = normalizeFramePreviewConfig({
|
|
5561
|
+
enabled: true,
|
|
5562
|
+
rows,
|
|
5563
|
+
columns,
|
|
5564
|
+
frameCount: rows * columns,
|
|
5565
|
+
fps,
|
|
5566
|
+
})
|
|
5567
|
+
if (!config) {
|
|
5568
|
+
throw new Error('行列数需为 1–64 的整数且至少两格,FPS 必须大于 0')
|
|
5569
|
+
}
|
|
5570
|
+
return config
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
function loadFramePreviewImage(imageId) {
|
|
5574
|
+
return new Promise((resolve, reject) => {
|
|
5575
|
+
const image = new Image()
|
|
5576
|
+
image.onload = () => resolve(image)
|
|
5577
|
+
image.onerror = () => reject(new Error('无法读取图片,不能检测透明空帧'))
|
|
5578
|
+
image.src = imageUrl(imageId)
|
|
5579
|
+
})
|
|
5580
|
+
}
|
|
5581
|
+
|
|
5582
|
+
async function detectFramePreviewCount(imageId, rows, columns) {
|
|
5583
|
+
const image = await loadFramePreviewImage(imageId)
|
|
5584
|
+
if (!image.naturalWidth || !image.naturalHeight) return rows * columns
|
|
5585
|
+
const canvas = document.createElement('canvas')
|
|
5586
|
+
|
|
5587
|
+
for (let index = rows * columns - 1; index >= 0; index -= 1) {
|
|
5588
|
+
const row = Math.floor(index / columns)
|
|
5589
|
+
const column = index % columns
|
|
5590
|
+
const left = Math.floor((column * image.naturalWidth) / columns)
|
|
5591
|
+
const right = Math.floor(((column + 1) * image.naturalWidth) / columns)
|
|
5592
|
+
const top = Math.floor((row * image.naturalHeight) / rows)
|
|
5593
|
+
const bottom = Math.floor(((row + 1) * image.naturalHeight) / rows)
|
|
5594
|
+
const width = Math.max(1, right - left)
|
|
5595
|
+
const height = Math.max(1, bottom - top)
|
|
5596
|
+
canvas.width = width
|
|
5597
|
+
canvas.height = height
|
|
5598
|
+
const currentContext = canvas.getContext('2d', { willReadFrequently: true })
|
|
5599
|
+
if (!currentContext) return rows * columns
|
|
5600
|
+
currentContext.clearRect(0, 0, width, height)
|
|
5601
|
+
currentContext.drawImage(image, left, top, width, height, 0, 0, width, height)
|
|
5602
|
+
const pixels = currentContext.getImageData(0, 0, width, height).data
|
|
5603
|
+
for (let offset = 3; offset < pixels.length; offset += 4) {
|
|
5604
|
+
if (pixels[offset] !== 0) return index + 1
|
|
5605
|
+
}
|
|
5606
|
+
}
|
|
5607
|
+
return 1
|
|
5608
|
+
}
|
|
5609
|
+
|
|
5610
|
+
async function confirmFramePreview() {
|
|
5611
|
+
const imageId = state.framePreviewImageId
|
|
5612
|
+
if (!imageId || els.framePreviewConfirm.disabled) return
|
|
5613
|
+
|
|
5614
|
+
let config
|
|
5615
|
+
try {
|
|
5616
|
+
config = readFramePreviewConfig()
|
|
5617
|
+
} catch (err) {
|
|
5618
|
+
els.framePreviewStatus.hidden = false
|
|
5619
|
+
els.framePreviewStatus.textContent = String(err.message || err)
|
|
5620
|
+
return
|
|
5621
|
+
}
|
|
5622
|
+
|
|
5623
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5624
|
+
if (!isPlainImageItem(item)) {
|
|
5625
|
+
closeFramePreviewModal(true)
|
|
5626
|
+
state.error = '图片不存在,无法开始序列帧预览'
|
|
5627
|
+
renderChrome()
|
|
5628
|
+
return
|
|
5629
|
+
}
|
|
5630
|
+
|
|
5631
|
+
els.framePreviewConfirm.disabled = true
|
|
5632
|
+
els.framePreviewClose.disabled = true
|
|
5633
|
+
els.framePreviewCancel.disabled = true
|
|
5634
|
+
try {
|
|
5635
|
+
els.framePreviewStatus.hidden = false
|
|
5636
|
+
els.framePreviewStatus.textContent = '正在检查末尾透明空帧…'
|
|
5637
|
+
const known =
|
|
5638
|
+
normalizeFramePreviewConfig(item.framePreview) || sourceFramePreviewConfig(item)
|
|
5639
|
+
const frameCount =
|
|
5640
|
+
known && known.rows === config.rows && known.columns === config.columns
|
|
5641
|
+
? known.frameCount
|
|
5642
|
+
: await detectFramePreviewCount(imageId, config.rows, config.columns)
|
|
5643
|
+
item.framePreview = { ...config, frameCount }
|
|
5644
|
+
state.frameSheetPlayback[imageId] = { index: 0, acc: 0 }
|
|
5645
|
+
await saveLayout()
|
|
5646
|
+
closeFramePreviewModal(true)
|
|
5647
|
+
renderCanvas()
|
|
5648
|
+
renderChrome()
|
|
5649
|
+
} catch (err) {
|
|
5650
|
+
els.framePreviewStatus.hidden = false
|
|
5651
|
+
els.framePreviewStatus.textContent = String(err.message || err)
|
|
5652
|
+
} finally {
|
|
5653
|
+
els.framePreviewConfirm.disabled = false
|
|
5654
|
+
els.framePreviewClose.disabled = false
|
|
5655
|
+
els.framePreviewCancel.disabled = false
|
|
5656
|
+
}
|
|
5657
|
+
}
|
|
5658
|
+
|
|
5659
|
+
async function stopFrameSheetPreview(imageId) {
|
|
5660
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5661
|
+
const config = normalizeFramePreviewConfig(item?.framePreview)
|
|
5662
|
+
if (!item || !config) return
|
|
5663
|
+
item.framePreview = { ...config, enabled: false }
|
|
5664
|
+
delete state.frameSheetPlayback[imageId]
|
|
5665
|
+
await saveLayout()
|
|
5666
|
+
renderCanvas()
|
|
5667
|
+
renderChrome()
|
|
5668
|
+
}
|
|
5669
|
+
|
|
5670
|
+
function openFrameEditorSplitModal(imageId) {
|
|
5671
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5672
|
+
if (!isPlainImageItem(item)) {
|
|
5673
|
+
state.error = '请对普通图片使用序列帧编辑'
|
|
5674
|
+
renderChrome()
|
|
5675
|
+
return
|
|
5676
|
+
}
|
|
5677
|
+
const preview = frameSheetPreviewMeta(item)
|
|
5678
|
+
state.frameEditorSourceImageId = imageId
|
|
5679
|
+
els.frameEditorSplitRows.value = String(preview?.rows || 2)
|
|
5680
|
+
els.frameEditorSplitColumns.value = String(preview?.columns || 2)
|
|
5681
|
+
els.frameEditorSplitStatus.hidden = true
|
|
5682
|
+
els.frameEditorSplitStatus.textContent = ''
|
|
5683
|
+
els.frameEditorSplitModal.hidden = false
|
|
5684
|
+
updateFrameEditorSplitInfo()
|
|
5685
|
+
els.frameEditorSplitRows.focus()
|
|
5686
|
+
}
|
|
5687
|
+
|
|
5688
|
+
function closeFrameEditorSplitModal(_force = false) {
|
|
5689
|
+
els.frameEditorSplitModal.hidden = true
|
|
5690
|
+
state.frameEditorSourceImageId = null
|
|
5691
|
+
}
|
|
5692
|
+
|
|
5693
|
+
function readFrameEditorSplitGrid() {
|
|
5694
|
+
const rows = Number(els.frameEditorSplitRows.value)
|
|
5695
|
+
const columns = Number(els.frameEditorSplitColumns.value)
|
|
5696
|
+
if (
|
|
5697
|
+
!Number.isInteger(rows) ||
|
|
5698
|
+
!Number.isInteger(columns) ||
|
|
5699
|
+
rows < 1 ||
|
|
5700
|
+
columns < 1 ||
|
|
5701
|
+
rows > 64 ||
|
|
5702
|
+
columns > 64
|
|
5703
|
+
) {
|
|
5704
|
+
throw new Error('行数和列数必须是 1–64 的整数')
|
|
5705
|
+
}
|
|
5706
|
+
return { rows, columns }
|
|
5707
|
+
}
|
|
5708
|
+
|
|
5709
|
+
function updateFrameEditorSplitInfo() {
|
|
5710
|
+
try {
|
|
5711
|
+
const { rows, columns } = readFrameEditorSplitGrid()
|
|
5712
|
+
els.frameEditorSplitInfo.textContent = `将生成 ${rows * columns} 个可编辑帧格;切分允许 1×1。`
|
|
5713
|
+
els.frameEditorSplitConfirm.disabled = false
|
|
5714
|
+
} catch (err) {
|
|
5715
|
+
els.frameEditorSplitInfo.textContent = String(err.message || err)
|
|
5716
|
+
els.frameEditorSplitConfirm.disabled = true
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
|
|
5720
|
+
async function confirmFrameEditorSplit() {
|
|
5721
|
+
const sourceId = state.frameEditorSourceImageId
|
|
5722
|
+
if (!sourceId || els.frameEditorSplitConfirm.disabled) return
|
|
5723
|
+
let grid
|
|
5724
|
+
try {
|
|
5725
|
+
grid = readFrameEditorSplitGrid()
|
|
5726
|
+
} catch (err) {
|
|
5727
|
+
els.frameEditorSplitStatus.hidden = false
|
|
5728
|
+
els.frameEditorSplitStatus.textContent = String(err.message || err)
|
|
5729
|
+
return
|
|
5730
|
+
}
|
|
5731
|
+
|
|
5732
|
+
const source = state.layoutItems.find((candidate) => candidate.id === sourceId)
|
|
5733
|
+
if (!isPlainImageItem(source)) {
|
|
5734
|
+
closeFrameEditorSplitModal(true)
|
|
5735
|
+
state.error = '原始图片不存在,无法创建序列帧编辑器'
|
|
5736
|
+
renderChrome()
|
|
5737
|
+
return
|
|
5738
|
+
}
|
|
5739
|
+
const preview = frameSheetPreviewMeta(source)
|
|
5740
|
+
const usesPreviewGrid = preview?.rows === grid.rows && preview?.columns === grid.columns
|
|
5741
|
+
const frameCount = usesPreviewGrid ? preview.frameCount : grid.rows * grid.columns
|
|
5742
|
+
const fps = preview?.fps || 8
|
|
5743
|
+
const editorId = createCanvasClientId('frameeditor')
|
|
5744
|
+
const width = source.width || DEFAULT_W
|
|
5745
|
+
state.layoutItems.push({
|
|
5746
|
+
id: editorId,
|
|
5747
|
+
type: 'frame_editor',
|
|
5748
|
+
name: '序列帧编辑',
|
|
5749
|
+
x: (Number(source.x) || 0) + width + 60,
|
|
5750
|
+
y: Number(source.y) || 0,
|
|
5751
|
+
width: FRAME_EDITOR_W,
|
|
5752
|
+
height: null,
|
|
5753
|
+
sourceId,
|
|
5754
|
+
sourceRows: grid.rows,
|
|
5755
|
+
sourceColumns: grid.columns,
|
|
5756
|
+
rows: grid.rows,
|
|
5757
|
+
columns: grid.columns,
|
|
5758
|
+
fps,
|
|
5759
|
+
frames: Array.from({ length: frameCount }, (_, index) => ({
|
|
5760
|
+
kind: 'source_cell',
|
|
5761
|
+
index,
|
|
5762
|
+
})),
|
|
5763
|
+
error: null,
|
|
5764
|
+
})
|
|
5765
|
+
await saveLayout()
|
|
5766
|
+
closeFrameEditorSplitModal(true)
|
|
5767
|
+
setSelection([editorId])
|
|
5768
|
+
renderCanvas()
|
|
5769
|
+
renderChrome()
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5308
5772
|
function selectSmartSplitAnchor(anchor) {
|
|
5309
5773
|
state.smartSplitAnchor = anchor
|
|
5310
5774
|
for (const button of els.smartSplitAnchorGrid.querySelectorAll('button[data-anchor]')) {
|
|
@@ -5325,7 +5789,7 @@ function setSmartSplitControlsDisabled(disabled) {
|
|
|
5325
5789
|
|
|
5326
5790
|
function openSmartSplitModal(imageId) {
|
|
5327
5791
|
const node = state.layoutItems.find((it) => it.id === imageId)
|
|
5328
|
-
if (!node
|
|
5792
|
+
if (!node) {
|
|
5329
5793
|
state.error = '请对普通图片使用智能切分'
|
|
5330
5794
|
renderChrome()
|
|
5331
5795
|
return
|
|
@@ -5345,25 +5809,26 @@ function closeSmartSplitModal() {
|
|
|
5345
5809
|
|
|
5346
5810
|
async function confirmSmartSplit() {
|
|
5347
5811
|
const imageId = state.smartSplitImageId
|
|
5348
|
-
if (!imageId ||
|
|
5812
|
+
if (!imageId || hasActiveOperations()) return
|
|
5349
5813
|
await runSmartSplit(imageId, state.smartSplitAnchor)
|
|
5350
5814
|
}
|
|
5351
5815
|
|
|
5352
5816
|
async function runSmartSplit(imageId, anchor = 'center') {
|
|
5353
5817
|
const node = state.layoutItems.find((it) => it.id === imageId)
|
|
5354
|
-
if (
|
|
5818
|
+
if (!node || isVideoItem(node) || isPending(node)) {
|
|
5355
5819
|
state.error = '请对普通图片使用智能切分'
|
|
5356
5820
|
renderChrome()
|
|
5357
5821
|
return
|
|
5358
5822
|
}
|
|
5359
|
-
|
|
5823
|
+
const activeOperation = beginActiveOperation('smart-split')
|
|
5360
5824
|
state.error = ''
|
|
5361
5825
|
setSmartSplitControlsDisabled(true)
|
|
5362
5826
|
els.smartSplitStatus.hidden = false
|
|
5363
5827
|
els.smartSplitStatus.textContent = '正在识别主体并统一尺寸…'
|
|
5364
5828
|
renderChrome()
|
|
5365
|
-
|
|
5366
|
-
|
|
5829
|
+
let operationId = ''
|
|
5830
|
+
try {
|
|
5831
|
+
operationId = await createProcessingPlaceholder(node, {
|
|
5367
5832
|
name: '智能切分中',
|
|
5368
5833
|
references: [imageId],
|
|
5369
5834
|
})
|
|
@@ -5382,13 +5847,14 @@ async function runSmartSplit(imageId, anchor = 'center') {
|
|
|
5382
5847
|
state.smartSplitImageId = null
|
|
5383
5848
|
renderCanvas()
|
|
5384
5849
|
renderChrome()
|
|
5385
|
-
} catch (err) {
|
|
5386
|
-
state.error = String(err.message || err)
|
|
5387
|
-
|
|
5850
|
+
} catch (err) {
|
|
5851
|
+
state.error = String(err.message || err)
|
|
5852
|
+
await discardFailedPendingOperation(operationId)
|
|
5853
|
+
els.smartSplitStatus.hidden = false
|
|
5388
5854
|
els.smartSplitStatus.textContent = state.error
|
|
5389
5855
|
renderChrome()
|
|
5390
5856
|
} finally {
|
|
5391
|
-
|
|
5857
|
+
endActiveOperation(activeOperation)
|
|
5392
5858
|
setSmartSplitControlsDisabled(false)
|
|
5393
5859
|
renderChrome()
|
|
5394
5860
|
}
|
|
@@ -5417,7 +5883,7 @@ function updateManualSplitPreview() {
|
|
|
5417
5883
|
els.manualSplitInfo.textContent = valid
|
|
5418
5884
|
? `网格 ${rows}×${columns} · 将生成 ${count} 张图片`
|
|
5419
5885
|
: '请输入有效的行列数(至少两格)'
|
|
5420
|
-
els.manualSplitConfirm.disabled = !valid ||
|
|
5886
|
+
els.manualSplitConfirm.disabled = !valid || hasActiveOperations()
|
|
5421
5887
|
|
|
5422
5888
|
const preview = els.manualSplitPreview
|
|
5423
5889
|
preview.innerHTML = ''
|
|
@@ -5457,14 +5923,14 @@ function openManualSplitModal(imageId) {
|
|
|
5457
5923
|
}
|
|
5458
5924
|
|
|
5459
5925
|
function closeManualSplitModal() {
|
|
5460
|
-
if (
|
|
5926
|
+
if (hasActiveOperations() && state.manualSplitImageId) return
|
|
5461
5927
|
els.manualSplitModal.hidden = true
|
|
5462
5928
|
state.manualSplitImageId = null
|
|
5463
5929
|
}
|
|
5464
5930
|
|
|
5465
5931
|
async function confirmManualSplit() {
|
|
5466
5932
|
const imageId = state.manualSplitImageId
|
|
5467
|
-
if (!imageId ||
|
|
5933
|
+
if (!imageId || hasActiveOperations()) return
|
|
5468
5934
|
let grid
|
|
5469
5935
|
try {
|
|
5470
5936
|
grid = readManualSplitGrid()
|
|
@@ -5475,7 +5941,7 @@ async function confirmManualSplit() {
|
|
|
5475
5941
|
}
|
|
5476
5942
|
|
|
5477
5943
|
const node = state.layoutItems.find((item) => item.id === imageId)
|
|
5478
|
-
|
|
5944
|
+
const activeOperation = beginActiveOperation('manual-split')
|
|
5479
5945
|
state.error = ''
|
|
5480
5946
|
els.manualSplitConfirm.disabled = true
|
|
5481
5947
|
els.manualSplitClose.disabled = true
|
|
@@ -5484,8 +5950,9 @@ async function confirmManualSplit() {
|
|
|
5484
5950
|
els.manualSplitCols.disabled = true
|
|
5485
5951
|
renderChrome()
|
|
5486
5952
|
|
|
5487
|
-
|
|
5488
|
-
|
|
5953
|
+
let operationId = ''
|
|
5954
|
+
try {
|
|
5955
|
+
operationId = await createProcessingPlaceholder(node, {
|
|
5489
5956
|
name: '手动切分中',
|
|
5490
5957
|
references: [imageId],
|
|
5491
5958
|
})
|
|
@@ -5509,13 +5976,14 @@ async function confirmManualSplit() {
|
|
|
5509
5976
|
setSelection(images.map((image) => image.id))
|
|
5510
5977
|
renderCanvas()
|
|
5511
5978
|
renderChrome()
|
|
5512
|
-
} catch (err) {
|
|
5513
|
-
state.error = String(err.message || err)
|
|
5514
|
-
|
|
5979
|
+
} catch (err) {
|
|
5980
|
+
state.error = String(err.message || err)
|
|
5981
|
+
await discardFailedPendingOperation(operationId)
|
|
5982
|
+
els.manualSplitStatus.hidden = false
|
|
5515
5983
|
els.manualSplitStatus.textContent = state.error
|
|
5516
5984
|
renderChrome()
|
|
5517
5985
|
} finally {
|
|
5518
|
-
|
|
5986
|
+
endActiveOperation(activeOperation)
|
|
5519
5987
|
els.manualSplitConfirm.disabled = false
|
|
5520
5988
|
els.manualSplitClose.disabled = false
|
|
5521
5989
|
els.manualSplitCancel.disabled = false
|
|
@@ -5526,244 +5994,54 @@ async function confirmManualSplit() {
|
|
|
5526
5994
|
}
|
|
5527
5995
|
}
|
|
5528
5996
|
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
if (!
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
ok ? '' : cells < frameCount ? '(不足)' : ''
|
|
5551
|
-
}`
|
|
5552
|
-
: '共 0 帧'
|
|
5553
|
-
els.seqSheetConfirm.disabled = !ok || state.busy
|
|
5554
|
-
|
|
5555
|
-
const preview = els.seqSheetPreview
|
|
5556
|
-
preview.innerHTML = ''
|
|
5557
|
-
if (!(rows >= 1 && cols >= 1 && rows <= 64 && cols <= 64)) return
|
|
5558
|
-
preview.style.gridTemplateColumns = `repeat(${cols}, 18px)`
|
|
5559
|
-
preview.style.gridTemplateRows = `repeat(${rows}, 18px)`
|
|
5560
|
-
// 行优先:左→右,再换行
|
|
5561
|
-
for (let row = 0; row < rows; row++) {
|
|
5562
|
-
for (let col = 0; col < cols; col++) {
|
|
5563
|
-
const index = row * cols + col
|
|
5564
|
-
const cell = document.createElement('div')
|
|
5565
|
-
cell.className = `seq-sheet-cell${index < frameCount ? ' is-filled' : ''}`
|
|
5566
|
-
cell.textContent = index < frameCount ? String(index + 1) : ''
|
|
5567
|
-
cell.title = index < frameCount ? `帧 ${index + 1}` : '透明空位'
|
|
5568
|
-
preview.appendChild(cell)
|
|
5997
|
+
let lastFrameSheetPreviewTimestamp = 0
|
|
5998
|
+
|
|
5999
|
+
function tickFrameSheetPreviews(timestamp) {
|
|
6000
|
+
if (!lastFrameSheetPreviewTimestamp) lastFrameSheetPreviewTimestamp = timestamp
|
|
6001
|
+
const deltaSeconds = Math.min(0.25, Math.max(0, timestamp - lastFrameSheetPreviewTimestamp) / 1000)
|
|
6002
|
+
lastFrameSheetPreviewTimestamp = timestamp
|
|
6003
|
+
|
|
6004
|
+
for (const preview of els.world.querySelectorAll('.frame-sheet-preview[data-asset-id]')) {
|
|
6005
|
+
const assetId = preview.dataset.assetId
|
|
6006
|
+
const item = state.layoutItems.find((candidate) => candidate.id === assetId)
|
|
6007
|
+
const meta = frameSheetPreviewMeta(item)
|
|
6008
|
+
if (!meta || meta.frameCount < 2) continue
|
|
6009
|
+
const play = state.frameSheetPlayback[assetId] || { index: 0, acc: 0 }
|
|
6010
|
+
play.acc += deltaSeconds
|
|
6011
|
+
const frameDuration = 1 / meta.fps
|
|
6012
|
+
let changed = false
|
|
6013
|
+
const elapsedFrames = Math.floor(play.acc / frameDuration)
|
|
6014
|
+
if (elapsedFrames > 0) {
|
|
6015
|
+
play.acc -= elapsedFrames * frameDuration
|
|
6016
|
+
play.index = (play.index + elapsedFrames) % meta.frameCount
|
|
6017
|
+
changed = true
|
|
5569
6018
|
}
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
function openSeqSheetModal(seqId) {
|
|
5574
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
5575
|
-
if (!isSequence(item) || !item.frames.length) {
|
|
5576
|
-
state.error = '请对含帧的序列使用拼成单图'
|
|
5577
|
-
renderChrome()
|
|
5578
|
-
return
|
|
5579
|
-
}
|
|
5580
|
-
state.seqSheetId = seqId
|
|
5581
|
-
const { rows, cols } = suggestSheetGrid(item.frames.length)
|
|
5582
|
-
els.seqSheetRows.value = String(rows)
|
|
5583
|
-
els.seqSheetCols.value = String(cols)
|
|
5584
|
-
els.seqSheetStatus.hidden = true
|
|
5585
|
-
els.seqSheetStatus.textContent = ''
|
|
5586
|
-
els.seqSheetCancel.disabled = false
|
|
5587
|
-
els.seqSheetClose.disabled = false
|
|
5588
|
-
els.seqSheetRows.disabled = false
|
|
5589
|
-
els.seqSheetCols.disabled = false
|
|
5590
|
-
els.seqSheetModal.hidden = false
|
|
5591
|
-
updateSeqSheetPreview()
|
|
5592
|
-
}
|
|
5593
|
-
|
|
5594
|
-
function closeSeqSheetModal() {
|
|
5595
|
-
if (state.busy && state.seqSheetId) return
|
|
5596
|
-
els.seqSheetModal.hidden = true
|
|
5597
|
-
state.seqSheetId = null
|
|
5598
|
-
}
|
|
5599
|
-
|
|
5600
|
-
async function confirmSeqSheet() {
|
|
5601
|
-
const seqId = state.seqSheetId
|
|
5602
|
-
if (!seqId || state.busy) return
|
|
5603
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
5604
|
-
if (!isSequence(item) || !item.frames.length) {
|
|
5605
|
-
els.seqSheetStatus.hidden = false
|
|
5606
|
-
els.seqSheetStatus.textContent = '序列无效'
|
|
5607
|
-
return
|
|
6019
|
+
state.frameSheetPlayback[assetId] = play
|
|
6020
|
+
if (changed) applyFrameSheetPreviewFrame(preview, meta, play.index)
|
|
5608
6021
|
}
|
|
5609
6022
|
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
6023
|
+
for (const preview of els.world.querySelectorAll('.frame-editor-preview-stage[data-editor-id]')) {
|
|
6024
|
+
const editorId = preview.dataset.editorId
|
|
6025
|
+
const editor = normalizeFrameEditor(
|
|
6026
|
+
state.layoutItems.find((candidate) => candidate.id === editorId),
|
|
6027
|
+
)
|
|
6028
|
+
if (!editor || editor.frames.length < 2) continue
|
|
6029
|
+
const play = state.frameSheetPlayback[editorId] || { index: 0, acc: 0 }
|
|
6030
|
+
play.acc += deltaSeconds
|
|
6031
|
+
const frameDuration = 1 / editor.fps
|
|
6032
|
+
const elapsedFrames = Math.floor(play.acc / frameDuration)
|
|
6033
|
+
if (elapsedFrames > 0) {
|
|
6034
|
+
play.acc -= elapsedFrames * frameDuration
|
|
6035
|
+
play.index = (play.index + elapsedFrames) % editor.frames.length
|
|
6036
|
+
applyFrameEditorPreviewFrame(preview, editor, play.index)
|
|
5615
6037
|
}
|
|
5616
|
-
|
|
5617
|
-
els.seqSheetStatus.hidden = false
|
|
5618
|
-
els.seqSheetStatus.textContent = String(err.message || err)
|
|
5619
|
-
return
|
|
5620
|
-
}
|
|
5621
|
-
|
|
5622
|
-
state.busy = true
|
|
5623
|
-
state.error = ''
|
|
5624
|
-
els.seqSheetConfirm.disabled = true
|
|
5625
|
-
els.seqSheetCancel.disabled = true
|
|
5626
|
-
els.seqSheetClose.disabled = true
|
|
5627
|
-
els.seqSheetRows.disabled = true
|
|
5628
|
-
els.seqSheetCols.disabled = true
|
|
5629
|
-
els.seqSheetStatus.hidden = false
|
|
5630
|
-
els.seqSheetStatus.textContent = '正在拼接…'
|
|
5631
|
-
renderChrome()
|
|
5632
|
-
|
|
5633
|
-
try {
|
|
5634
|
-
const operationId = await createProcessingPlaceholder(item, {
|
|
5635
|
-
name: '序列拼图中',
|
|
5636
|
-
references: item.frames,
|
|
5637
|
-
})
|
|
5638
|
-
els.seqSheetModal.hidden = true
|
|
5639
|
-
state.seqSheetId = null
|
|
5640
|
-
const uploaded = await api('/api/sequence-sheet', {
|
|
5641
|
-
method: 'POST',
|
|
5642
|
-
headers: { 'Content-Type': 'application/json' },
|
|
5643
|
-
body: JSON.stringify({
|
|
5644
|
-
frame_ids: item.frames,
|
|
5645
|
-
rows: grid.rows,
|
|
5646
|
-
columns: grid.cols,
|
|
5647
|
-
client_operation_id: operationId,
|
|
5648
|
-
}),
|
|
5649
|
-
})
|
|
5650
|
-
const imageId = uploaded.id
|
|
5651
|
-
if (!imageId) throw new Error('序列拼图没有产物')
|
|
5652
|
-
await reload()
|
|
5653
|
-
setSelection([imageId])
|
|
5654
|
-
closeSeqSheetModal()
|
|
5655
|
-
renderCanvas()
|
|
5656
|
-
renderChrome()
|
|
5657
|
-
} catch (err) {
|
|
5658
|
-
state.error = String(err.message || err)
|
|
5659
|
-
els.seqSheetStatus.hidden = false
|
|
5660
|
-
els.seqSheetStatus.textContent = state.error
|
|
5661
|
-
renderChrome()
|
|
5662
|
-
} finally {
|
|
5663
|
-
state.busy = false
|
|
5664
|
-
els.seqSheetConfirm.disabled = false
|
|
5665
|
-
els.seqSheetCancel.disabled = false
|
|
5666
|
-
els.seqSheetClose.disabled = false
|
|
5667
|
-
els.seqSheetRows.disabled = false
|
|
5668
|
-
els.seqSheetCols.disabled = false
|
|
5669
|
-
updateSeqSheetPreview()
|
|
5670
|
-
renderChrome()
|
|
5671
|
-
}
|
|
5672
|
-
}
|
|
5673
|
-
|
|
5674
|
-
async function ungroupSequence(seqId) {
|
|
5675
|
-
const item = state.layoutItems.find((it) => it.id === seqId)
|
|
5676
|
-
if (!isSequence(item)) return
|
|
5677
|
-
const singles = item.frames.map((fid, i) => ({
|
|
5678
|
-
id: fid,
|
|
5679
|
-
x: (item.x || 0) + (i % 4) * 40,
|
|
5680
|
-
y: (item.y || 0) + Math.floor(i / 4) * 40,
|
|
5681
|
-
width: item.width || DEFAULT_W,
|
|
5682
|
-
height: null,
|
|
5683
|
-
}))
|
|
5684
|
-
state.layoutItems = [
|
|
5685
|
-
...state.layoutItems.filter((it) => it.id !== seqId),
|
|
5686
|
-
...singles,
|
|
5687
|
-
]
|
|
5688
|
-
delete state.playback[seqId]
|
|
5689
|
-
closeSeqModal()
|
|
5690
|
-
await saveLayout(state.layoutItems)
|
|
5691
|
-
await reload()
|
|
5692
|
-
}
|
|
5693
|
-
|
|
5694
|
-
async function combineImagesAsSequence(imageIds) {
|
|
5695
|
-
const ids = [...new Set((imageIds || []).filter(Boolean))]
|
|
5696
|
-
if (ids.length < 2) return
|
|
5697
|
-
const images = ids
|
|
5698
|
-
.map((id) => state.layoutItems.find((item) => item.id === id))
|
|
5699
|
-
.filter(Boolean)
|
|
5700
|
-
if (images.length !== ids.length || !images.every(isPlainImageItem)) {
|
|
5701
|
-
state.error = '只有全部选中项都是普通图片时,才能组合为序列帧'
|
|
5702
|
-
renderChrome()
|
|
5703
|
-
return
|
|
6038
|
+
state.frameSheetPlayback[editorId] = play
|
|
5704
6039
|
}
|
|
5705
6040
|
|
|
5706
|
-
|
|
5707
|
-
(a, b) =>
|
|
5708
|
-
(Number(a.y) || 0) - (Number(b.y) || 0) ||
|
|
5709
|
-
(Number(a.x) || 0) - (Number(b.x) || 0),
|
|
5710
|
-
)
|
|
5711
|
-
const selected = new Set(images.map((item) => item.id))
|
|
5712
|
-
const seqId = `seq_${Date.now().toString(36)}`
|
|
5713
|
-
const sequence = {
|
|
5714
|
-
id: seqId,
|
|
5715
|
-
type: 'sequence',
|
|
5716
|
-
name: `序列 · ${images.length} 帧`,
|
|
5717
|
-
x: Math.min(...images.map((item) => Number(item.x) || 0)),
|
|
5718
|
-
y: Math.min(...images.map((item) => Number(item.y) || 0)),
|
|
5719
|
-
width: images[0].width || DEFAULT_W,
|
|
5720
|
-
height: null,
|
|
5721
|
-
frames: images.map((item) => item.id),
|
|
5722
|
-
fps: 8,
|
|
5723
|
-
loop: true,
|
|
5724
|
-
}
|
|
5725
|
-
state.layoutItems = [
|
|
5726
|
-
...state.layoutItems.filter((item) => !selected.has(item.id)),
|
|
5727
|
-
sequence,
|
|
5728
|
-
]
|
|
5729
|
-
state.playback[seqId] = { index: 0, acc: 0 }
|
|
5730
|
-
await saveLayout(state.layoutItems)
|
|
5731
|
-
await reload()
|
|
5732
|
-
setSelection([seqId])
|
|
5733
|
-
renderCanvas()
|
|
5734
|
-
renderChrome()
|
|
6041
|
+
requestAnimationFrame(tickFrameSheetPreviews)
|
|
5735
6042
|
}
|
|
5736
6043
|
|
|
5737
|
-
|
|
5738
|
-
function tickPlayback(ts) {
|
|
5739
|
-
if (!lastTs) lastTs = ts
|
|
5740
|
-
const dt = (ts - lastTs) / 1000
|
|
5741
|
-
lastTs = ts
|
|
5742
|
-
let dirty = false
|
|
5743
|
-
for (const item of state.layoutItems) {
|
|
5744
|
-
if (!isSequence(item) || item.frames.length < 2) continue
|
|
5745
|
-
const fps = Number(item.fps) > 0 ? Number(item.fps) : 8
|
|
5746
|
-
const play = state.playback[item.id] || { index: 0, acc: 0 }
|
|
5747
|
-
play.acc += dt
|
|
5748
|
-
const step = 1 / fps
|
|
5749
|
-
while (play.acc >= step) {
|
|
5750
|
-
play.acc -= step
|
|
5751
|
-
if (item.loop === false) {
|
|
5752
|
-
if (play.index < item.frames.length - 1) {
|
|
5753
|
-
play.index += 1
|
|
5754
|
-
dirty = true
|
|
5755
|
-
}
|
|
5756
|
-
} else {
|
|
5757
|
-
play.index = (play.index + 1) % item.frames.length
|
|
5758
|
-
dirty = true
|
|
5759
|
-
}
|
|
5760
|
-
}
|
|
5761
|
-
state.playback[item.id] = play
|
|
5762
|
-
}
|
|
5763
|
-
if (dirty) updateSequenceFramesOnly()
|
|
5764
|
-
requestAnimationFrame(tickPlayback)
|
|
5765
|
-
}
|
|
5766
|
-
requestAnimationFrame(tickPlayback)
|
|
6044
|
+
requestAnimationFrame(tickFrameSheetPreviews)
|
|
5767
6045
|
|
|
5768
6046
|
const SLOW_ASSET_SYNC_INTERVAL_MS = 5 * 60 * 1000
|
|
5769
6047
|
|
|
@@ -5776,13 +6054,13 @@ function scheduleSlowAssetSync() {
|
|
|
5776
6054
|
|
|
5777
6055
|
async function runCutout(imageId) {
|
|
5778
6056
|
const source = state.layoutItems.find((item) => item.id === imageId)
|
|
5779
|
-
if (!source ||
|
|
6057
|
+
if (!source || isPending(source) || isVideoItem(source)) {
|
|
5780
6058
|
state.error = '请对普通图片使用抠图'
|
|
5781
6059
|
renderChrome()
|
|
5782
6060
|
return
|
|
5783
6061
|
}
|
|
5784
6062
|
|
|
5785
|
-
const pendingId =
|
|
6063
|
+
const pendingId = createCanvasClientId('job_cutout')
|
|
5786
6064
|
const pending = {
|
|
5787
6065
|
id: pendingId,
|
|
5788
6066
|
type: 'pending',
|
|
@@ -5794,14 +6072,13 @@ async function runCutout(imageId) {
|
|
|
5794
6072
|
references: [{ id: imageId, role: 'source' }],
|
|
5795
6073
|
}
|
|
5796
6074
|
state.layoutItems.push(pending)
|
|
5797
|
-
|
|
6075
|
+
const activeOperation = beginActiveOperation('cutout')
|
|
5798
6076
|
state.error = ''
|
|
5799
6077
|
setSelection([pendingId])
|
|
5800
|
-
await saveLayout(state.layoutItems)
|
|
5801
|
-
renderCanvas()
|
|
5802
|
-
renderChrome()
|
|
5803
|
-
|
|
5804
6078
|
try {
|
|
6079
|
+
await saveLayout(state.layoutItems)
|
|
6080
|
+
renderCanvas()
|
|
6081
|
+
renderChrome()
|
|
5805
6082
|
const result = await api('/api/cutout', {
|
|
5806
6083
|
method: 'POST',
|
|
5807
6084
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -5810,27 +6087,13 @@ async function runCutout(imageId) {
|
|
|
5810
6087
|
client_operation_id: pendingId,
|
|
5811
6088
|
}),
|
|
5812
6089
|
})
|
|
5813
|
-
state.layoutItems = state.layoutItems.map((item) =>
|
|
5814
|
-
item.id === pendingId
|
|
5815
|
-
? {
|
|
5816
|
-
id: result.id,
|
|
5817
|
-
x: item.x,
|
|
5818
|
-
y: item.y,
|
|
5819
|
-
width: item.width,
|
|
5820
|
-
height: item.height,
|
|
5821
|
-
}
|
|
5822
|
-
: item,
|
|
5823
|
-
)
|
|
5824
|
-
await saveLayout(state.layoutItems)
|
|
5825
6090
|
await reload()
|
|
5826
6091
|
setSelection([result.id])
|
|
5827
|
-
} catch (err) {
|
|
5828
|
-
state.error = String(err.message || err)
|
|
5829
|
-
|
|
5830
|
-
await saveLayout(state.layoutItems)
|
|
5831
|
-
await reload()
|
|
6092
|
+
} catch (err) {
|
|
6093
|
+
state.error = String(err.message || err)
|
|
6094
|
+
await discardFailedPendingOperation(pendingId)
|
|
5832
6095
|
} finally {
|
|
5833
|
-
|
|
6096
|
+
endActiveOperation(activeOperation)
|
|
5834
6097
|
renderCanvas()
|
|
5835
6098
|
renderChrome()
|
|
5836
6099
|
}
|