evomeme 0.1.7-beta.1 → 0.1.7-beta.5
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 +1566 -1353
- 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 +810 -517
- package/package.json +1 -1
- 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,13 @@ 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(),
|
|
21
22
|
deleting: false,
|
|
22
23
|
refreshing: false,
|
|
23
24
|
externalSyncPending: false,
|
|
@@ -33,15 +34,42 @@ const state = {
|
|
|
33
34
|
smartSplitAnchor: 'center',
|
|
34
35
|
manualSplitImageId: null,
|
|
35
36
|
frameExtractVideoId: null,
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
framePreviewImageId: null,
|
|
38
|
+
frameEditorSourceImageId: null,
|
|
38
39
|
videoCycleId: null,
|
|
39
40
|
videoCycleAbort: null,
|
|
40
|
-
|
|
41
|
-
playback: {}, // seqId -> { index, acc }
|
|
41
|
+
frameSheetPlayback: {}, // canvas item id -> { index, acc }
|
|
42
42
|
imageDims: {}, // id -> { w, h } | { loading: true }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function hasActiveOperations() {
|
|
46
|
+
return state.activeOperations.size > 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createCanvasClientId(prefix) {
|
|
50
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function beginActiveOperation(kind) {
|
|
54
|
+
const token = createCanvasClientId(kind || 'canvas')
|
|
55
|
+
state.activeOperations.add(token)
|
|
56
|
+
return token
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function endActiveOperation(token) {
|
|
60
|
+
if (token) state.activeOperations.delete(token)
|
|
61
|
+
if (!hasActiveOperations() && state.externalSyncPending && !shouldDeferExternalSync()) {
|
|
62
|
+
setTimeout(() => syncExternalChanges(), 0)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let layoutSaveQueue = Promise.resolve()
|
|
67
|
+
let layoutSaveSequence = 0
|
|
68
|
+
let layoutSaveEpoch = 0
|
|
69
|
+
let layoutPendingSaveCount = 0
|
|
70
|
+
let layoutNextExpectedRevision = 0
|
|
71
|
+
let imageSizeSaveTimer = null
|
|
72
|
+
|
|
45
73
|
const els = {
|
|
46
74
|
welcome: document.getElementById('welcome'),
|
|
47
75
|
welcomeOpenBtn: document.getElementById('welcome-open-btn'),
|
|
@@ -102,6 +130,24 @@ const els = {
|
|
|
102
130
|
frameExtractConfirm: document.getElementById('frame-extract-confirm'),
|
|
103
131
|
frameExtractCount: document.getElementById('frame-extract-count'),
|
|
104
132
|
frameExtractStatus: document.getElementById('frame-extract-status'),
|
|
133
|
+
framePreviewModal: document.getElementById('frame-preview-modal'),
|
|
134
|
+
framePreviewClose: document.getElementById('frame-preview-close'),
|
|
135
|
+
framePreviewCancel: document.getElementById('frame-preview-cancel'),
|
|
136
|
+
framePreviewConfirm: document.getElementById('frame-preview-confirm'),
|
|
137
|
+
framePreviewRows: document.getElementById('frame-preview-rows'),
|
|
138
|
+
framePreviewColumns: document.getElementById('frame-preview-columns'),
|
|
139
|
+
framePreviewFps: document.getElementById('frame-preview-fps'),
|
|
140
|
+
framePreviewInfo: document.getElementById('frame-preview-info'),
|
|
141
|
+
framePreviewGrid: document.getElementById('frame-preview-grid'),
|
|
142
|
+
framePreviewStatus: document.getElementById('frame-preview-status'),
|
|
143
|
+
frameEditorSplitModal: document.getElementById('frame-editor-split-modal'),
|
|
144
|
+
frameEditorSplitClose: document.getElementById('frame-editor-split-close'),
|
|
145
|
+
frameEditorSplitCancel: document.getElementById('frame-editor-split-cancel'),
|
|
146
|
+
frameEditorSplitConfirm: document.getElementById('frame-editor-split-confirm'),
|
|
147
|
+
frameEditorSplitRows: document.getElementById('frame-editor-split-rows'),
|
|
148
|
+
frameEditorSplitColumns: document.getElementById('frame-editor-split-columns'),
|
|
149
|
+
frameEditorSplitInfo: document.getElementById('frame-editor-split-info'),
|
|
150
|
+
frameEditorSplitStatus: document.getElementById('frame-editor-split-status'),
|
|
105
151
|
videoCycleModal: document.getElementById('video-cycle-modal'),
|
|
106
152
|
videoCycleClose: document.getElementById('video-cycle-close'),
|
|
107
153
|
videoCycleCancel: document.getElementById('video-cycle-cancel'),
|
|
@@ -109,41 +155,6 @@ const els = {
|
|
|
109
155
|
videoCycleThreshold: document.getElementById('video-cycle-threshold'),
|
|
110
156
|
videoCycleMinGap: document.getElementById('video-cycle-min-gap'),
|
|
111
157
|
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
158
|
marquee: document.getElementById('marquee'),
|
|
148
159
|
}
|
|
149
160
|
|
|
@@ -209,10 +220,6 @@ function transparentBackgroundWarning(dialogState) {
|
|
|
209
220
|
: TRANSPARENT_BACKGROUND_REFERENCE_ERROR
|
|
210
221
|
}
|
|
211
222
|
|
|
212
|
-
function isSequence(item) {
|
|
213
|
-
return item && item.type === 'sequence' && Array.isArray(item.frames)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
223
|
function isGenDialog(item) {
|
|
217
224
|
return item && item.type === 'gen_dialog'
|
|
218
225
|
}
|
|
@@ -249,11 +256,22 @@ function parseSizeAspect(sizeStr) {
|
|
|
249
256
|
return { w, h }
|
|
250
257
|
}
|
|
251
258
|
|
|
259
|
+
function imageDisplaySize(pixelWidth, pixelHeight) {
|
|
260
|
+
const width = Number(pixelWidth)
|
|
261
|
+
const height = Number(pixelHeight)
|
|
262
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
263
|
+
return { width: IMAGE_BASE_SIZE, height: IMAGE_BASE_SIZE }
|
|
264
|
+
}
|
|
265
|
+
const scale = IMAGE_BASE_SIZE / Math.min(width, height)
|
|
266
|
+
return {
|
|
267
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
268
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
252
272
|
function pendingDisplaySize(sizeStr) {
|
|
253
273
|
const { w, h } = parseSizeAspect(sizeStr)
|
|
254
|
-
|
|
255
|
-
const height = Math.max(120, Math.round((width * h) / w))
|
|
256
|
-
return { width, height }
|
|
274
|
+
return imageDisplaySize(w, h)
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
function videoDisplaySize(ratio) {
|
|
@@ -311,7 +329,7 @@ function ensureVideoDialogState(id) {
|
|
|
311
329
|
|
|
312
330
|
function createGeneratorDialog(kind, position) {
|
|
313
331
|
const isVideo = kind === 'video'
|
|
314
|
-
const id =
|
|
332
|
+
const id = createCanvasClientId(isVideo ? 'videodialog' : 'gendialog')
|
|
315
333
|
const item = {
|
|
316
334
|
id,
|
|
317
335
|
type: isVideo ? 'video_dialog' : 'gen_dialog',
|
|
@@ -363,24 +381,50 @@ function ensureImageDims(id) {
|
|
|
363
381
|
const img = new Image()
|
|
364
382
|
img.onload = () => {
|
|
365
383
|
state.imageDims[id] = { w: img.naturalWidth, h: img.naturalHeight }
|
|
366
|
-
if (state.selectedIds.has(id)
|
|
384
|
+
if (state.selectedIds.has(id)) {
|
|
367
385
|
renderInspector()
|
|
368
386
|
}
|
|
369
387
|
}
|
|
370
388
|
img.onerror = () => {
|
|
371
389
|
state.imageDims[id] = { w: 0, h: 0 }
|
|
372
|
-
if (state.selectedIds.has(id)
|
|
390
|
+
if (state.selectedIds.has(id)) {
|
|
373
391
|
renderInspector()
|
|
374
392
|
}
|
|
375
393
|
}
|
|
376
394
|
img.src = imageUrl(id)
|
|
377
395
|
}
|
|
378
396
|
|
|
379
|
-
function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
397
|
+
function scheduleImageSizeSave() {
|
|
398
|
+
clearTimeout(imageSizeSaveTimer)
|
|
399
|
+
imageSizeSaveTimer = setTimeout(() => {
|
|
400
|
+
imageSizeSaveTimer = null
|
|
401
|
+
saveLayout(state.layoutItems).catch((error) => {
|
|
402
|
+
console.warn('save image canvas size failed', error)
|
|
403
|
+
})
|
|
404
|
+
}, 120)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function applyImageDisplaySize(id, pixelWidth, pixelHeight) {
|
|
408
|
+
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
409
|
+
if (
|
|
410
|
+
!item ||
|
|
411
|
+
item.manualSize === true ||
|
|
412
|
+
(item.layer && typeof item.layer === 'object') ||
|
|
413
|
+
isPending(item) ||
|
|
414
|
+
isGenDialog(item) ||
|
|
415
|
+
isVideoDialog(item) ||
|
|
416
|
+
isFrameEditor(item)
|
|
417
|
+
) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const display = imageDisplaySize(pixelWidth, pixelHeight)
|
|
421
|
+
if (Number(item.width) === display.width && Number(item.height) === display.height) return
|
|
422
|
+
item.width = display.width
|
|
423
|
+
item.height = display.height
|
|
424
|
+
const nodeElement = findNodeEl(id)
|
|
425
|
+
if (nodeElement) nodeElement.style.width = `${display.width}px`
|
|
426
|
+
requestDependencyEdgeUpdate()
|
|
427
|
+
scheduleImageSizeSave()
|
|
384
428
|
}
|
|
385
429
|
|
|
386
430
|
function getSelectedItem(id) {
|
|
@@ -412,7 +456,7 @@ function appendKv(parent, rows) {
|
|
|
412
456
|
function isPlainImageItem(item) {
|
|
413
457
|
if (
|
|
414
458
|
!item ||
|
|
415
|
-
|
|
459
|
+
isFrameEditor(item) ||
|
|
416
460
|
isGenDialog(item) ||
|
|
417
461
|
isVideoDialog(item) ||
|
|
418
462
|
isPending(item) ||
|
|
@@ -423,6 +467,153 @@ function isPlainImageItem(item) {
|
|
|
423
467
|
return state.images.some((image) => image.id === item.id && image.kind === 'image')
|
|
424
468
|
}
|
|
425
469
|
|
|
470
|
+
function isFrameEditor(item) {
|
|
471
|
+
return item && item.type === 'frame_editor'
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function normalizeFramePreviewConfig(value) {
|
|
475
|
+
if (!value || typeof value !== 'object') return null
|
|
476
|
+
const rows = Math.round(Number(value.rows))
|
|
477
|
+
const columns = Math.round(Number(value.columns))
|
|
478
|
+
const frameCount = Math.round(Number(value.frameCount))
|
|
479
|
+
const fps = Number(value.fps)
|
|
480
|
+
if (
|
|
481
|
+
!Number.isFinite(rows) ||
|
|
482
|
+
!Number.isFinite(columns) ||
|
|
483
|
+
!Number.isFinite(frameCount) ||
|
|
484
|
+
!Number.isFinite(fps) ||
|
|
485
|
+
rows < 1 ||
|
|
486
|
+
rows > 64 ||
|
|
487
|
+
columns < 1 ||
|
|
488
|
+
columns > 64 ||
|
|
489
|
+
(rows === 1 && columns === 1) ||
|
|
490
|
+
frameCount < 1 ||
|
|
491
|
+
frameCount > rows * columns ||
|
|
492
|
+
fps <= 0
|
|
493
|
+
) {
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
return { enabled: value.enabled === true, rows, columns, frameCount, fps }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function sourceFramePreviewConfig(item) {
|
|
500
|
+
const provenance = item?.provenance
|
|
501
|
+
const metadata = provenance?.metadata
|
|
502
|
+
if (metadata && typeof metadata === 'object') {
|
|
503
|
+
const generated = normalizeFramePreviewConfig({
|
|
504
|
+
enabled: false,
|
|
505
|
+
rows: metadata.rows,
|
|
506
|
+
columns: metadata.columns,
|
|
507
|
+
frameCount: metadata.frameCount,
|
|
508
|
+
fps: Number(metadata.fps) || Number(metadata.frameCount) || 8,
|
|
509
|
+
})
|
|
510
|
+
if (
|
|
511
|
+
generated &&
|
|
512
|
+
(metadata.kind === 'frame_sheet' || provenance.operation === 'video_frame_extract')
|
|
513
|
+
) {
|
|
514
|
+
return generated
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function suggestedFramePreviewConfig(item) {
|
|
522
|
+
const saved = normalizeFramePreviewConfig(item?.framePreview)
|
|
523
|
+
if (saved) return saved
|
|
524
|
+
const generated = sourceFramePreviewConfig(item)
|
|
525
|
+
if (generated) return generated
|
|
526
|
+
|
|
527
|
+
return { enabled: false, rows: 2, columns: 2, frameCount: 4, fps: 8 }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function frameSheetPreviewMeta(item) {
|
|
531
|
+
const config = normalizeFramePreviewConfig(item?.framePreview)
|
|
532
|
+
if (!config?.enabled) return null
|
|
533
|
+
|
|
534
|
+
const metadata = item?.provenance?.metadata
|
|
535
|
+
const cellWidth = Math.round(Number(metadata?.cellWidth))
|
|
536
|
+
const cellHeight = Math.round(Number(metadata?.cellHeight))
|
|
537
|
+
return {
|
|
538
|
+
...config,
|
|
539
|
+
cellWidth: cellWidth > 0 ? cellWidth : null,
|
|
540
|
+
cellHeight: cellHeight > 0 ? cellHeight : null,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function applyFrameSheetPreviewFrame(preview, meta, index) {
|
|
545
|
+
const image = preview?.querySelector('img[data-role="frame-sheet-image"]')
|
|
546
|
+
if (!image) return
|
|
547
|
+
const frameIndex = Math.max(0, Math.min(meta.frameCount - 1, index % meta.frameCount))
|
|
548
|
+
const row = Math.floor(frameIndex / meta.columns)
|
|
549
|
+
const column = frameIndex % meta.columns
|
|
550
|
+
image.style.transform = `translate(${-100 * column / meta.columns}%, ${-100 * row / meta.rows}%)`
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function normalizeFrameEditorFrame(value, sourceRows, sourceColumns) {
|
|
554
|
+
if (!value || typeof value !== 'object') return null
|
|
555
|
+
if (value.kind === 'source_cell') {
|
|
556
|
+
const index = Math.round(Number(value.index))
|
|
557
|
+
if (!Number.isFinite(index) || index < 0 || index >= sourceRows * sourceColumns) return null
|
|
558
|
+
return { kind: 'source_cell', index }
|
|
559
|
+
}
|
|
560
|
+
if (value.kind === 'asset') {
|
|
561
|
+
const assetId = String(value.assetId || value.asset_id || '').trim()
|
|
562
|
+
return assetId ? { kind: 'asset', assetId } : null
|
|
563
|
+
}
|
|
564
|
+
return null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function normalizeFrameEditor(item) {
|
|
568
|
+
if (!isFrameEditor(item)) return null
|
|
569
|
+
const sourceId = String(item.sourceId || '').trim()
|
|
570
|
+
const sourceRows = Math.round(Number(item.sourceRows))
|
|
571
|
+
const sourceColumns = Math.round(Number(item.sourceColumns))
|
|
572
|
+
const rows = Math.round(Number(item.rows))
|
|
573
|
+
const columns = Math.round(Number(item.columns))
|
|
574
|
+
const fps = Number(item.fps)
|
|
575
|
+
if (
|
|
576
|
+
!sourceId ||
|
|
577
|
+
!Number.isFinite(sourceRows) ||
|
|
578
|
+
!Number.isFinite(sourceColumns) ||
|
|
579
|
+
!Number.isFinite(rows) ||
|
|
580
|
+
!Number.isFinite(columns) ||
|
|
581
|
+
!Number.isFinite(fps) ||
|
|
582
|
+
sourceRows < 1 ||
|
|
583
|
+
sourceRows > 64 ||
|
|
584
|
+
sourceColumns < 1 ||
|
|
585
|
+
sourceColumns > 64 ||
|
|
586
|
+
rows < 1 ||
|
|
587
|
+
rows > 64 ||
|
|
588
|
+
columns < 1 ||
|
|
589
|
+
columns > 64 ||
|
|
590
|
+
fps <= 0
|
|
591
|
+
) {
|
|
592
|
+
return null
|
|
593
|
+
}
|
|
594
|
+
const frames = (Array.isArray(item.frames) ? item.frames : [])
|
|
595
|
+
.map((frame) => normalizeFrameEditorFrame(frame, sourceRows, sourceColumns))
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
.slice(0, rows * columns)
|
|
598
|
+
return { sourceId, sourceRows, sourceColumns, rows, columns, fps, frames }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function frameEditorWidth(value) {
|
|
602
|
+
const width = Number(value)
|
|
603
|
+
return Number.isFinite(width) ? Math.max(FRAME_EDITOR_W, width) : FRAME_EDITOR_W
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function frameEditorParentIds(item) {
|
|
607
|
+
const editor = normalizeFrameEditor(item)
|
|
608
|
+
if (!editor) return []
|
|
609
|
+
return [
|
|
610
|
+
editor.sourceId,
|
|
611
|
+
...editor.frames
|
|
612
|
+
.filter((frame) => frame.kind === 'asset')
|
|
613
|
+
.map((frame) => frame.assetId),
|
|
614
|
+
].filter((id, index, ids) => id && ids.indexOf(id) === index)
|
|
615
|
+
}
|
|
616
|
+
|
|
426
617
|
function resolveTosUrl(item) {
|
|
427
618
|
const value =
|
|
428
619
|
item?.provenance?.assetUrl ||
|
|
@@ -553,7 +744,7 @@ function normalizeOrigin(origin) {
|
|
|
553
744
|
const parentId = origin.parentId || origin.sourceId || null
|
|
554
745
|
if (!parentId && kind !== 'ai_generate') return null
|
|
555
746
|
|
|
556
|
-
if (kind === '
|
|
747
|
+
if (kind === 'video_cycle') {
|
|
557
748
|
const start = Number(origin.start)
|
|
558
749
|
const end = Number(origin.end)
|
|
559
750
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
|
@@ -570,14 +761,6 @@ function normalizeOrigin(origin) {
|
|
|
570
761
|
: null,
|
|
571
762
|
}
|
|
572
763
|
}
|
|
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
764
|
if (kind === 'smart_split') {
|
|
582
765
|
return {
|
|
583
766
|
kind,
|
|
@@ -590,16 +773,6 @@ function normalizeOrigin(origin) {
|
|
|
590
773
|
),
|
|
591
774
|
}
|
|
592
775
|
}
|
|
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
776
|
return null
|
|
604
777
|
}
|
|
605
778
|
|
|
@@ -607,21 +780,9 @@ function buildOrigin(kind, fields = {}) {
|
|
|
607
780
|
return normalizeOrigin({ kind, ...fields })
|
|
608
781
|
}
|
|
609
782
|
|
|
610
|
-
/** 解析元素产生方式:优先 origin;旧序列仅有 sourceId 时做兼容。 */
|
|
611
783
|
function resolveItemOrigin(item) {
|
|
612
784
|
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
|
|
785
|
+
return normalizeOrigin(item.origin)
|
|
625
786
|
}
|
|
626
787
|
|
|
627
788
|
function originParentLabel(origin) {
|
|
@@ -671,15 +832,6 @@ function appendOriginSection(section, item) {
|
|
|
671
832
|
}
|
|
672
833
|
|
|
673
834
|
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
835
|
if (origin?.kind === 'video_cycle') {
|
|
684
836
|
const rows = [
|
|
685
837
|
['方式', '智能周期提取'],
|
|
@@ -694,14 +846,6 @@ function appendOriginSection(section, item) {
|
|
|
694
846
|
appendKv(section, rows)
|
|
695
847
|
return
|
|
696
848
|
}
|
|
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
849
|
if (origin?.kind === 'smart_split') {
|
|
706
850
|
appendKv(section, [
|
|
707
851
|
['方式', '智能切分'],
|
|
@@ -710,15 +854,6 @@ function appendOriginSection(section, item) {
|
|
|
710
854
|
])
|
|
711
855
|
return
|
|
712
856
|
}
|
|
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
857
|
|
|
723
858
|
const gen = normalizeGenMeta(item.gen)
|
|
724
859
|
if (gen && (gen.prompt || gen.model)) {
|
|
@@ -754,6 +889,7 @@ function appendOriginSection(section, item) {
|
|
|
754
889
|
|
|
755
890
|
function attachSerializableMeta(out, item) {
|
|
756
891
|
if (typeof item.name === 'string' && item.name) out.name = item.name
|
|
892
|
+
if (item.manualSize === true) out.manualSize = true
|
|
757
893
|
const gen = normalizeGenMeta(item.gen)
|
|
758
894
|
if (gen) out.gen = gen
|
|
759
895
|
const origin = normalizeOrigin(item.origin)
|
|
@@ -761,30 +897,51 @@ function attachSerializableMeta(out, item) {
|
|
|
761
897
|
if (item.provenance && typeof item.provenance === 'object') {
|
|
762
898
|
out.provenance = item.provenance
|
|
763
899
|
}
|
|
900
|
+
const framePreview = normalizeFramePreviewConfig(item.framePreview)
|
|
901
|
+
if (framePreview) out.framePreview = framePreview
|
|
764
902
|
if (item.layer && typeof item.layer === 'object' && !Array.isArray(item.layer)) {
|
|
765
903
|
out.layer = item.layer
|
|
766
904
|
}
|
|
767
905
|
return out
|
|
768
906
|
}
|
|
769
907
|
|
|
908
|
+
function attachCanvasIdentity(out, item) {
|
|
909
|
+
const assetId = item.assetId || (String(item.id || '').startsWith('assets/') ? item.id : '')
|
|
910
|
+
const operationId = item.operationId || (
|
|
911
|
+
isPending(item) || String(item.id || '').startsWith('job_') || String(item.id || '').startsWith('pending_')
|
|
912
|
+
? item.id
|
|
913
|
+
: ''
|
|
914
|
+
)
|
|
915
|
+
out.nodeId = item.nodeId || (operationId
|
|
916
|
+
? `node:${operationId}`
|
|
917
|
+
: assetId
|
|
918
|
+
? `node:${assetId}`
|
|
919
|
+
: `node:${item.id}`)
|
|
920
|
+
if (assetId) out.assetId = assetId
|
|
921
|
+
if (operationId) out.operationId = operationId
|
|
922
|
+
return out
|
|
923
|
+
}
|
|
924
|
+
|
|
770
925
|
function serializeLayoutItem(item) {
|
|
771
|
-
if (
|
|
772
|
-
const
|
|
926
|
+
if (isFrameEditor(item)) {
|
|
927
|
+
const editor = normalizeFrameEditor(item)
|
|
928
|
+
return {
|
|
773
929
|
id: item.id,
|
|
774
|
-
type: '
|
|
775
|
-
name: item.name || '
|
|
930
|
+
type: 'frame_editor',
|
|
931
|
+
name: item.name || '序列帧编辑',
|
|
776
932
|
x: item.x,
|
|
777
933
|
y: item.y,
|
|
778
|
-
width: item.width
|
|
934
|
+
width: frameEditorWidth(item.width),
|
|
779
935
|
height: item.height ?? null,
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
936
|
+
sourceId: editor?.sourceId || item.sourceId,
|
|
937
|
+
sourceRows: editor?.sourceRows || item.sourceRows,
|
|
938
|
+
sourceColumns: editor?.sourceColumns || item.sourceColumns,
|
|
939
|
+
rows: editor?.rows || item.rows,
|
|
940
|
+
columns: editor?.columns || item.columns,
|
|
941
|
+
fps: editor?.fps || item.fps || 8,
|
|
942
|
+
frames: editor?.frames || [],
|
|
943
|
+
error: item.error || null,
|
|
784
944
|
}
|
|
785
|
-
const origin = normalizeOrigin(item.origin)
|
|
786
|
-
if (origin) out.origin = origin
|
|
787
|
-
return out
|
|
788
945
|
}
|
|
789
946
|
if (isGenDialog(item)) {
|
|
790
947
|
return {
|
|
@@ -995,21 +1152,23 @@ async function api(path, options) {
|
|
|
995
1152
|
const message = Array.isArray(detail)
|
|
996
1153
|
? detail.map((d) => d.msg || JSON.stringify(d)).join('; ')
|
|
997
1154
|
: detail || data.message || res.statusText || '请求失败'
|
|
998
|
-
|
|
1155
|
+
const error = new Error(message)
|
|
1156
|
+
error.status = res.status
|
|
1157
|
+
error.data = data
|
|
1158
|
+
throw error
|
|
999
1159
|
}
|
|
1000
1160
|
return data
|
|
1001
1161
|
}
|
|
1002
1162
|
|
|
1003
1163
|
/** 画布只显示 layout 里的元素;磁盘上的孤儿文件不会自动冒出来 */
|
|
1004
1164
|
function mergeLayout(_images, layoutItems) {
|
|
1005
|
-
return (layoutItems || []).map((item) => {
|
|
1006
|
-
if (
|
|
1165
|
+
return (layoutItems || []).filter((item) => item?.type !== 'sequence').map((item) => {
|
|
1166
|
+
if (isFrameEditor(item)) {
|
|
1007
1167
|
return {
|
|
1008
1168
|
...item,
|
|
1009
|
-
type: '
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
loop: item.loop !== false,
|
|
1169
|
+
type: 'frame_editor',
|
|
1170
|
+
name: item.name || '序列帧编辑',
|
|
1171
|
+
width: frameEditorWidth(item.width),
|
|
1013
1172
|
}
|
|
1014
1173
|
}
|
|
1015
1174
|
if (isGenDialog(item)) {
|
|
@@ -1069,6 +1228,20 @@ function buildNodes() {
|
|
|
1069
1228
|
const nodes = []
|
|
1070
1229
|
|
|
1071
1230
|
state.layoutItems.forEach((item, index) => {
|
|
1231
|
+
if (isFrameEditor(item)) {
|
|
1232
|
+
const editor = normalizeFrameEditor(item)
|
|
1233
|
+
if (!editor) return
|
|
1234
|
+
nodes.push({
|
|
1235
|
+
...item,
|
|
1236
|
+
...editor,
|
|
1237
|
+
type: 'frame_editor',
|
|
1238
|
+
name: item.name || '序列帧编辑',
|
|
1239
|
+
x: item.x ?? 60 + (index % 5) * 540,
|
|
1240
|
+
y: item.y ?? 60 + Math.floor(index / 5) * 360,
|
|
1241
|
+
width: frameEditorWidth(item.width),
|
|
1242
|
+
})
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1072
1245
|
if (isGenDialog(item)) {
|
|
1073
1246
|
nodes.push({
|
|
1074
1247
|
...item,
|
|
@@ -1124,25 +1297,6 @@ function buildNodes() {
|
|
|
1124
1297
|
})
|
|
1125
1298
|
return
|
|
1126
1299
|
}
|
|
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
1300
|
const img = imageMap[item.id]
|
|
1147
1301
|
if (!img) return
|
|
1148
1302
|
nodes.push({
|
|
@@ -1158,18 +1312,55 @@ function buildNodes() {
|
|
|
1158
1312
|
return nodes
|
|
1159
1313
|
}
|
|
1160
1314
|
|
|
1161
|
-
|
|
1162
|
-
const next = (items || state.layoutItems).map(
|
|
1315
|
+
function saveLayout(items) {
|
|
1316
|
+
const next = (items || state.layoutItems).map((item) =>
|
|
1317
|
+
attachCanvasIdentity(serializeLayoutItem(item), item),
|
|
1318
|
+
)
|
|
1319
|
+
const sequence = ++layoutSaveSequence
|
|
1320
|
+
const epoch = layoutSaveEpoch
|
|
1321
|
+
const expectedRevision = layoutPendingSaveCount === 0
|
|
1322
|
+
? state.layoutRevision
|
|
1323
|
+
: layoutNextExpectedRevision
|
|
1324
|
+
layoutNextExpectedRevision = expectedRevision + 1
|
|
1325
|
+
layoutPendingSaveCount += 1
|
|
1163
1326
|
state.layoutItems = next
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1327
|
+
const request = layoutSaveQueue.then(async () => {
|
|
1328
|
+
try {
|
|
1329
|
+
if (epoch !== layoutSaveEpoch) {
|
|
1330
|
+
const staleError = new Error('画布已被其他任务更新,请重试刚才的操作。')
|
|
1331
|
+
staleError.status = 409
|
|
1332
|
+
throw staleError
|
|
1333
|
+
}
|
|
1334
|
+
const saved = await api('/api/layout', {
|
|
1335
|
+
method: 'PUT',
|
|
1336
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1337
|
+
body: JSON.stringify({ items: next, revision: expectedRevision }),
|
|
1338
|
+
})
|
|
1339
|
+
state.layoutRevision = Number(saved.revision) || state.layoutRevision
|
|
1340
|
+
if (sequence === layoutSaveSequence && Array.isArray(saved.items)) {
|
|
1341
|
+
state.layoutItems = mergeLayout(state.images, saved.items)
|
|
1342
|
+
}
|
|
1343
|
+
return saved
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
console.warn('save layout failed', err)
|
|
1346
|
+
const firstFailureInEpoch = epoch === layoutSaveEpoch
|
|
1347
|
+
if (firstFailureInEpoch) {
|
|
1348
|
+
layoutSaveEpoch += 1
|
|
1349
|
+
}
|
|
1350
|
+
if (err?.status === 409 && firstFailureInEpoch) {
|
|
1351
|
+
state.error = '画布已被其他任务更新,请重试刚才的操作。'
|
|
1352
|
+
await reload().catch(() => undefined)
|
|
1353
|
+
}
|
|
1354
|
+
throw err
|
|
1355
|
+
} finally {
|
|
1356
|
+
layoutPendingSaveCount = Math.max(0, layoutPendingSaveCount - 1)
|
|
1357
|
+
if (layoutPendingSaveCount === 0) {
|
|
1358
|
+
layoutNextExpectedRevision = state.layoutRevision
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
})
|
|
1362
|
+
layoutSaveQueue = request.catch(() => undefined)
|
|
1363
|
+
return request
|
|
1173
1364
|
}
|
|
1174
1365
|
|
|
1175
1366
|
function applyCamera() {
|
|
@@ -1188,40 +1379,30 @@ function showCtxMenu(clientX, clientY, node) {
|
|
|
1188
1379
|
state.ctxCanvasPoint = null
|
|
1189
1380
|
const selectedIds = isSelected(node.id) ? [...state.selectedIds] : [node.id]
|
|
1190
1381
|
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) {
|
|
1382
|
+
if (isMulti || isFrameEditor(node)) {
|
|
1197
1383
|
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
|
-
)
|
|
1384
|
+
button.hidden = button.dataset.action !== 'delete'
|
|
1202
1385
|
}
|
|
1203
1386
|
} else {
|
|
1204
|
-
const isSeq = isSequence(node)
|
|
1205
1387
|
const isDialog = isGenDialog(node) || isVideoDialog(node)
|
|
1206
1388
|
const pending = isPending(node)
|
|
1207
1389
|
const isVid = isVideoItem(node)
|
|
1208
|
-
const isImage = !
|
|
1390
|
+
const isImage = !isDialog && !pending && !isVid
|
|
1209
1391
|
els.ctxMenu.querySelector('[data-action="copy"]').hidden = pending || isDialog
|
|
1210
1392
|
els.ctxMenu.querySelector('[data-action="paste"]').hidden = true
|
|
1211
1393
|
els.ctxMenu.querySelector('[data-action="cutout"]').hidden = !isImage
|
|
1212
1394
|
els.ctxMenu.querySelector('[data-action="interactive-edit"]').hidden = !isImage
|
|
1213
1395
|
els.ctxMenu.querySelector('[data-action="layer-decomposition"]').hidden = !isImage
|
|
1214
|
-
els.ctxMenu.querySelector('[data-action="video-trim"]').hidden = !isVid
|
|
1215
1396
|
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="
|
|
1397
|
+
els.ctxMenu.querySelector('[data-action="video-to-frame-sheet"]').hidden = !isVid
|
|
1398
|
+
els.ctxMenu.querySelector('[data-action="smart-split"]').hidden = !isImage
|
|
1399
|
+
els.ctxMenu.querySelector('[data-action="manual-split"]').hidden = !isImage
|
|
1400
|
+
const framePreviewButton = els.ctxMenu.querySelector('[data-action="frame-sheet-preview"]')
|
|
1401
|
+
framePreviewButton.hidden = !isImage
|
|
1402
|
+
framePreviewButton.textContent = frameSheetPreviewMeta(node)
|
|
1403
|
+
? '停止序列帧预览'
|
|
1404
|
+
: '序列帧预览'
|
|
1405
|
+
els.ctxMenu.querySelector('[data-action="frame-sheet-edit"]').hidden = !isImage
|
|
1225
1406
|
els.ctxMenu.querySelector('[data-action="delete"]').hidden = false
|
|
1226
1407
|
}
|
|
1227
1408
|
positionCtxMenu(clientX, clientY)
|
|
@@ -1509,7 +1690,7 @@ async function submitGenDialog(dialogId) {
|
|
|
1509
1690
|
: undefined
|
|
1510
1691
|
const references = [...ds.refs]
|
|
1511
1692
|
const display = pendingDisplaySize(size)
|
|
1512
|
-
const pendingId =
|
|
1693
|
+
const pendingId = createCanvasClientId('pending')
|
|
1513
1694
|
const pendingItem = {
|
|
1514
1695
|
id: pendingId,
|
|
1515
1696
|
type: 'pending',
|
|
@@ -1535,6 +1716,7 @@ async function submitGenDialog(dialogId) {
|
|
|
1535
1716
|
renderCanvas()
|
|
1536
1717
|
renderChrome()
|
|
1537
1718
|
|
|
1719
|
+
const activeOperation = beginActiveOperation('image-generation')
|
|
1538
1720
|
try {
|
|
1539
1721
|
const result = await api('/api/generate', {
|
|
1540
1722
|
method: 'POST',
|
|
@@ -1550,25 +1732,7 @@ async function submitGenDialog(dialogId) {
|
|
|
1550
1732
|
}),
|
|
1551
1733
|
})
|
|
1552
1734
|
// 占位节点替换为真实图片
|
|
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
1735
|
delete state.genDialogs[dialogId]
|
|
1571
|
-
await saveLayout(state.layoutItems)
|
|
1572
1736
|
await reload()
|
|
1573
1737
|
setSelection([result.id])
|
|
1574
1738
|
renderCanvas()
|
|
@@ -1581,10 +1745,12 @@ async function submitGenDialog(dialogId) {
|
|
|
1581
1745
|
(it) => it.id !== pendingId && it.id !== dialogId,
|
|
1582
1746
|
)
|
|
1583
1747
|
delete state.genDialogs[dialogId]
|
|
1584
|
-
await
|
|
1748
|
+
await reload().catch(() => undefined)
|
|
1585
1749
|
clearSelection()
|
|
1586
1750
|
renderCanvas()
|
|
1587
1751
|
renderChrome()
|
|
1752
|
+
} finally {
|
|
1753
|
+
endActiveOperation(activeOperation)
|
|
1588
1754
|
}
|
|
1589
1755
|
}
|
|
1590
1756
|
|
|
@@ -1811,7 +1977,7 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1811
1977
|
const text = String(ds.prompt || '').trim()
|
|
1812
1978
|
if (!text || ds.busy || !dialog) return
|
|
1813
1979
|
const display = videoDisplaySize(ds.ratio || '16:9')
|
|
1814
|
-
const pendingId =
|
|
1980
|
+
const pendingId = createCanvasClientId('pending')
|
|
1815
1981
|
const references = ds.refs.map((r) => ({
|
|
1816
1982
|
id: r.id,
|
|
1817
1983
|
role: r.role,
|
|
@@ -1845,6 +2011,7 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1845
2011
|
renderCanvas()
|
|
1846
2012
|
renderChrome()
|
|
1847
2013
|
|
|
2014
|
+
const activeOperation = beginActiveOperation('video-generation')
|
|
1848
2015
|
try {
|
|
1849
2016
|
const seedRaw = String(ds.seed ?? '').trim()
|
|
1850
2017
|
const result = await api('/api/generate-video', {
|
|
@@ -1862,30 +2029,7 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1862
2029
|
place: false,
|
|
1863
2030
|
}),
|
|
1864
2031
|
})
|
|
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
2032
|
delete state.videoDialogs[dialogId]
|
|
1888
|
-
await saveLayout(state.layoutItems)
|
|
1889
2033
|
await reload()
|
|
1890
2034
|
setSelection([result.id])
|
|
1891
2035
|
renderCanvas()
|
|
@@ -1900,6 +2044,15 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1900
2044
|
role: 'assistant',
|
|
1901
2045
|
error: detail,
|
|
1902
2046
|
})
|
|
2047
|
+
await reload().catch(() => undefined)
|
|
2048
|
+
state.layoutItems.push({
|
|
2049
|
+
id: pendingId,
|
|
2050
|
+
type: 'pending',
|
|
2051
|
+
x: dialog.x,
|
|
2052
|
+
y: dialog.y,
|
|
2053
|
+
width: VIDEO_DIALOG_W,
|
|
2054
|
+
height: null,
|
|
2055
|
+
})
|
|
1903
2056
|
state.layoutItems = state.layoutItems.map((it) =>
|
|
1904
2057
|
it.id === pendingId
|
|
1905
2058
|
? {
|
|
@@ -1917,6 +2070,8 @@ async function submitVideoGenDialog(dialogId) {
|
|
|
1917
2070
|
setSelection([dialogId])
|
|
1918
2071
|
renderCanvas()
|
|
1919
2072
|
renderChrome()
|
|
2073
|
+
} finally {
|
|
2074
|
+
endActiveOperation(activeOperation)
|
|
1920
2075
|
}
|
|
1921
2076
|
}
|
|
1922
2077
|
|
|
@@ -1985,6 +2140,9 @@ function dependencyParentIds(item) {
|
|
|
1985
2140
|
if (isVideoDialog(item)) {
|
|
1986
2141
|
for (const reference of ensureVideoDialogState(item.id).refs || []) add(reference.id)
|
|
1987
2142
|
}
|
|
2143
|
+
if (isFrameEditor(item)) {
|
|
2144
|
+
for (const parentId of frameEditorParentIds(item)) add(parentId)
|
|
2145
|
+
}
|
|
1988
2146
|
if (isPending(item)) {
|
|
1989
2147
|
for (const reference of item.references || item._refs || []) add(reference.id)
|
|
1990
2148
|
}
|
|
@@ -1997,11 +2155,7 @@ function dependencyParentIds(item) {
|
|
|
1997
2155
|
}
|
|
1998
2156
|
|
|
1999
2157
|
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
|
|
2158
|
+
return visibleIds.has(parentId) ? parentId : null
|
|
2005
2159
|
}
|
|
2006
2160
|
|
|
2007
2161
|
function dependencyNodeBox(id) {
|
|
@@ -2113,62 +2267,603 @@ function requestDependencyEdgeGeometryUpdate() {
|
|
|
2113
2267
|
})
|
|
2114
2268
|
}
|
|
2115
2269
|
|
|
2116
|
-
function
|
|
2117
|
-
const
|
|
2118
|
-
|
|
2119
|
-
|
|
2270
|
+
function frameEditorLayoutItem(editorId) {
|
|
2271
|
+
const item = state.layoutItems.find((candidate) => candidate.id === editorId)
|
|
2272
|
+
return isFrameEditor(item) ? item : null
|
|
2273
|
+
}
|
|
2120
2274
|
|
|
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
|
|
2275
|
+
async function commitFrameEditorChange(editorId) {
|
|
2276
|
+
await saveLayout()
|
|
2277
|
+
renderCanvas()
|
|
2278
|
+
renderChrome()
|
|
2279
|
+
setSelection([editorId])
|
|
2280
|
+
syncSelectionClass()
|
|
2281
|
+
}
|
|
2137
2282
|
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2283
|
+
function frameEditorThumbnail(editor, frame) {
|
|
2284
|
+
const preview = document.createElement('div')
|
|
2285
|
+
preview.className = 'frame-editor-thumb'
|
|
2286
|
+
const image = document.createElement('img')
|
|
2287
|
+
image.alt = '序列帧'
|
|
2288
|
+
image.draggable = false
|
|
2289
|
+
if (frame.kind === 'source_cell') {
|
|
2290
|
+
preview.classList.add('is-source-cell')
|
|
2291
|
+
image.src = imageUrl(editor.sourceId)
|
|
2292
|
+
image.style.width = `${editor.sourceColumns * 100}%`
|
|
2293
|
+
image.style.height = `${editor.sourceRows * 100}%`
|
|
2294
|
+
const row = Math.floor(frame.index / editor.sourceColumns)
|
|
2295
|
+
const column = frame.index % editor.sourceColumns
|
|
2296
|
+
image.style.transform = `translate(${-100 * column / editor.sourceColumns}%, ${-100 * row / editor.sourceRows}%)`
|
|
2297
|
+
} else {
|
|
2298
|
+
image.src = imageUrl(frame.assetId)
|
|
2299
|
+
}
|
|
2300
|
+
preview.appendChild(image)
|
|
2301
|
+
return preview
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
function applyFrameEditorPreviewFrame(preview, editor, index) {
|
|
2305
|
+
if (!preview) return
|
|
2306
|
+
const frameCount = editor.frames.length
|
|
2307
|
+
const counter = preview
|
|
2308
|
+
.closest('.frame-editor-preview')
|
|
2309
|
+
?.querySelector('[data-role="frame-editor-preview-counter"]')
|
|
2310
|
+
if (!frameCount) {
|
|
2311
|
+
const empty = document.createElement('span')
|
|
2312
|
+
empty.className = 'frame-editor-preview-empty'
|
|
2313
|
+
empty.textContent = '暂无帧'
|
|
2314
|
+
preview.replaceChildren(empty)
|
|
2315
|
+
if (counter) counter.textContent = '0 / 0'
|
|
2316
|
+
return
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
const frameIndex = Math.max(0, index % frameCount)
|
|
2320
|
+
const frame = frameEditorThumbnail(editor, editor.frames[frameIndex])
|
|
2321
|
+
frame.classList.add('frame-editor-preview-frame')
|
|
2322
|
+
preview.replaceChildren(frame)
|
|
2323
|
+
if (counter) counter.textContent = `${frameIndex + 1} / ${frameCount}`
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function fitFrameEditorPreviewStage(preview, editor) {
|
|
2327
|
+
const source = new Image()
|
|
2328
|
+
source.onload = () => {
|
|
2329
|
+
if (!source.naturalWidth || !source.naturalHeight) return
|
|
2330
|
+
const ratio =
|
|
2331
|
+
(source.naturalWidth * editor.sourceRows) /
|
|
2332
|
+
(source.naturalHeight * editor.sourceColumns)
|
|
2333
|
+
if (!Number.isFinite(ratio) || ratio <= 0) return
|
|
2334
|
+
preview.style.aspectRatio = String(ratio)
|
|
2335
|
+
preview.style.width = `${Math.min(1, ratio) * 100}%`
|
|
2336
|
+
}
|
|
2337
|
+
source.src = imageUrl(editor.sourceId)
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
async function deleteFrameEditorFrame(editorId, frameIndex) {
|
|
2341
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2342
|
+
const editor = normalizeFrameEditor(item)
|
|
2343
|
+
if (!item || !editor || frameIndex < 0 || frameIndex >= editor.frames.length) return
|
|
2344
|
+
item.frames = editor.frames.filter((_, index) => index !== frameIndex)
|
|
2345
|
+
item.error = null
|
|
2346
|
+
await commitFrameEditorChange(editorId)
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
async function updateFrameEditorDimension(editorId, dimension, rawValue) {
|
|
2350
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2351
|
+
const editor = normalizeFrameEditor(item)
|
|
2352
|
+
if (!item || !editor) return
|
|
2353
|
+
const value = Number(rawValue)
|
|
2354
|
+
if (!Number.isInteger(value) || value < 1 || value > 64) {
|
|
2355
|
+
item.error = `${dimension === 'row' ? '行数' : '列数'}必须是 1–64 的整数`
|
|
2356
|
+
renderCanvas()
|
|
2357
|
+
return
|
|
2358
|
+
}
|
|
2359
|
+
if (dimension === 'row') {
|
|
2360
|
+
item.rows = value
|
|
2361
|
+
} else {
|
|
2362
|
+
item.columns = value
|
|
2363
|
+
}
|
|
2364
|
+
const capacity = Number(item.rows) * Number(item.columns)
|
|
2365
|
+
if (editor.frames.length > capacity) item.frames = editor.frames.slice(0, capacity)
|
|
2366
|
+
item.error = null
|
|
2367
|
+
await commitFrameEditorChange(editorId)
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
async function updateFrameEditorFps(editorId, rawValue) {
|
|
2371
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2372
|
+
const fps = Number(rawValue)
|
|
2373
|
+
if (!item || !Number.isFinite(fps) || fps <= 0) {
|
|
2374
|
+
if (item) {
|
|
2375
|
+
item.error = 'FPS 必须大于 0'
|
|
2376
|
+
renderCanvas()
|
|
2152
2377
|
}
|
|
2378
|
+
return
|
|
2379
|
+
}
|
|
2380
|
+
item.fps = fps
|
|
2381
|
+
item.error = null
|
|
2382
|
+
await saveLayout()
|
|
2383
|
+
}
|
|
2153
2384
|
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2385
|
+
async function dropOnFrameEditor(editorId, targetIndex, dataTransfer) {
|
|
2386
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2387
|
+
const editor = normalizeFrameEditor(item)
|
|
2388
|
+
if (!item || !editor) return
|
|
2389
|
+
|
|
2390
|
+
const internalRaw = dataTransfer.getData('application/x-frame-editor-frame')
|
|
2391
|
+
if (internalRaw) {
|
|
2392
|
+
try {
|
|
2393
|
+
const internal = JSON.parse(internalRaw)
|
|
2394
|
+
const fromIndex = Number(internal.index)
|
|
2395
|
+
if (
|
|
2396
|
+
internal.editorId !== editorId ||
|
|
2397
|
+
!Number.isInteger(fromIndex) ||
|
|
2398
|
+
fromIndex < 0 ||
|
|
2399
|
+
fromIndex >= editor.frames.length
|
|
2400
|
+
) return
|
|
2401
|
+
const frames = [...editor.frames]
|
|
2402
|
+
if (targetIndex >= frames.length) {
|
|
2403
|
+
const [moved] = frames.splice(fromIndex, 1)
|
|
2404
|
+
frames.push(moved)
|
|
2405
|
+
} else if (targetIndex !== fromIndex) {
|
|
2406
|
+
const targetFrame = frames[targetIndex]
|
|
2407
|
+
frames[targetIndex] = frames[fromIndex]
|
|
2408
|
+
frames[fromIndex] = targetFrame
|
|
2409
|
+
}
|
|
2410
|
+
item.frames = frames
|
|
2411
|
+
item.error = null
|
|
2412
|
+
await commitFrameEditorChange(editorId)
|
|
2413
|
+
} catch {
|
|
2414
|
+
/* ignore malformed internal drag data */
|
|
2168
2415
|
}
|
|
2416
|
+
return
|
|
2417
|
+
}
|
|
2169
2418
|
|
|
2170
|
-
|
|
2171
|
-
|
|
2419
|
+
let raw = dataTransfer.getData('application/x-canvas-image')
|
|
2420
|
+
if (!raw) {
|
|
2421
|
+
const id = dataTransfer.getData('text/plain')
|
|
2422
|
+
if (id && id.includes('/')) raw = JSON.stringify({ id })
|
|
2423
|
+
}
|
|
2424
|
+
if (!raw) return
|
|
2425
|
+
try {
|
|
2426
|
+
const reference = JSON.parse(raw)
|
|
2427
|
+
const assetId = String(reference.id || '').trim()
|
|
2428
|
+
const image = state.images.find((candidate) => candidate.id === assetId)
|
|
2429
|
+
if (!image || image.kind !== 'image') {
|
|
2430
|
+
item.error = '只能加入图片素材'
|
|
2431
|
+
renderCanvas()
|
|
2432
|
+
return
|
|
2433
|
+
}
|
|
2434
|
+
if (editor.frames.length >= editor.rows * editor.columns) {
|
|
2435
|
+
if (editor.rows < 64) item.rows = editor.rows + 1
|
|
2436
|
+
else if (editor.columns < 64) item.columns = editor.columns + 1
|
|
2437
|
+
else {
|
|
2438
|
+
item.error = '网格已满,无法继续添加图片'
|
|
2439
|
+
renderCanvas()
|
|
2440
|
+
return
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
const frames = [...editor.frames]
|
|
2444
|
+
frames.splice(Math.min(targetIndex, frames.length), 0, { kind: 'asset', assetId })
|
|
2445
|
+
item.frames = frames
|
|
2446
|
+
item.error = null
|
|
2447
|
+
await commitFrameEditorChange(editorId)
|
|
2448
|
+
} catch {
|
|
2449
|
+
item.error = '无法读取拖入的图片'
|
|
2450
|
+
renderCanvas()
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
async function saveFrameEditorAsSheet(editorId) {
|
|
2455
|
+
if (hasActiveOperations()) return
|
|
2456
|
+
const item = frameEditorLayoutItem(editorId)
|
|
2457
|
+
const editor = normalizeFrameEditor(item)
|
|
2458
|
+
if (!item || !editor) return
|
|
2459
|
+
if (editor.frames.length < 2 || editor.rows * editor.columns < 2) {
|
|
2460
|
+
item.error = '至少需要两帧和两个格子才能保存序列帧'
|
|
2461
|
+
renderCanvas()
|
|
2462
|
+
return
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
const activeOperation = beginActiveOperation('frame-sheet-save')
|
|
2466
|
+
state.error = ''
|
|
2467
|
+
item.error = null
|
|
2468
|
+
const editorSnapshot = serializeLayoutItem(item)
|
|
2469
|
+
const operationId = createCanvasClientId('pending_frame_sheet')
|
|
2470
|
+
const parentIds = frameEditorParentIds(item)
|
|
2471
|
+
try {
|
|
2472
|
+
state.layoutItems = state.layoutItems.map((candidate) =>
|
|
2473
|
+
candidate.id === editorId
|
|
2474
|
+
? {
|
|
2475
|
+
id: operationId,
|
|
2476
|
+
type: 'pending',
|
|
2477
|
+
name: '序列帧保存中',
|
|
2478
|
+
x: item.x,
|
|
2479
|
+
y: item.y,
|
|
2480
|
+
width: frameEditorWidth(item.width),
|
|
2481
|
+
height: item.height ?? null,
|
|
2482
|
+
media: 'image',
|
|
2483
|
+
references: parentIds.map((id) => ({ id, role: 'source' })),
|
|
2484
|
+
}
|
|
2485
|
+
: candidate,
|
|
2486
|
+
)
|
|
2487
|
+
setSelection([operationId])
|
|
2488
|
+
await saveLayout()
|
|
2489
|
+
renderCanvas()
|
|
2490
|
+
renderChrome()
|
|
2491
|
+
const result = await api('/api/frame-sheet/edit', {
|
|
2492
|
+
method: 'POST',
|
|
2493
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2494
|
+
body: JSON.stringify({
|
|
2495
|
+
source_id: editor.sourceId,
|
|
2496
|
+
source_rows: editor.sourceRows,
|
|
2497
|
+
source_columns: editor.sourceColumns,
|
|
2498
|
+
rows: editor.rows,
|
|
2499
|
+
columns: editor.columns,
|
|
2500
|
+
fps: editor.fps,
|
|
2501
|
+
frames: editor.frames.map((frame) =>
|
|
2502
|
+
frame.kind === 'source_cell'
|
|
2503
|
+
? { kind: 'source_cell', index: frame.index }
|
|
2504
|
+
: { kind: 'asset', asset_id: frame.assetId },
|
|
2505
|
+
),
|
|
2506
|
+
client_operation_id: operationId,
|
|
2507
|
+
}),
|
|
2508
|
+
})
|
|
2509
|
+
await reload()
|
|
2510
|
+
if (result.id) setSelection([result.id])
|
|
2511
|
+
renderCanvas()
|
|
2512
|
+
renderChrome()
|
|
2513
|
+
} catch (err) {
|
|
2514
|
+
state.error = String(err.message || err)
|
|
2515
|
+
await reload().catch(() => undefined)
|
|
2516
|
+
state.layoutItems = [
|
|
2517
|
+
...state.layoutItems.filter(
|
|
2518
|
+
(candidate) => candidate.id !== operationId && candidate.id !== editorId,
|
|
2519
|
+
),
|
|
2520
|
+
editorSnapshot,
|
|
2521
|
+
]
|
|
2522
|
+
await saveLayout()
|
|
2523
|
+
setSelection([editorId])
|
|
2524
|
+
renderCanvas()
|
|
2525
|
+
renderChrome()
|
|
2526
|
+
} finally {
|
|
2527
|
+
endActiveOperation(activeOperation)
|
|
2528
|
+
renderChrome()
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function mountFrameEditorNode(container, node) {
|
|
2533
|
+
const editor = normalizeFrameEditor(node)
|
|
2534
|
+
if (!editor) return
|
|
2535
|
+
container.classList.add('is-frame-editor')
|
|
2536
|
+
|
|
2537
|
+
const head = document.createElement('div')
|
|
2538
|
+
head.className = 'frame-editor-head'
|
|
2539
|
+
const title = document.createElement('strong')
|
|
2540
|
+
title.textContent = node.name || '序列帧编辑'
|
|
2541
|
+
const summary = document.createElement('span')
|
|
2542
|
+
summary.textContent = `${editor.frames.length} 帧 · ${editor.rows}×${editor.columns}`
|
|
2543
|
+
head.append(title, summary)
|
|
2544
|
+
|
|
2545
|
+
const toolbar = document.createElement('div')
|
|
2546
|
+
toolbar.className = 'frame-editor-toolbar frame-editor-interactive'
|
|
2547
|
+
const rowLabel = document.createElement('label')
|
|
2548
|
+
rowLabel.className = 'frame-editor-dimension-field'
|
|
2549
|
+
rowLabel.textContent = '行数'
|
|
2550
|
+
const rowInput = document.createElement('input')
|
|
2551
|
+
rowInput.type = 'number'
|
|
2552
|
+
rowInput.min = '1'
|
|
2553
|
+
rowInput.max = '64'
|
|
2554
|
+
rowInput.step = '1'
|
|
2555
|
+
rowInput.value = String(editor.rows)
|
|
2556
|
+
rowInput.disabled = hasActiveOperations()
|
|
2557
|
+
rowInput.dataset.role = 'frame-editor-rows'
|
|
2558
|
+
rowInput.addEventListener('change', () =>
|
|
2559
|
+
updateFrameEditorDimension(node.id, 'row', rowInput.value),
|
|
2560
|
+
)
|
|
2561
|
+
rowLabel.appendChild(rowInput)
|
|
2562
|
+
const columnLabel = document.createElement('label')
|
|
2563
|
+
columnLabel.className = 'frame-editor-dimension-field'
|
|
2564
|
+
columnLabel.textContent = '列数'
|
|
2565
|
+
const columnInput = document.createElement('input')
|
|
2566
|
+
columnInput.type = 'number'
|
|
2567
|
+
columnInput.min = '1'
|
|
2568
|
+
columnInput.max = '64'
|
|
2569
|
+
columnInput.step = '1'
|
|
2570
|
+
columnInput.value = String(editor.columns)
|
|
2571
|
+
columnInput.disabled = hasActiveOperations()
|
|
2572
|
+
columnInput.dataset.role = 'frame-editor-columns'
|
|
2573
|
+
columnInput.addEventListener('change', () =>
|
|
2574
|
+
updateFrameEditorDimension(node.id, 'column', columnInput.value),
|
|
2575
|
+
)
|
|
2576
|
+
columnLabel.appendChild(columnInput)
|
|
2577
|
+
const fpsLabel = document.createElement('label')
|
|
2578
|
+
fpsLabel.className = 'frame-editor-fps-field'
|
|
2579
|
+
fpsLabel.textContent = 'FPS'
|
|
2580
|
+
const fpsInput = document.createElement('input')
|
|
2581
|
+
fpsInput.type = 'number'
|
|
2582
|
+
fpsInput.min = '0.000001'
|
|
2583
|
+
fpsInput.step = 'any'
|
|
2584
|
+
fpsInput.value = String(editor.fps)
|
|
2585
|
+
fpsInput.disabled = hasActiveOperations()
|
|
2586
|
+
fpsInput.addEventListener('change', () => updateFrameEditorFps(node.id, fpsInput.value))
|
|
2587
|
+
fpsLabel.appendChild(fpsInput)
|
|
2588
|
+
toolbar.append(rowLabel, columnLabel, fpsLabel)
|
|
2589
|
+
|
|
2590
|
+
const scroll = document.createElement('div')
|
|
2591
|
+
scroll.className = 'frame-editor-grid-scroll frame-editor-interactive'
|
|
2592
|
+
const grid = document.createElement('div')
|
|
2593
|
+
grid.className = 'frame-editor-grid'
|
|
2594
|
+
grid.dataset.accept = 'image'
|
|
2595
|
+
grid.style.gridTemplateColumns = `repeat(${editor.columns}, 72px)`
|
|
2596
|
+
const capacity = editor.rows * editor.columns
|
|
2597
|
+
for (let index = 0; index < capacity; index += 1) {
|
|
2598
|
+
const cell = document.createElement('div')
|
|
2599
|
+
const frame = editor.frames[index]
|
|
2600
|
+
cell.className = `frame-editor-cell${frame ? ' has-frame' : ' is-empty'}`
|
|
2601
|
+
cell.dataset.index = String(index)
|
|
2602
|
+
cell.addEventListener('dragover', (event) => {
|
|
2603
|
+
event.preventDefault()
|
|
2604
|
+
event.stopPropagation()
|
|
2605
|
+
event.dataTransfer.dropEffect = Array.from(event.dataTransfer.types).includes('application/x-frame-editor-frame')
|
|
2606
|
+
? 'move'
|
|
2607
|
+
: 'copy'
|
|
2608
|
+
cell.classList.add('is-drop')
|
|
2609
|
+
})
|
|
2610
|
+
cell.addEventListener('dragleave', () => cell.classList.remove('is-drop'))
|
|
2611
|
+
cell.addEventListener('drop', async (event) => {
|
|
2612
|
+
event.preventDefault()
|
|
2613
|
+
event.stopPropagation()
|
|
2614
|
+
interaction.htmlDrag = false
|
|
2615
|
+
clearRefDragHighlight()
|
|
2616
|
+
await dropOnFrameEditor(node.id, index, event.dataTransfer)
|
|
2617
|
+
})
|
|
2618
|
+
if (frame) {
|
|
2619
|
+
cell.draggable = !hasActiveOperations()
|
|
2620
|
+
cell.appendChild(frameEditorThumbnail(editor, frame))
|
|
2621
|
+
const order = document.createElement('span')
|
|
2622
|
+
order.className = 'frame-editor-order'
|
|
2623
|
+
order.textContent = String(index + 1)
|
|
2624
|
+
const remove = document.createElement('button')
|
|
2625
|
+
remove.type = 'button'
|
|
2626
|
+
remove.className = 'frame-editor-remove'
|
|
2627
|
+
remove.textContent = '×'
|
|
2628
|
+
remove.title = '删除这一帧'
|
|
2629
|
+
remove.disabled = hasActiveOperations()
|
|
2630
|
+
remove.addEventListener('click', (event) => {
|
|
2631
|
+
event.stopPropagation()
|
|
2632
|
+
deleteFrameEditorFrame(node.id, index)
|
|
2633
|
+
})
|
|
2634
|
+
cell.append(order, remove)
|
|
2635
|
+
cell.addEventListener('dragstart', (event) => {
|
|
2636
|
+
event.stopPropagation()
|
|
2637
|
+
event.dataTransfer.setData(
|
|
2638
|
+
'application/x-frame-editor-frame',
|
|
2639
|
+
JSON.stringify({ editorId: node.id, index }),
|
|
2640
|
+
)
|
|
2641
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
2642
|
+
interaction.htmlDrag = true
|
|
2643
|
+
cell.classList.add('is-dragging')
|
|
2644
|
+
})
|
|
2645
|
+
cell.addEventListener('dragend', () => {
|
|
2646
|
+
interaction.htmlDrag = false
|
|
2647
|
+
cell.classList.remove('is-dragging')
|
|
2648
|
+
})
|
|
2649
|
+
} else {
|
|
2650
|
+
const empty = document.createElement('span')
|
|
2651
|
+
empty.className = 'frame-editor-empty-label'
|
|
2652
|
+
empty.textContent = '拖入'
|
|
2653
|
+
cell.appendChild(empty)
|
|
2654
|
+
}
|
|
2655
|
+
grid.appendChild(cell)
|
|
2656
|
+
}
|
|
2657
|
+
scroll.appendChild(grid)
|
|
2658
|
+
|
|
2659
|
+
const workspace = document.createElement('div')
|
|
2660
|
+
workspace.className = 'frame-editor-workspace frame-editor-interactive'
|
|
2661
|
+
const previewPanel = document.createElement('aside')
|
|
2662
|
+
previewPanel.className = 'frame-editor-preview'
|
|
2663
|
+
const previewHead = document.createElement('div')
|
|
2664
|
+
previewHead.className = 'frame-editor-preview-head'
|
|
2665
|
+
const previewTitle = document.createElement('strong')
|
|
2666
|
+
previewTitle.textContent = '实时预览'
|
|
2667
|
+
const previewCounter = document.createElement('span')
|
|
2668
|
+
previewCounter.dataset.role = 'frame-editor-preview-counter'
|
|
2669
|
+
previewHead.append(previewTitle, previewCounter)
|
|
2670
|
+
const previewViewport = document.createElement('div')
|
|
2671
|
+
previewViewport.className = 'frame-editor-preview-viewport'
|
|
2672
|
+
const previewStage = document.createElement('div')
|
|
2673
|
+
previewStage.className = 'frame-editor-preview-stage'
|
|
2674
|
+
previewStage.dataset.editorId = node.id
|
|
2675
|
+
previewViewport.appendChild(previewStage)
|
|
2676
|
+
const previewHint = document.createElement('span')
|
|
2677
|
+
previewHint.className = 'frame-editor-preview-hint'
|
|
2678
|
+
previewHint.textContent = '按照当前顺序循环播放'
|
|
2679
|
+
previewPanel.append(previewHead, previewViewport, previewHint)
|
|
2680
|
+
workspace.append(scroll, previewPanel)
|
|
2681
|
+
|
|
2682
|
+
const play = state.frameSheetPlayback[node.id] || { index: 0, acc: 0 }
|
|
2683
|
+
if (editor.frames.length) play.index %= editor.frames.length
|
|
2684
|
+
else play.index = 0
|
|
2685
|
+
state.frameSheetPlayback[node.id] = play
|
|
2686
|
+
applyFrameEditorPreviewFrame(previewStage, editor, play.index)
|
|
2687
|
+
fitFrameEditorPreviewStage(previewStage, editor)
|
|
2688
|
+
|
|
2689
|
+
const hint = document.createElement('p')
|
|
2690
|
+
hint.className = `frame-editor-hint${node.error ? ' is-error' : ''}`
|
|
2691
|
+
hint.textContent = node.error || '拖入其他图片可添加帧;拖动格子可交换顺序。'
|
|
2692
|
+
|
|
2693
|
+
const footer = document.createElement('div')
|
|
2694
|
+
footer.className = 'frame-editor-footer frame-editor-interactive'
|
|
2695
|
+
const saveButton = document.createElement('button')
|
|
2696
|
+
saveButton.type = 'button'
|
|
2697
|
+
saveButton.className = 'tool-btn'
|
|
2698
|
+
saveButton.textContent = hasActiveOperations() ? '正在保存…' : '保存成为序列帧'
|
|
2699
|
+
saveButton.disabled = hasActiveOperations() || editor.frames.length < 2 || capacity < 2
|
|
2700
|
+
saveButton.addEventListener('click', () => saveFrameEditorAsSheet(node.id))
|
|
2701
|
+
footer.appendChild(saveButton)
|
|
2702
|
+
|
|
2703
|
+
toolbar.addEventListener('pointerdown', (event) => event.stopPropagation())
|
|
2704
|
+
workspace.addEventListener('pointerdown', (event) => event.stopPropagation())
|
|
2705
|
+
footer.addEventListener('pointerdown', (event) => event.stopPropagation())
|
|
2706
|
+
container.append(head, toolbar, workspace, hint, footer)
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
function mediaWorldSize(id) {
|
|
2710
|
+
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
2711
|
+
const nodeElement = findNodeEl(id)
|
|
2712
|
+
const mediaElement = nodeElement?.querySelector(
|
|
2713
|
+
"img[data-role='frame'], video[data-role='frame'], .frame-sheet-preview",
|
|
2714
|
+
)
|
|
2715
|
+
const scale = Number(state.camera.scale) || 1
|
|
2716
|
+
const mediaRect = mediaElement?.getBoundingClientRect()
|
|
2717
|
+
const width = Number(item?.width) || (mediaRect?.width ? mediaRect.width / scale : DEFAULT_W)
|
|
2718
|
+
let height = Number(item?.height) || (mediaRect?.height ? mediaRect.height / scale : 0)
|
|
2719
|
+
if (!(height > 0)) {
|
|
2720
|
+
const dims = state.imageDims[id]
|
|
2721
|
+
if (dims?.w > 0 && dims?.h > 0) height = width * (dims.h / dims.w)
|
|
2722
|
+
}
|
|
2723
|
+
if (!(height > 0) && isVideoItem(item)) {
|
|
2724
|
+
const match = String(item?.gen?.ratio || item?.ratio || '16:9').match(/^(\d+)\s*:\s*(\d+)$/)
|
|
2725
|
+
if (match) height = width * (Number(match[2]) / Number(match[1]))
|
|
2726
|
+
}
|
|
2727
|
+
return { width, height: height > 0 ? height : width }
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function startMediaResize(event, node, corner) {
|
|
2731
|
+
if (event.button !== 0) return
|
|
2732
|
+
event.preventDefault()
|
|
2733
|
+
event.stopPropagation()
|
|
2734
|
+
hideCtxMenu()
|
|
2735
|
+
const current = state.layoutItems.find((item) => item.id === node.id)
|
|
2736
|
+
if (!current) return
|
|
2737
|
+
if (!isSelected(node.id) || state.selectedIds.size !== 1) {
|
|
2738
|
+
setSelection([node.id])
|
|
2739
|
+
syncSelectionClass()
|
|
2740
|
+
renderChrome()
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
const clickedSize = mediaWorldSize(node.id)
|
|
2744
|
+
const x = Number(current.x) || 0
|
|
2745
|
+
const y = Number(current.y) || 0
|
|
2746
|
+
const east = corner.includes('e')
|
|
2747
|
+
const south = corner.includes('s')
|
|
2748
|
+
const anchor = {
|
|
2749
|
+
x: east ? x : x + clickedSize.width,
|
|
2750
|
+
y: south ? y : y + clickedSize.height,
|
|
2751
|
+
}
|
|
2752
|
+
const diagonal = {
|
|
2753
|
+
x: east ? clickedSize.width : -clickedSize.width,
|
|
2754
|
+
y: south ? clickedSize.height : -clickedSize.height,
|
|
2755
|
+
}
|
|
2756
|
+
const originals = new Map()
|
|
2757
|
+
originals.set(current.id, {
|
|
2758
|
+
x,
|
|
2759
|
+
y,
|
|
2760
|
+
width: clickedSize.width,
|
|
2761
|
+
height: clickedSize.height,
|
|
2762
|
+
})
|
|
2763
|
+
|
|
2764
|
+
interaction.mode = 'resize'
|
|
2765
|
+
interaction.resize = {
|
|
2766
|
+
pointerId: event.pointerId,
|
|
2767
|
+
startWorld: clientToWorld(event.clientX, event.clientY),
|
|
2768
|
+
anchor,
|
|
2769
|
+
diagonal,
|
|
2770
|
+
originals,
|
|
2771
|
+
minimumScale: Math.min(
|
|
2772
|
+
1,
|
|
2773
|
+
MIN_MEDIA_RESIZE_SIDE / Math.max(1, Math.min(clickedSize.width, clickedSize.height)),
|
|
2774
|
+
),
|
|
2775
|
+
}
|
|
2776
|
+
els.viewport.classList.add('is-resizing')
|
|
2777
|
+
els.viewport.dataset.resizeCorner = corner
|
|
2778
|
+
els.viewport.setPointerCapture?.(event.pointerId)
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function appendMediaResizeHandles(container, node) {
|
|
2782
|
+
for (const corner of ['nw', 'ne', 'sw', 'se']) {
|
|
2783
|
+
const handle = document.createElement('button')
|
|
2784
|
+
handle.type = 'button'
|
|
2785
|
+
handle.className = 'canvas-resize-handle'
|
|
2786
|
+
handle.dataset.corner = corner
|
|
2787
|
+
handle.setAttribute('aria-label', '等比缩放画布元素')
|
|
2788
|
+
handle.addEventListener('pointerdown', (event) => startMediaResize(event, node, corner))
|
|
2789
|
+
container.appendChild(handle)
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
function renderCanvas() {
|
|
2794
|
+
const nodes = buildNodes()
|
|
2795
|
+
const alive = new Set(nodes.map((n) => n.id))
|
|
2796
|
+
state.selectedIds = new Set([...state.selectedIds].filter((id) => alive.has(id)))
|
|
2797
|
+
for (const id of Object.keys(state.frameSheetPlayback)) {
|
|
2798
|
+
if (!alive.has(id)) delete state.frameSheetPlayback[id]
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
els.world.innerHTML = ''
|
|
2802
|
+
const dependencyLayer = createDependencyLayer()
|
|
2803
|
+
els.world.appendChild(dependencyLayer)
|
|
2804
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
2805
|
+
const node = nodes[index]
|
|
2806
|
+
const el = document.createElement('div')
|
|
2807
|
+
el.className = `canvas-node${isSelected(node.id) ? ' is-selected' : ''}${
|
|
2808
|
+
isGenDialog(node) || isVideoDialog(node) ? ' is-gen-dialog' : ''}${
|
|
2809
|
+
isVideoDialog(node) ? ' is-video-dialog' : ''
|
|
2810
|
+
}${isVideoItem(node) ? ' is-video' : ''}`
|
|
2811
|
+
el.style.left = `${node.x}px`
|
|
2812
|
+
el.style.top = `${node.y}px`
|
|
2813
|
+
el.style.width = `${node.width}px`
|
|
2814
|
+
el.style.zIndex = isSelected(node.id) ? String(1000 + index) : String(1 + index)
|
|
2815
|
+
el.dataset.id = node.id
|
|
2816
|
+
|
|
2817
|
+
if (isFrameEditor(node)) {
|
|
2818
|
+
mountFrameEditorNode(el, node)
|
|
2819
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2820
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2821
|
+
e.preventDefault()
|
|
2822
|
+
e.stopPropagation()
|
|
2823
|
+
if (!isSelected(node.id)) setSelection([node.id])
|
|
2824
|
+
state.ctxTargetId = node.id
|
|
2825
|
+
syncSelectionClass()
|
|
2826
|
+
renderChrome()
|
|
2827
|
+
showCtxMenu(e.clientX, e.clientY, node)
|
|
2828
|
+
})
|
|
2829
|
+
els.world.appendChild(el)
|
|
2830
|
+
continue
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
if (isGenDialog(node)) {
|
|
2834
|
+
mountGenDialogNode(el, node)
|
|
2835
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2836
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2837
|
+
e.preventDefault()
|
|
2838
|
+
e.stopPropagation()
|
|
2839
|
+
if (!isSelected(node.id)) setSelection([node.id])
|
|
2840
|
+
state.ctxTargetId = node.id
|
|
2841
|
+
syncSelectionClass()
|
|
2842
|
+
renderChrome()
|
|
2843
|
+
showCtxMenu(e.clientX, e.clientY, node)
|
|
2844
|
+
})
|
|
2845
|
+
els.world.appendChild(el)
|
|
2846
|
+
continue
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
if (isVideoDialog(node)) {
|
|
2850
|
+
mountVideoGenDialogNode(el, node)
|
|
2851
|
+
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2852
|
+
el.addEventListener('contextmenu', (e) => {
|
|
2853
|
+
e.preventDefault()
|
|
2854
|
+
e.stopPropagation()
|
|
2855
|
+
if (!isSelected(node.id)) setSelection([node.id])
|
|
2856
|
+
state.ctxTargetId = node.id
|
|
2857
|
+
syncSelectionClass()
|
|
2858
|
+
renderChrome()
|
|
2859
|
+
showCtxMenu(e.clientX, e.clientY, node)
|
|
2860
|
+
})
|
|
2861
|
+
els.world.appendChild(el)
|
|
2862
|
+
continue
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
if (isPending(node)) {
|
|
2866
|
+
el.classList.add('is-pending')
|
|
2172
2867
|
if (node.media === 'video') el.classList.add('is-pending-video')
|
|
2173
2868
|
if (node.error) el.classList.add('is-pending-error')
|
|
2174
2869
|
if (node.height) el.style.height = `${node.height}px`
|
|
@@ -2235,7 +2930,7 @@ function renderCanvas() {
|
|
|
2235
2930
|
if (!video.videoWidth || !video.videoHeight) return
|
|
2236
2931
|
video.style.aspectRatio = `${video.videoWidth} / ${video.videoHeight}`
|
|
2237
2932
|
const layoutItem = state.layoutItems.find((it) => it.id === node.id)
|
|
2238
|
-
if (layoutItem && layoutItem.height != null) {
|
|
2933
|
+
if (layoutItem && layoutItem.manualSize !== true && layoutItem.height != null) {
|
|
2239
2934
|
layoutItem.height = null
|
|
2240
2935
|
}
|
|
2241
2936
|
requestDependencyEdgeUpdate()
|
|
@@ -2246,6 +2941,7 @@ function renderCanvas() {
|
|
|
2246
2941
|
caption.textContent = node.name
|
|
2247
2942
|
|
|
2248
2943
|
el.append(handle, video, caption)
|
|
2944
|
+
appendMediaResizeHandles(el, node)
|
|
2249
2945
|
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2250
2946
|
el.addEventListener('contextmenu', (e) => {
|
|
2251
2947
|
e.preventDefault()
|
|
@@ -2260,23 +2956,16 @@ function renderCanvas() {
|
|
|
2260
2956
|
continue
|
|
2261
2957
|
}
|
|
2262
2958
|
|
|
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
2959
|
el.classList.add('is-image')
|
|
2271
2960
|
|
|
2272
2961
|
const handle = document.createElement('div')
|
|
2273
2962
|
handle.className = 'ref-handle'
|
|
2274
|
-
handle.title =
|
|
2963
|
+
handle.title = '拖到生图对话框引用'
|
|
2275
2964
|
handle.textContent = '引用'
|
|
2276
2965
|
handle.draggable = true
|
|
2277
2966
|
handle.addEventListener('dragstart', (e) => {
|
|
2278
2967
|
e.stopPropagation()
|
|
2279
|
-
const refId =
|
|
2968
|
+
const refId = node.id
|
|
2280
2969
|
e.dataTransfer.setData(
|
|
2281
2970
|
'application/x-canvas-image',
|
|
2282
2971
|
JSON.stringify({
|
|
@@ -2296,21 +2985,66 @@ function renderCanvas() {
|
|
|
2296
2985
|
})
|
|
2297
2986
|
handle.addEventListener('pointerdown', (e) => e.stopPropagation())
|
|
2298
2987
|
|
|
2299
|
-
const
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2988
|
+
const frameSheet = frameSheetPreviewMeta(node)
|
|
2989
|
+
let mediaPreview
|
|
2990
|
+
if (frameSheet) {
|
|
2991
|
+
el.classList.add('is-frame-sheet-preview')
|
|
2992
|
+
const preview = document.createElement('div')
|
|
2993
|
+
preview.className = 'frame-sheet-preview'
|
|
2994
|
+
preview.dataset.assetId = node.id
|
|
2995
|
+
preview.style.aspectRatio =
|
|
2996
|
+
frameSheet.cellWidth && frameSheet.cellHeight
|
|
2997
|
+
? `${frameSheet.cellWidth} / ${frameSheet.cellHeight}`
|
|
2998
|
+
: '1 / 1'
|
|
2999
|
+
const image = document.createElement('img')
|
|
3000
|
+
image.src = node.url
|
|
3001
|
+
image.alt = node.name
|
|
3002
|
+
image.draggable = false
|
|
3003
|
+
image.dataset.role = 'frame-sheet-image'
|
|
3004
|
+
image.style.width = `${frameSheet.columns * 100}%`
|
|
3005
|
+
image.style.height = `${frameSheet.rows * 100}%`
|
|
3006
|
+
image.addEventListener('load', () => {
|
|
3007
|
+
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
|
3008
|
+
state.imageDims[node.id] = { w: image.naturalWidth, h: image.naturalHeight }
|
|
3009
|
+
preview.style.aspectRatio = `${image.naturalWidth * frameSheet.rows} / ${image.naturalHeight * frameSheet.columns}`
|
|
3010
|
+
const frameWidth = Number(frameSheet.cellWidth) > 0
|
|
3011
|
+
? Number(frameSheet.cellWidth)
|
|
3012
|
+
: image.naturalWidth / frameSheet.columns
|
|
3013
|
+
const frameHeight = Number(frameSheet.cellHeight) > 0
|
|
3014
|
+
? Number(frameSheet.cellHeight)
|
|
3015
|
+
: image.naturalHeight / frameSheet.rows
|
|
3016
|
+
applyImageDisplaySize(node.id, frameWidth, frameHeight)
|
|
3017
|
+
}
|
|
3018
|
+
requestDependencyEdgeUpdate()
|
|
3019
|
+
})
|
|
3020
|
+
preview.appendChild(image)
|
|
3021
|
+
const play = state.frameSheetPlayback[node.id] || { index: 0, acc: 0 }
|
|
3022
|
+
state.frameSheetPlayback[node.id] = play
|
|
3023
|
+
applyFrameSheetPreviewFrame(preview, frameSheet, play.index)
|
|
3024
|
+
mediaPreview = preview
|
|
3025
|
+
} else {
|
|
3026
|
+
const image = document.createElement('img')
|
|
3027
|
+
image.src = node.url
|
|
3028
|
+
image.alt = node.name
|
|
3029
|
+
image.draggable = false
|
|
3030
|
+
image.dataset.role = 'frame'
|
|
3031
|
+
image.addEventListener('load', () => {
|
|
3032
|
+
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
|
3033
|
+
state.imageDims[node.id] = { w: image.naturalWidth, h: image.naturalHeight }
|
|
3034
|
+
applyImageDisplaySize(node.id, image.naturalWidth, image.naturalHeight)
|
|
3035
|
+
if (state.selectedIds.has(node.id)) renderInspector()
|
|
3036
|
+
}
|
|
3037
|
+
requestDependencyEdgeUpdate()
|
|
3038
|
+
})
|
|
3039
|
+
mediaPreview = image
|
|
3040
|
+
}
|
|
2308
3041
|
|
|
2309
3042
|
const caption = document.createElement('div')
|
|
2310
3043
|
caption.className = 'canvas-node-caption'
|
|
2311
3044
|
caption.textContent = node.name
|
|
2312
3045
|
|
|
2313
|
-
el.append(handle,
|
|
3046
|
+
el.append(handle, mediaPreview, caption)
|
|
3047
|
+
appendMediaResizeHandles(el, node)
|
|
2314
3048
|
el.addEventListener('pointerdown', (e) => onNodePointerDown(e, node))
|
|
2315
3049
|
el.addEventListener('contextmenu', (e) => {
|
|
2316
3050
|
e.preventDefault()
|
|
@@ -2327,24 +3061,6 @@ function renderCanvas() {
|
|
|
2327
3061
|
applyCamera()
|
|
2328
3062
|
}
|
|
2329
3063
|
|
|
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
3064
|
function inspSelectField(labelText, selectEl) {
|
|
2349
3065
|
const field = el('label', 'insp-field')
|
|
2350
3066
|
field.appendChild(document.createTextNode(labelText))
|
|
@@ -2553,122 +3269,6 @@ function renderInspectorImage(id) {
|
|
|
2553
3269
|
els.inspectorBody.appendChild(section)
|
|
2554
3270
|
}
|
|
2555
3271
|
|
|
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
3272
|
function renderInspectorMulti(ids) {
|
|
2673
3273
|
els.inspectorTitle.textContent = `已选中 ${ids.length} 项`
|
|
2674
3274
|
const list = el('div', 'insp-list')
|
|
@@ -2678,9 +3278,12 @@ function renderInspectorMulti(ids) {
|
|
|
2678
3278
|
btn.type = 'button'
|
|
2679
3279
|
const name = el('div', 'name')
|
|
2680
3280
|
const meta = el('div', 'meta')
|
|
2681
|
-
if (
|
|
2682
|
-
|
|
2683
|
-
|
|
3281
|
+
if (isFrameEditor(item)) {
|
|
3282
|
+
const editor = normalizeFrameEditor(item)
|
|
3283
|
+
name.textContent = item.name || '序列帧编辑'
|
|
3284
|
+
meta.textContent = editor
|
|
3285
|
+
? `编辑器 · ${editor.frames.length} 帧 · ${editor.rows}×${editor.columns}`
|
|
3286
|
+
: '序列帧编辑器'
|
|
2684
3287
|
} else if (isGenDialog(item)) {
|
|
2685
3288
|
name.textContent = item.name || '图片生成'
|
|
2686
3289
|
meta.textContent = '生图对话框'
|
|
@@ -2731,13 +3334,20 @@ function renderInspector() {
|
|
|
2731
3334
|
|
|
2732
3335
|
const id = ids[0]
|
|
2733
3336
|
const item = getSelectedItem(id)
|
|
2734
|
-
if (
|
|
2735
|
-
|
|
2736
|
-
|
|
3337
|
+
if (isFrameEditor(item)) {
|
|
3338
|
+
const editor = normalizeFrameEditor(item)
|
|
3339
|
+
els.inspectorTitle.textContent = '序列帧编辑'
|
|
3340
|
+
const section = el('div', 'insp-section')
|
|
3341
|
+
appendKv(section, [
|
|
3342
|
+
['来源', editor ? imageName(editor.sourceId) : '—'],
|
|
3343
|
+
['切分', editor ? `${editor.sourceRows}×${editor.sourceColumns}` : '—'],
|
|
3344
|
+
['输出网格', editor ? `${editor.rows}×${editor.columns}` : '—'],
|
|
3345
|
+
['帧数', editor?.frames.length ?? 0],
|
|
3346
|
+
['FPS', editor?.fps ?? '—'],
|
|
3347
|
+
])
|
|
3348
|
+
els.inspectorBody.appendChild(section)
|
|
2737
3349
|
return
|
|
2738
3350
|
}
|
|
2739
|
-
|
|
2740
|
-
state.editingSeqId = null
|
|
2741
3351
|
if (isGenDialog(item)) {
|
|
2742
3352
|
renderInspectorGenDialog(item)
|
|
2743
3353
|
return
|
|
@@ -2797,145 +3407,19 @@ function renderChrome() {
|
|
|
2797
3407
|
renderInspector()
|
|
2798
3408
|
}
|
|
2799
3409
|
|
|
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
3410
|
async function copyCanvasItem(targetId) {
|
|
2918
3411
|
const item = state.layoutItems.find((it) => it.id === targetId)
|
|
2919
|
-
if (!item || isPending(item) || isGenDialog(item) || isVideoDialog(item)) return
|
|
3412
|
+
if (!item || isPending(item) || isGenDialog(item) || isVideoDialog(item) || isFrameEditor(item)) return
|
|
2920
3413
|
|
|
2921
|
-
const ids =
|
|
3414
|
+
const ids = [item.id]
|
|
2922
3415
|
if (!ids.length) return
|
|
2923
3416
|
|
|
2924
3417
|
try {
|
|
2925
3418
|
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
3419
|
await api('/api/clipboard/copy', {
|
|
2936
3420
|
method: 'POST',
|
|
2937
3421
|
headers: { 'Content-Type': 'application/json' },
|
|
2938
|
-
body: JSON.stringify({ ids
|
|
3422
|
+
body: JSON.stringify({ ids }),
|
|
2939
3423
|
})
|
|
2940
3424
|
renderChrome()
|
|
2941
3425
|
} catch (err) {
|
|
@@ -2945,8 +3429,8 @@ async function copyCanvasItem(targetId) {
|
|
|
2945
3429
|
}
|
|
2946
3430
|
|
|
2947
3431
|
async function pasteClipboardFiles(point) {
|
|
2948
|
-
if (!point ||
|
|
2949
|
-
|
|
3432
|
+
if (!point || hasActiveOperations()) return
|
|
3433
|
+
const activeOperation = beginActiveOperation('clipboard-paste')
|
|
2950
3434
|
state.error = ''
|
|
2951
3435
|
renderChrome()
|
|
2952
3436
|
try {
|
|
@@ -2957,29 +3441,7 @@ async function pasteClipboardFiles(point) {
|
|
|
2957
3441
|
})
|
|
2958
3442
|
const assets = Array.isArray(result.assets) ? result.assets : []
|
|
2959
3443
|
if (!assets.length) throw new Error('剪贴板中没有可粘贴的素材文件')
|
|
2960
|
-
const sequence = result.sequence?.kind === 'sequence' ? result.sequence : null
|
|
2961
3444
|
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
3445
|
const columns = Math.min(5, Math.max(1, Math.ceil(Math.sqrt(assets.length))))
|
|
2984
3446
|
assets.forEach((asset, index) => {
|
|
2985
3447
|
const width = asset.kind === 'video' ? 280 : DEFAULT_W
|
|
@@ -3000,7 +3462,7 @@ async function pasteClipboardFiles(point) {
|
|
|
3000
3462
|
} catch (err) {
|
|
3001
3463
|
state.error = String(err.message || err)
|
|
3002
3464
|
} finally {
|
|
3003
|
-
|
|
3465
|
+
endActiveOperation(activeOperation)
|
|
3004
3466
|
renderChrome()
|
|
3005
3467
|
}
|
|
3006
3468
|
}
|
|
@@ -3026,7 +3488,8 @@ async function deleteCanvasItems(targetIds, { confirm = true } = {}) {
|
|
|
3026
3488
|
.filter(Boolean)
|
|
3027
3489
|
const imageIds = ids.filter((id) => {
|
|
3028
3490
|
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
3029
|
-
return !item ||
|
|
3491
|
+
return !item ||
|
|
3492
|
+
(!isGenDialog(item) && !isVideoDialog(item) && !isPending(item) && !isFrameEditor(item))
|
|
3030
3493
|
})
|
|
3031
3494
|
|
|
3032
3495
|
state.deleting = true
|
|
@@ -3041,11 +3504,9 @@ async function deleteCanvasItems(targetIds, { confirm = true } = {}) {
|
|
|
3041
3504
|
})
|
|
3042
3505
|
|
|
3043
3506
|
for (const item of items) {
|
|
3044
|
-
if (isSequence(item)) delete state.playback[item.id]
|
|
3045
3507
|
if (isGenDialog(item)) delete state.genDialogs[item.id]
|
|
3046
3508
|
if (isVideoDialog(item)) delete state.videoDialogs[item.id]
|
|
3047
3509
|
}
|
|
3048
|
-
if (state.editingSeqId && selected.has(state.editingSeqId)) closeSeqModal()
|
|
3049
3510
|
for (const ds of Object.values(state.genDialogs)) {
|
|
3050
3511
|
ds.refs = ds.refs.filter((reference) => !selected.has(reference.id))
|
|
3051
3512
|
}
|
|
@@ -3067,16 +3528,20 @@ async function deleteCanvasItems(targetIds, { confirm = true } = {}) {
|
|
|
3067
3528
|
}
|
|
3068
3529
|
|
|
3069
3530
|
function resetSessionState() {
|
|
3531
|
+
clearTimeout(imageSizeSaveTimer)
|
|
3532
|
+
imageSizeSaveTimer = null
|
|
3070
3533
|
state.meta = null
|
|
3071
3534
|
state.images = []
|
|
3072
3535
|
state.layoutItems = []
|
|
3536
|
+
state.layoutRevision = 0
|
|
3537
|
+
layoutNextExpectedRevision = 0
|
|
3538
|
+
layoutSaveEpoch += 1
|
|
3073
3539
|
state.selectedIds = new Set()
|
|
3074
|
-
state.editingSeqId = null
|
|
3075
3540
|
state.genDialogs = {}
|
|
3076
3541
|
state.videoDialogs = {}
|
|
3077
3542
|
state.fileInputDialogId = null
|
|
3078
3543
|
state.mediaInputDialogId = null
|
|
3079
|
-
state.
|
|
3544
|
+
state.activeOperations.clear()
|
|
3080
3545
|
state.deleting = false
|
|
3081
3546
|
state.error = ''
|
|
3082
3547
|
state.camera = { x: 0, y: 0, scale: 1 }
|
|
@@ -3084,12 +3549,15 @@ function resetSessionState() {
|
|
|
3084
3549
|
state.ctxCanvasPoint = null
|
|
3085
3550
|
state.interactiveEditImageId = null
|
|
3086
3551
|
state.layerDecompositionImageId = null
|
|
3087
|
-
state.
|
|
3552
|
+
state.framePreviewImageId = null
|
|
3553
|
+
state.frameEditorSourceImageId = null
|
|
3554
|
+
state.frameSheetPlayback = {}
|
|
3088
3555
|
state.imageDims = {}
|
|
3089
3556
|
hideCtxMenu()
|
|
3090
3557
|
closeInteractiveEditModal(true)
|
|
3091
3558
|
closeLayerDecompositionModal(true)
|
|
3092
|
-
|
|
3559
|
+
closeFramePreviewModal(true)
|
|
3560
|
+
closeFrameEditorSplitModal(true)
|
|
3093
3561
|
if (els.world) els.world.innerHTML = ''
|
|
3094
3562
|
if (els.inspector) els.inspector.hidden = true
|
|
3095
3563
|
}
|
|
@@ -3244,6 +3712,8 @@ async function fetchCanvasSnapshot() {
|
|
|
3244
3712
|
return {
|
|
3245
3713
|
images: imagesData.images || [],
|
|
3246
3714
|
items: layoutData.items || [],
|
|
3715
|
+
version: layoutData.version,
|
|
3716
|
+
revision: layoutData.revision,
|
|
3247
3717
|
}
|
|
3248
3718
|
}
|
|
3249
3719
|
}
|
|
@@ -3251,6 +3721,11 @@ async function fetchCanvasSnapshot() {
|
|
|
3251
3721
|
function applyCanvasSnapshot(snapshot, force = false) {
|
|
3252
3722
|
const nextImages = snapshot.images || []
|
|
3253
3723
|
const nextItems = mergeLayout(nextImages, snapshot.items || [])
|
|
3724
|
+
const nextRevision = Number(snapshot.revision)
|
|
3725
|
+
if (Number.isInteger(nextRevision) && nextRevision >= 0) {
|
|
3726
|
+
state.layoutRevision = nextRevision
|
|
3727
|
+
if (layoutPendingSaveCount === 0) layoutNextExpectedRevision = nextRevision
|
|
3728
|
+
}
|
|
3254
3729
|
if (!force && JSON.stringify([state.images, state.layoutItems]) === JSON.stringify([nextImages, nextItems])) {
|
|
3255
3730
|
return false
|
|
3256
3731
|
}
|
|
@@ -3258,11 +3733,6 @@ function applyCanvasSnapshot(snapshot, force = false) {
|
|
|
3258
3733
|
state.layoutItems = nextItems
|
|
3259
3734
|
renderCanvas()
|
|
3260
3735
|
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
3736
|
return true
|
|
3267
3737
|
}
|
|
3268
3738
|
|
|
@@ -3285,7 +3755,7 @@ function hasEditableFocus() {
|
|
|
3285
3755
|
function shouldDeferExternalSync() {
|
|
3286
3756
|
return Boolean(
|
|
3287
3757
|
!state.project ||
|
|
3288
|
-
|
|
3758
|
+
hasActiveOperations() ||
|
|
3289
3759
|
state.refreshing ||
|
|
3290
3760
|
interaction.mode ||
|
|
3291
3761
|
document.hidden ||
|
|
@@ -3362,11 +3832,12 @@ async function boot() {
|
|
|
3362
3832
|
}
|
|
3363
3833
|
|
|
3364
3834
|
const interaction = {
|
|
3365
|
-
mode: null, // null | 'pan' | 'marquee' | 'move' | 'click' | 'generator-tool-drag'
|
|
3835
|
+
mode: null, // null | 'pan' | 'marquee' | 'move' | 'resize' | 'click' | 'generator-tool-drag'
|
|
3366
3836
|
htmlDrag: false,
|
|
3367
3837
|
pan: null,
|
|
3368
3838
|
marquee: null,
|
|
3369
3839
|
move: null,
|
|
3840
|
+
resize: null,
|
|
3370
3841
|
click: null,
|
|
3371
3842
|
}
|
|
3372
3843
|
|
|
@@ -3524,41 +3995,33 @@ function ensureLayoutItem(node) {
|
|
|
3524
3995
|
? 280
|
|
3525
3996
|
: DEFAULT_W),
|
|
3526
3997
|
height: isPending(node) || isVideoItem(node) ? node.height ?? null : null,
|
|
3527
|
-
...(
|
|
3998
|
+
...(isGenDialog(node)
|
|
3528
3999
|
? {
|
|
3529
|
-
type: '
|
|
3530
|
-
name: node.name,
|
|
3531
|
-
frames: [...node.frames],
|
|
3532
|
-
fps: node.fps || 8,
|
|
3533
|
-
loop: node.loop !== false,
|
|
4000
|
+
type: 'gen_dialog',
|
|
4001
|
+
name: node.name || '图片生成',
|
|
3534
4002
|
}
|
|
3535
|
-
:
|
|
4003
|
+
: isVideoDialog(node)
|
|
3536
4004
|
? {
|
|
3537
|
-
type: '
|
|
3538
|
-
name: node.name || '
|
|
4005
|
+
type: 'video_dialog',
|
|
4006
|
+
name: node.name || '视频生成',
|
|
3539
4007
|
}
|
|
3540
|
-
:
|
|
4008
|
+
: isPending(node)
|
|
3541
4009
|
? {
|
|
3542
|
-
type: '
|
|
3543
|
-
name: node.name || '
|
|
4010
|
+
type: 'pending',
|
|
4011
|
+
name: node.name || '生成中',
|
|
4012
|
+
prompt: node.prompt || '',
|
|
4013
|
+
size: node.size || '',
|
|
4014
|
+
model: node.model || '',
|
|
4015
|
+
media: node.media || 'image',
|
|
4016
|
+
ratio: node.ratio || '',
|
|
4017
|
+
error: node.error || null,
|
|
4018
|
+
references: (node.references || []).map((reference) => ({ ...reference })),
|
|
3544
4019
|
}
|
|
3545
|
-
:
|
|
4020
|
+
: isVideoItem(node)
|
|
3546
4021
|
? {
|
|
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 })),
|
|
4022
|
+
type: 'video',
|
|
3556
4023
|
}
|
|
3557
|
-
:
|
|
3558
|
-
? {
|
|
3559
|
-
type: 'video',
|
|
3560
|
-
}
|
|
3561
|
-
: {}),
|
|
4024
|
+
: {}),
|
|
3562
4025
|
})
|
|
3563
4026
|
return getNodePosition(node.id, node)
|
|
3564
4027
|
}
|
|
@@ -3648,6 +4111,7 @@ function startMarquee(e) {
|
|
|
3648
4111
|
|
|
3649
4112
|
function onNodePointerDown(e, node) {
|
|
3650
4113
|
if (e.target?.closest?.('.ref-handle')) return
|
|
4114
|
+
if (e.target?.closest?.('.canvas-resize-handle')) return
|
|
3651
4115
|
if (e.target?.closest?.('.gen-dialog-chrome .ghost-btn')) return
|
|
3652
4116
|
if (e.button === 2) return
|
|
3653
4117
|
|
|
@@ -3789,6 +4253,49 @@ els.viewport.addEventListener('pointermove', (e) => {
|
|
|
3789
4253
|
return
|
|
3790
4254
|
}
|
|
3791
4255
|
|
|
4256
|
+
if (interaction.mode === 'resize' && interaction.resize) {
|
|
4257
|
+
const resize = interaction.resize
|
|
4258
|
+
if (resize.pointerId !== e.pointerId) return
|
|
4259
|
+
const world = clientToWorld(e.clientX, e.clientY)
|
|
4260
|
+
const pointerDelta = {
|
|
4261
|
+
x: world.x - resize.startWorld.x,
|
|
4262
|
+
y: world.y - resize.startWorld.y,
|
|
4263
|
+
}
|
|
4264
|
+
const vector = {
|
|
4265
|
+
x: resize.diagonal.x + pointerDelta.x,
|
|
4266
|
+
y: resize.diagonal.y + pointerDelta.y,
|
|
4267
|
+
}
|
|
4268
|
+
const denominator =
|
|
4269
|
+
resize.diagonal.x * resize.diagonal.x + resize.diagonal.y * resize.diagonal.y
|
|
4270
|
+
const projectedScale = denominator > 0
|
|
4271
|
+
? (vector.x * resize.diagonal.x + vector.y * resize.diagonal.y) / denominator
|
|
4272
|
+
: 1
|
|
4273
|
+
const scale = Math.max(resize.minimumScale, projectedScale)
|
|
4274
|
+
|
|
4275
|
+
for (const [id, original] of resize.originals) {
|
|
4276
|
+
const x = resize.anchor.x + (original.x - resize.anchor.x) * scale
|
|
4277
|
+
const y = resize.anchor.y + (original.y - resize.anchor.y) * scale
|
|
4278
|
+
const width = Math.max(1, original.width * scale)
|
|
4279
|
+
const height = Math.max(1, original.height * scale)
|
|
4280
|
+
const item = state.layoutItems.find((candidate) => candidate.id === id)
|
|
4281
|
+
if (item) {
|
|
4282
|
+
item.x = x
|
|
4283
|
+
item.y = y
|
|
4284
|
+
item.width = width
|
|
4285
|
+
item.height = height
|
|
4286
|
+
item.manualSize = true
|
|
4287
|
+
}
|
|
4288
|
+
const nodeElement = findNodeEl(id)
|
|
4289
|
+
if (nodeElement) {
|
|
4290
|
+
nodeElement.style.left = `${x}px`
|
|
4291
|
+
nodeElement.style.top = `${y}px`
|
|
4292
|
+
nodeElement.style.width = `${width}px`
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
requestDependencyEdgeUpdate()
|
|
4296
|
+
return
|
|
4297
|
+
}
|
|
4298
|
+
|
|
3792
4299
|
if (interaction.mode === 'marquee' && interaction.marquee) {
|
|
3793
4300
|
updateMarquee(
|
|
3794
4301
|
interaction.marquee.x0,
|
|
@@ -3802,7 +4309,7 @@ els.viewport.addEventListener('pointermove', (e) => {
|
|
|
3802
4309
|
function endPointer(e) {
|
|
3803
4310
|
const mode = interaction.mode
|
|
3804
4311
|
|
|
3805
|
-
if (mode === 'move') {
|
|
4312
|
+
if (mode === 'move' || mode === 'resize') {
|
|
3806
4313
|
saveLayout(state.layoutItems)
|
|
3807
4314
|
}
|
|
3808
4315
|
|
|
@@ -3860,10 +4367,13 @@ function endPointer(e) {
|
|
|
3860
4367
|
}
|
|
3861
4368
|
|
|
3862
4369
|
els.viewport.classList.remove('is-panning')
|
|
4370
|
+
els.viewport.classList.remove('is-resizing')
|
|
4371
|
+
delete els.viewport.dataset.resizeCorner
|
|
3863
4372
|
interaction.mode = null
|
|
3864
4373
|
interaction.pan = null
|
|
3865
4374
|
interaction.marquee = null
|
|
3866
4375
|
interaction.move = null
|
|
4376
|
+
interaction.resize = null
|
|
3867
4377
|
interaction.click = null
|
|
3868
4378
|
}
|
|
3869
4379
|
|
|
@@ -3889,14 +4399,13 @@ window.addEventListener('keydown', async (e) => {
|
|
|
3889
4399
|
hideCtxMenu()
|
|
3890
4400
|
// 弹窗只允许点关闭/取消按钮退出,Esc 不关
|
|
3891
4401
|
if (
|
|
3892
|
-
!els.seqModal.hidden ||
|
|
3893
4402
|
!els.interactiveEditModal.hidden ||
|
|
3894
4403
|
!els.layerDecompositionModal.hidden ||
|
|
3895
4404
|
!els.smartSplitModal.hidden ||
|
|
3896
4405
|
!els.manualSplitModal.hidden ||
|
|
3897
4406
|
!els.frameExtractModal.hidden ||
|
|
3898
|
-
!els.
|
|
3899
|
-
!els.
|
|
4407
|
+
!els.framePreviewModal.hidden ||
|
|
4408
|
+
!els.frameEditorSplitModal.hidden ||
|
|
3900
4409
|
!els.videoCycleModal.hidden
|
|
3901
4410
|
) {
|
|
3902
4411
|
return
|
|
@@ -3914,7 +4423,6 @@ window.addEventListener('click', (e) => {
|
|
|
3914
4423
|
|
|
3915
4424
|
els.inspectorClose.addEventListener('click', () => {
|
|
3916
4425
|
clearSelection()
|
|
3917
|
-
state.editingSeqId = null
|
|
3918
4426
|
syncSelectionClass()
|
|
3919
4427
|
renderChrome()
|
|
3920
4428
|
})
|
|
@@ -3988,15 +4496,11 @@ els.ctxMenu.addEventListener('click', async (e) => {
|
|
|
3988
4496
|
openLayerDecompositionModal(targetId)
|
|
3989
4497
|
return
|
|
3990
4498
|
}
|
|
3991
|
-
if (action === 'video-trim') {
|
|
3992
|
-
openVideoTrimModal(targetId)
|
|
3993
|
-
return
|
|
3994
|
-
}
|
|
3995
4499
|
if (action === 'video-cycle') {
|
|
3996
4500
|
openVideoCycleModal(targetId)
|
|
3997
4501
|
return
|
|
3998
4502
|
}
|
|
3999
|
-
if (action === 'video-to-
|
|
4503
|
+
if (action === 'video-to-frame-sheet') {
|
|
4000
4504
|
openFrameExtractModal(targetId)
|
|
4001
4505
|
return
|
|
4002
4506
|
}
|
|
@@ -4008,20 +4512,14 @@ els.ctxMenu.addEventListener('click', async (e) => {
|
|
|
4008
4512
|
openManualSplitModal(targetId)
|
|
4009
4513
|
return
|
|
4010
4514
|
}
|
|
4011
|
-
if (action === '
|
|
4012
|
-
|
|
4515
|
+
if (action === 'frame-sheet-preview') {
|
|
4516
|
+
const node = state.layoutItems.find((item) => item.id === targetId)
|
|
4517
|
+
if (frameSheetPreviewMeta(node)) await stopFrameSheetPreview(targetId)
|
|
4518
|
+
else openFramePreviewModal(targetId)
|
|
4013
4519
|
return
|
|
4014
4520
|
}
|
|
4015
|
-
if (action === '
|
|
4016
|
-
|
|
4017
|
-
return
|
|
4018
|
-
}
|
|
4019
|
-
if (action === 'ungroup-sequence') {
|
|
4020
|
-
await ungroupSequence(targetId)
|
|
4021
|
-
return
|
|
4022
|
-
}
|
|
4023
|
-
if (action === 'combine-sequence') {
|
|
4024
|
-
await combineImagesAsSequence([...state.selectedIds])
|
|
4521
|
+
if (action === 'frame-sheet-edit') {
|
|
4522
|
+
openFrameEditorSplitModal(targetId)
|
|
4025
4523
|
return
|
|
4026
4524
|
}
|
|
4027
4525
|
if (action === 'delete') {
|
|
@@ -4030,9 +4528,6 @@ els.ctxMenu.addEventListener('click', async (e) => {
|
|
|
4030
4528
|
}
|
|
4031
4529
|
})
|
|
4032
4530
|
|
|
4033
|
-
els.seqPanelClose.addEventListener('click', closeSeqModal)
|
|
4034
|
-
els.seqPanelDone.addEventListener('click', closeSeqModal)
|
|
4035
|
-
|
|
4036
4531
|
els.interactiveEditClose.addEventListener('click', closeInteractiveEditModal)
|
|
4037
4532
|
els.interactiveEditCancel.addEventListener('click', closeInteractiveEditModal)
|
|
4038
4533
|
els.interactiveEditSave.addEventListener('click', () => {
|
|
@@ -4091,13 +4586,41 @@ els.frameExtractCount.addEventListener('keydown', (e) => {
|
|
|
4091
4586
|
}
|
|
4092
4587
|
})
|
|
4093
4588
|
|
|
4094
|
-
els.
|
|
4095
|
-
els.
|
|
4096
|
-
els.
|
|
4097
|
-
|
|
4589
|
+
els.framePreviewClose.addEventListener('click', () => closeFramePreviewModal())
|
|
4590
|
+
els.framePreviewCancel.addEventListener('click', () => closeFramePreviewModal())
|
|
4591
|
+
els.framePreviewConfirm.addEventListener('click', () => {
|
|
4592
|
+
confirmFramePreview()
|
|
4098
4593
|
})
|
|
4099
|
-
els.
|
|
4100
|
-
els.
|
|
4594
|
+
els.framePreviewRows.addEventListener('input', updateFramePreviewBounds)
|
|
4595
|
+
els.framePreviewColumns.addEventListener('input', updateFramePreviewBounds)
|
|
4596
|
+
for (const input of [
|
|
4597
|
+
els.framePreviewRows,
|
|
4598
|
+
els.framePreviewColumns,
|
|
4599
|
+
els.framePreviewFps,
|
|
4600
|
+
]) {
|
|
4601
|
+
input.addEventListener('keydown', (e) => {
|
|
4602
|
+
if (e.key === 'Enter') {
|
|
4603
|
+
e.preventDefault()
|
|
4604
|
+
confirmFramePreview()
|
|
4605
|
+
}
|
|
4606
|
+
})
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
els.frameEditorSplitClose.addEventListener('click', () => closeFrameEditorSplitModal())
|
|
4610
|
+
els.frameEditorSplitCancel.addEventListener('click', () => closeFrameEditorSplitModal())
|
|
4611
|
+
els.frameEditorSplitConfirm.addEventListener('click', () => {
|
|
4612
|
+
confirmFrameEditorSplit()
|
|
4613
|
+
})
|
|
4614
|
+
els.frameEditorSplitRows.addEventListener('input', updateFrameEditorSplitInfo)
|
|
4615
|
+
els.frameEditorSplitColumns.addEventListener('input', updateFrameEditorSplitInfo)
|
|
4616
|
+
for (const input of [els.frameEditorSplitRows, els.frameEditorSplitColumns]) {
|
|
4617
|
+
input.addEventListener('keydown', (event) => {
|
|
4618
|
+
if (event.key === 'Enter') {
|
|
4619
|
+
event.preventDefault()
|
|
4620
|
+
confirmFrameEditorSplit()
|
|
4621
|
+
}
|
|
4622
|
+
})
|
|
4623
|
+
}
|
|
4101
4624
|
|
|
4102
4625
|
els.videoCycleClose.addEventListener('click', () => closeVideoCycleModal())
|
|
4103
4626
|
els.videoCycleCancel.addEventListener('click', () => closeVideoCycleModal())
|
|
@@ -4105,124 +4628,6 @@ els.videoCycleConfirm.addEventListener('click', () => {
|
|
|
4105
4628
|
confirmVideoCycle()
|
|
4106
4629
|
})
|
|
4107
4630
|
|
|
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
4631
|
function openVideoCycleModal(videoId) {
|
|
4227
4632
|
const item = state.layoutItems.find((it) => it.id === videoId)
|
|
4228
4633
|
if (!isVideoItem(item) && !String(videoId || '').startsWith('videos/')) {
|
|
@@ -4248,7 +4653,7 @@ function closeVideoCycleModal(force = false) {
|
|
|
4248
4653
|
state.videoCycleAbort.abort()
|
|
4249
4654
|
state.videoCycleAbort = null
|
|
4250
4655
|
}
|
|
4251
|
-
if (!force &&
|
|
4656
|
+
if (!force && hasActiveOperations() && state.videoCycleId) {
|
|
4252
4657
|
els.videoCycleStatus.hidden = false
|
|
4253
4658
|
els.videoCycleStatus.textContent = '正在取消…'
|
|
4254
4659
|
return
|
|
@@ -4271,7 +4676,7 @@ function readVideoCycleParams() {
|
|
|
4271
4676
|
|
|
4272
4677
|
async function confirmVideoCycle() {
|
|
4273
4678
|
const videoId = state.videoCycleId
|
|
4274
|
-
if (!videoId ||
|
|
4679
|
+
if (!videoId || hasActiveOperations()) return
|
|
4275
4680
|
|
|
4276
4681
|
let params
|
|
4277
4682
|
try {
|
|
@@ -4285,7 +4690,7 @@ async function confirmVideoCycle() {
|
|
|
4285
4690
|
const node = state.layoutItems.find((it) => it.id === videoId)
|
|
4286
4691
|
const abort = new AbortController()
|
|
4287
4692
|
state.videoCycleAbort = abort
|
|
4288
|
-
|
|
4693
|
+
const activeOperation = beginActiveOperation('video-cycle')
|
|
4289
4694
|
state.error = ''
|
|
4290
4695
|
els.videoCycleConfirm.disabled = true
|
|
4291
4696
|
els.videoCycleCancel.disabled = false
|
|
@@ -4334,7 +4739,7 @@ async function confirmVideoCycle() {
|
|
|
4334
4739
|
renderChrome()
|
|
4335
4740
|
}
|
|
4336
4741
|
} finally {
|
|
4337
|
-
|
|
4742
|
+
endActiveOperation(activeOperation)
|
|
4338
4743
|
state.videoCycleAbort = null
|
|
4339
4744
|
els.videoCycleConfirm.disabled = false
|
|
4340
4745
|
els.videoCycleCancel.disabled = false
|
|
@@ -4345,272 +4750,8 @@ async function confirmVideoCycle() {
|
|
|
4345
4750
|
}
|
|
4346
4751
|
}
|
|
4347
4752
|
|
|
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
|
-
})
|
|
4559
|
-
}
|
|
4560
|
-
|
|
4561
|
-
async function confirmVideoTrim() {
|
|
4562
|
-
const videoId = state.videoTrimId
|
|
4563
|
-
if (!videoId || state.busy) return
|
|
4564
|
-
const { start, end, duration } = state.videoTrim
|
|
4565
|
-
if (!(duration > 0) || !(end > start + 0.01)) {
|
|
4566
|
-
els.videoTrimStatus.hidden = false
|
|
4567
|
-
els.videoTrimStatus.textContent = '请选择有效的起止区间(至少约 0.01s)'
|
|
4568
|
-
return
|
|
4569
|
-
}
|
|
4570
|
-
|
|
4571
|
-
const node = state.layoutItems.find((it) => it.id === videoId)
|
|
4572
|
-
state.busy = true
|
|
4573
|
-
state.error = ''
|
|
4574
|
-
els.videoTrimConfirm.disabled = true
|
|
4575
|
-
els.videoTrimCancel.disabled = true
|
|
4576
|
-
els.videoTrimClose.disabled = true
|
|
4577
|
-
els.videoTrimStatus.hidden = false
|
|
4578
|
-
els.videoTrimStatus.textContent = '正在裁剪…'
|
|
4579
|
-
renderChrome()
|
|
4580
|
-
|
|
4581
|
-
try {
|
|
4582
|
-
const operationId = await createProcessingPlaceholder(node, {
|
|
4583
|
-
name: '视频裁剪中',
|
|
4584
|
-
media: 'video',
|
|
4585
|
-
references: [videoId],
|
|
4586
|
-
})
|
|
4587
|
-
closeVideoTrimModal(true)
|
|
4588
|
-
const result = await api('/api/video-trim', {
|
|
4589
|
-
method: 'POST',
|
|
4590
|
-
headers: { 'Content-Type': 'application/json' },
|
|
4591
|
-
body: JSON.stringify({ id: videoId, start, end, client_operation_id: operationId }),
|
|
4592
|
-
})
|
|
4593
|
-
await reload()
|
|
4594
|
-
setSelection([result.id])
|
|
4595
|
-
closeVideoTrimModal(true)
|
|
4596
|
-
renderCanvas()
|
|
4597
|
-
renderChrome()
|
|
4598
|
-
} catch (err) {
|
|
4599
|
-
state.error = String(err.message || err)
|
|
4600
|
-
els.videoTrimStatus.hidden = false
|
|
4601
|
-
els.videoTrimStatus.textContent = state.error
|
|
4602
|
-
renderChrome()
|
|
4603
|
-
} finally {
|
|
4604
|
-
state.busy = false
|
|
4605
|
-
els.videoTrimConfirm.disabled = false
|
|
4606
|
-
els.videoTrimCancel.disabled = false
|
|
4607
|
-
els.videoTrimClose.disabled = false
|
|
4608
|
-
renderChrome()
|
|
4609
|
-
}
|
|
4610
|
-
}
|
|
4611
|
-
|
|
4612
4753
|
async function createProcessingPlaceholder(source, { name, media = 'image', references = [] } = {}) {
|
|
4613
|
-
const id =
|
|
4754
|
+
const id = createCanvasClientId('pending')
|
|
4614
4755
|
const width = source?.width || (media === 'video' ? 280 : DEFAULT_W)
|
|
4615
4756
|
const item = {
|
|
4616
4757
|
id,
|
|
@@ -4961,7 +5102,7 @@ function installInteractiveEditorInteractions(editor) {
|
|
|
4961
5102
|
}
|
|
4962
5103
|
|
|
4963
5104
|
function closeInteractiveEditModal(force = false) {
|
|
4964
|
-
if (!force &&
|
|
5105
|
+
if (!force && hasActiveOperations() && state.interactiveEditImageId) return
|
|
4965
5106
|
els.interactiveEditModal.hidden = true
|
|
4966
5107
|
state.interactiveEditImageId = null
|
|
4967
5108
|
destroyInteractiveEditor()
|
|
@@ -4992,14 +5133,14 @@ async function saveInteractiveEditToCanvas() {
|
|
|
4992
5133
|
const imageId = state.interactiveEditImageId
|
|
4993
5134
|
const node = state.layoutItems.find((item) => item.id === imageId)
|
|
4994
5135
|
const editor = interactiveEditor
|
|
4995
|
-
if (!imageId || !editor ||
|
|
5136
|
+
if (!imageId || !editor || hasActiveOperations()) return
|
|
4996
5137
|
if (!isPlainImageItem(node)) {
|
|
4997
5138
|
state.error = '请对普通图片使用交互编辑'
|
|
4998
5139
|
renderChrome()
|
|
4999
5140
|
return
|
|
5000
5141
|
}
|
|
5001
5142
|
|
|
5002
|
-
|
|
5143
|
+
const activeOperation = beginActiveOperation('interactive-edit-save')
|
|
5003
5144
|
state.error = ''
|
|
5004
5145
|
setInteractiveEditControlsDisabled(true)
|
|
5005
5146
|
els.interactiveEditStatus.hidden = false
|
|
@@ -5040,7 +5181,7 @@ async function saveInteractiveEditToCanvas() {
|
|
|
5040
5181
|
els.interactiveEditStatus.hidden = false
|
|
5041
5182
|
els.interactiveEditStatus.textContent = `保存编辑图片失败:${String(error.message || error)}`
|
|
5042
5183
|
} finally {
|
|
5043
|
-
|
|
5184
|
+
endActiveOperation(activeOperation)
|
|
5044
5185
|
if (!els.interactiveEditModal.hidden) setInteractiveEditControlsDisabled(false)
|
|
5045
5186
|
renderChrome()
|
|
5046
5187
|
}
|
|
@@ -5051,7 +5192,7 @@ async function confirmInteractiveEdit() {
|
|
|
5051
5192
|
const node = state.layoutItems.find((item) => item.id === imageId)
|
|
5052
5193
|
const prompt = els.interactiveEditPrompt.value.trim()
|
|
5053
5194
|
const editor = interactiveEditor
|
|
5054
|
-
if (!imageId || !editor ||
|
|
5195
|
+
if (!imageId || !editor || hasActiveOperations()) return
|
|
5055
5196
|
if (!isPlainImageItem(node)) {
|
|
5056
5197
|
state.error = '请对普通图片使用交互编辑'
|
|
5057
5198
|
renderChrome()
|
|
@@ -5078,7 +5219,7 @@ async function confirmInteractiveEdit() {
|
|
|
5078
5219
|
return
|
|
5079
5220
|
}
|
|
5080
5221
|
|
|
5081
|
-
|
|
5222
|
+
const activeOperation = beginActiveOperation('interactive-edit')
|
|
5082
5223
|
state.error = ''
|
|
5083
5224
|
setInteractiveEditControlsDisabled(true)
|
|
5084
5225
|
els.interactiveEditStatus.hidden = false
|
|
@@ -5109,14 +5250,10 @@ async function confirmInteractiveEdit() {
|
|
|
5109
5250
|
renderChrome()
|
|
5110
5251
|
} catch (error) {
|
|
5111
5252
|
state.error = String(error.message || error)
|
|
5112
|
-
if (operationId) {
|
|
5113
|
-
state.layoutItems = state.layoutItems.filter((item) => item.id !== operationId)
|
|
5114
|
-
await saveLayout(state.layoutItems)
|
|
5115
|
-
}
|
|
5116
5253
|
await reload().catch(() => undefined)
|
|
5117
5254
|
renderChrome()
|
|
5118
5255
|
} finally {
|
|
5119
|
-
|
|
5256
|
+
endActiveOperation(activeOperation)
|
|
5120
5257
|
renderChrome()
|
|
5121
5258
|
}
|
|
5122
5259
|
}
|
|
@@ -5147,7 +5284,7 @@ function openLayerDecompositionModal(imageId) {
|
|
|
5147
5284
|
}
|
|
5148
5285
|
|
|
5149
5286
|
function closeLayerDecompositionModal(force = false) {
|
|
5150
|
-
if (!force &&
|
|
5287
|
+
if (!force && hasActiveOperations() && state.layerDecompositionImageId) return
|
|
5151
5288
|
els.layerDecompositionModal.hidden = true
|
|
5152
5289
|
state.layerDecompositionImageId = null
|
|
5153
5290
|
els.layerDecompositionImage.removeAttribute('src')
|
|
@@ -5156,7 +5293,7 @@ function closeLayerDecompositionModal(force = false) {
|
|
|
5156
5293
|
async function confirmLayerDecomposition() {
|
|
5157
5294
|
const imageId = state.layerDecompositionImageId
|
|
5158
5295
|
const prompt = els.layerDecompositionPrompt.value.trim()
|
|
5159
|
-
if (!imageId ||
|
|
5296
|
+
if (!imageId || hasActiveOperations()) return
|
|
5160
5297
|
if (!prompt) {
|
|
5161
5298
|
els.layerDecompositionStatus.hidden = false
|
|
5162
5299
|
els.layerDecompositionStatus.textContent = '请输入需要模型执行的图层拆分要求'
|
|
@@ -5171,7 +5308,7 @@ async function confirmLayerDecomposition() {
|
|
|
5171
5308
|
return
|
|
5172
5309
|
}
|
|
5173
5310
|
|
|
5174
|
-
|
|
5311
|
+
const activeOperation = beginActiveOperation('layer-decomposition')
|
|
5175
5312
|
state.error = ''
|
|
5176
5313
|
setLayerDecompositionControlsDisabled(true)
|
|
5177
5314
|
els.layerDecompositionStatus.hidden = false
|
|
@@ -5204,14 +5341,10 @@ async function confirmLayerDecomposition() {
|
|
|
5204
5341
|
renderChrome()
|
|
5205
5342
|
} catch (err) {
|
|
5206
5343
|
state.error = String(err.message || err)
|
|
5207
|
-
if (operationId) {
|
|
5208
|
-
state.layoutItems = state.layoutItems.filter((item) => item.id !== operationId)
|
|
5209
|
-
await saveLayout(state.layoutItems)
|
|
5210
|
-
}
|
|
5211
5344
|
await reload().catch(() => undefined)
|
|
5212
5345
|
renderChrome()
|
|
5213
5346
|
} finally {
|
|
5214
|
-
|
|
5347
|
+
endActiveOperation(activeOperation)
|
|
5215
5348
|
setLayerDecompositionControlsDisabled(false)
|
|
5216
5349
|
renderChrome()
|
|
5217
5350
|
}
|
|
@@ -5220,7 +5353,7 @@ async function confirmLayerDecomposition() {
|
|
|
5220
5353
|
function openFrameExtractModal(videoId) {
|
|
5221
5354
|
const item = state.layoutItems.find((it) => it.id === videoId)
|
|
5222
5355
|
if (!isVideoItem(item) && !String(videoId || '').startsWith('videos/')) {
|
|
5223
|
-
state.error = '
|
|
5356
|
+
state.error = '请对视频元素使用视频转序列帧图'
|
|
5224
5357
|
renderChrome()
|
|
5225
5358
|
return
|
|
5226
5359
|
}
|
|
@@ -5236,7 +5369,7 @@ function openFrameExtractModal(videoId) {
|
|
|
5236
5369
|
}
|
|
5237
5370
|
|
|
5238
5371
|
function closeFrameExtractModal() {
|
|
5239
|
-
if (
|
|
5372
|
+
if (hasActiveOperations() && state.frameExtractVideoId) return
|
|
5240
5373
|
els.frameExtractModal.hidden = true
|
|
5241
5374
|
state.frameExtractVideoId = null
|
|
5242
5375
|
}
|
|
@@ -5244,14 +5377,14 @@ function closeFrameExtractModal() {
|
|
|
5244
5377
|
function readFrameExtractCount() {
|
|
5245
5378
|
const n = Math.round(Number(els.frameExtractCount.value))
|
|
5246
5379
|
if (!Number.isFinite(n) || n < 2 || n > 30) {
|
|
5247
|
-
throw new Error('
|
|
5380
|
+
throw new Error('抽取帧数需在 2–30 之间')
|
|
5248
5381
|
}
|
|
5249
5382
|
return n
|
|
5250
5383
|
}
|
|
5251
5384
|
|
|
5252
5385
|
async function confirmFrameExtract() {
|
|
5253
5386
|
const videoId = state.frameExtractVideoId
|
|
5254
|
-
if (!videoId ||
|
|
5387
|
+
if (!videoId || hasActiveOperations()) return
|
|
5255
5388
|
let count
|
|
5256
5389
|
try {
|
|
5257
5390
|
count = readFrameExtractCount()
|
|
@@ -5262,19 +5395,19 @@ async function confirmFrameExtract() {
|
|
|
5262
5395
|
}
|
|
5263
5396
|
|
|
5264
5397
|
const node = state.layoutItems.find((it) => it.id === videoId)
|
|
5265
|
-
|
|
5398
|
+
const activeOperation = beginActiveOperation('video-frame-extract')
|
|
5266
5399
|
state.error = ''
|
|
5267
5400
|
els.frameExtractConfirm.disabled = true
|
|
5268
5401
|
els.frameExtractCancel.disabled = true
|
|
5269
5402
|
els.frameExtractClose.disabled = true
|
|
5270
5403
|
els.frameExtractCount.disabled = true
|
|
5271
5404
|
els.frameExtractStatus.hidden = false
|
|
5272
|
-
els.frameExtractStatus.textContent =
|
|
5405
|
+
els.frameExtractStatus.textContent = `正在抽取 ${count} 帧并拼接 PNG…`
|
|
5273
5406
|
renderChrome()
|
|
5274
5407
|
|
|
5275
5408
|
try {
|
|
5276
5409
|
const operationId = await createProcessingPlaceholder(node, {
|
|
5277
|
-
name: '
|
|
5410
|
+
name: '视频序列帧图生成中',
|
|
5278
5411
|
references: [videoId],
|
|
5279
5412
|
})
|
|
5280
5413
|
els.frameExtractModal.hidden = true
|
|
@@ -5285,7 +5418,7 @@ async function confirmFrameExtract() {
|
|
|
5285
5418
|
body: JSON.stringify({ id: videoId, count, client_operation_id: operationId }),
|
|
5286
5419
|
})
|
|
5287
5420
|
await reload()
|
|
5288
|
-
if (result.
|
|
5421
|
+
if (result.id) setSelection([result.id])
|
|
5289
5422
|
els.frameExtractModal.hidden = true
|
|
5290
5423
|
state.frameExtractVideoId = null
|
|
5291
5424
|
renderCanvas()
|
|
@@ -5296,7 +5429,7 @@ async function confirmFrameExtract() {
|
|
|
5296
5429
|
els.frameExtractStatus.textContent = state.error
|
|
5297
5430
|
renderChrome()
|
|
5298
5431
|
} finally {
|
|
5299
|
-
|
|
5432
|
+
endActiveOperation(activeOperation)
|
|
5300
5433
|
els.frameExtractConfirm.disabled = false
|
|
5301
5434
|
els.frameExtractCancel.disabled = false
|
|
5302
5435
|
els.frameExtractClose.disabled = false
|
|
@@ -5305,6 +5438,291 @@ async function confirmFrameExtract() {
|
|
|
5305
5438
|
}
|
|
5306
5439
|
}
|
|
5307
5440
|
|
|
5441
|
+
function openFramePreviewModal(imageId) {
|
|
5442
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5443
|
+
if (!isPlainImageItem(item)) {
|
|
5444
|
+
state.error = '请对普通图片使用序列帧预览'
|
|
5445
|
+
renderChrome()
|
|
5446
|
+
return
|
|
5447
|
+
}
|
|
5448
|
+
|
|
5449
|
+
const config = suggestedFramePreviewConfig(item)
|
|
5450
|
+
state.framePreviewImageId = imageId
|
|
5451
|
+
els.framePreviewRows.value = String(config.rows)
|
|
5452
|
+
els.framePreviewColumns.value = String(config.columns)
|
|
5453
|
+
els.framePreviewFps.value = String(config.fps)
|
|
5454
|
+
els.framePreviewStatus.hidden = true
|
|
5455
|
+
els.framePreviewStatus.textContent = ''
|
|
5456
|
+
els.framePreviewConfirm.disabled = false
|
|
5457
|
+
els.framePreviewModal.hidden = false
|
|
5458
|
+
updateFramePreviewBounds()
|
|
5459
|
+
els.framePreviewRows.focus()
|
|
5460
|
+
}
|
|
5461
|
+
|
|
5462
|
+
function closeFramePreviewModal(force = false) {
|
|
5463
|
+
if (!force && els.framePreviewConfirm.disabled) return
|
|
5464
|
+
els.framePreviewModal.hidden = true
|
|
5465
|
+
state.framePreviewImageId = null
|
|
5466
|
+
}
|
|
5467
|
+
|
|
5468
|
+
function updateFramePreviewBounds() {
|
|
5469
|
+
const rows = Number(els.framePreviewRows.value)
|
|
5470
|
+
const columns = Number(els.framePreviewColumns.value)
|
|
5471
|
+
const valid =
|
|
5472
|
+
Number.isInteger(rows) &&
|
|
5473
|
+
Number.isInteger(columns) &&
|
|
5474
|
+
rows >= 1 &&
|
|
5475
|
+
columns >= 1 &&
|
|
5476
|
+
rows <= 64 &&
|
|
5477
|
+
columns <= 64 &&
|
|
5478
|
+
rows * columns >= 2
|
|
5479
|
+
const frameCount = valid ? rows * columns : 0
|
|
5480
|
+
els.framePreviewInfo.textContent = valid
|
|
5481
|
+
? `网格 ${rows}×${columns} · 按从左到右、从上到下播放`
|
|
5482
|
+
: '请输入有效的行列数(至少两格)'
|
|
5483
|
+
els.framePreviewConfirm.disabled = !valid
|
|
5484
|
+
|
|
5485
|
+
const preview = els.framePreviewGrid
|
|
5486
|
+
preview.innerHTML = ''
|
|
5487
|
+
if (!valid) return
|
|
5488
|
+
preview.style.gridTemplateColumns = `repeat(${columns}, 18px)`
|
|
5489
|
+
preview.style.gridTemplateRows = `repeat(${rows}, 18px)`
|
|
5490
|
+
for (let index = 0; index < frameCount; index += 1) {
|
|
5491
|
+
const cell = document.createElement('div')
|
|
5492
|
+
cell.className = 'seq-sheet-cell is-filled'
|
|
5493
|
+
cell.textContent = String(index + 1)
|
|
5494
|
+
cell.title = `帧 ${index + 1}`
|
|
5495
|
+
preview.appendChild(cell)
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
|
|
5499
|
+
function readFramePreviewConfig() {
|
|
5500
|
+
const rows = Number(els.framePreviewRows.value)
|
|
5501
|
+
const columns = Number(els.framePreviewColumns.value)
|
|
5502
|
+
const fps = Number(els.framePreviewFps.value)
|
|
5503
|
+
if (
|
|
5504
|
+
!Number.isInteger(rows) ||
|
|
5505
|
+
!Number.isInteger(columns) ||
|
|
5506
|
+
rows < 1 ||
|
|
5507
|
+
columns < 1 ||
|
|
5508
|
+
rows > 64 ||
|
|
5509
|
+
columns > 64 ||
|
|
5510
|
+
rows * columns < 2
|
|
5511
|
+
) {
|
|
5512
|
+
throw new Error('行列数需为 1–64 的整数,且至少两格')
|
|
5513
|
+
}
|
|
5514
|
+
const config = normalizeFramePreviewConfig({
|
|
5515
|
+
enabled: true,
|
|
5516
|
+
rows,
|
|
5517
|
+
columns,
|
|
5518
|
+
frameCount: rows * columns,
|
|
5519
|
+
fps,
|
|
5520
|
+
})
|
|
5521
|
+
if (!config) {
|
|
5522
|
+
throw new Error('行列数需为 1–64 的整数且至少两格,FPS 必须大于 0')
|
|
5523
|
+
}
|
|
5524
|
+
return config
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
function loadFramePreviewImage(imageId) {
|
|
5528
|
+
return new Promise((resolve, reject) => {
|
|
5529
|
+
const image = new Image()
|
|
5530
|
+
image.onload = () => resolve(image)
|
|
5531
|
+
image.onerror = () => reject(new Error('无法读取图片,不能检测透明空帧'))
|
|
5532
|
+
image.src = imageUrl(imageId)
|
|
5533
|
+
})
|
|
5534
|
+
}
|
|
5535
|
+
|
|
5536
|
+
async function detectFramePreviewCount(imageId, rows, columns) {
|
|
5537
|
+
const image = await loadFramePreviewImage(imageId)
|
|
5538
|
+
if (!image.naturalWidth || !image.naturalHeight) return rows * columns
|
|
5539
|
+
const canvas = document.createElement('canvas')
|
|
5540
|
+
|
|
5541
|
+
for (let index = rows * columns - 1; index >= 0; index -= 1) {
|
|
5542
|
+
const row = Math.floor(index / columns)
|
|
5543
|
+
const column = index % columns
|
|
5544
|
+
const left = Math.floor((column * image.naturalWidth) / columns)
|
|
5545
|
+
const right = Math.floor(((column + 1) * image.naturalWidth) / columns)
|
|
5546
|
+
const top = Math.floor((row * image.naturalHeight) / rows)
|
|
5547
|
+
const bottom = Math.floor(((row + 1) * image.naturalHeight) / rows)
|
|
5548
|
+
const width = Math.max(1, right - left)
|
|
5549
|
+
const height = Math.max(1, bottom - top)
|
|
5550
|
+
canvas.width = width
|
|
5551
|
+
canvas.height = height
|
|
5552
|
+
const currentContext = canvas.getContext('2d', { willReadFrequently: true })
|
|
5553
|
+
if (!currentContext) return rows * columns
|
|
5554
|
+
currentContext.clearRect(0, 0, width, height)
|
|
5555
|
+
currentContext.drawImage(image, left, top, width, height, 0, 0, width, height)
|
|
5556
|
+
const pixels = currentContext.getImageData(0, 0, width, height).data
|
|
5557
|
+
for (let offset = 3; offset < pixels.length; offset += 4) {
|
|
5558
|
+
if (pixels[offset] !== 0) return index + 1
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
return 1
|
|
5562
|
+
}
|
|
5563
|
+
|
|
5564
|
+
async function confirmFramePreview() {
|
|
5565
|
+
const imageId = state.framePreviewImageId
|
|
5566
|
+
if (!imageId || els.framePreviewConfirm.disabled) return
|
|
5567
|
+
|
|
5568
|
+
let config
|
|
5569
|
+
try {
|
|
5570
|
+
config = readFramePreviewConfig()
|
|
5571
|
+
} catch (err) {
|
|
5572
|
+
els.framePreviewStatus.hidden = false
|
|
5573
|
+
els.framePreviewStatus.textContent = String(err.message || err)
|
|
5574
|
+
return
|
|
5575
|
+
}
|
|
5576
|
+
|
|
5577
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5578
|
+
if (!isPlainImageItem(item)) {
|
|
5579
|
+
closeFramePreviewModal(true)
|
|
5580
|
+
state.error = '图片不存在,无法开始序列帧预览'
|
|
5581
|
+
renderChrome()
|
|
5582
|
+
return
|
|
5583
|
+
}
|
|
5584
|
+
|
|
5585
|
+
els.framePreviewConfirm.disabled = true
|
|
5586
|
+
els.framePreviewClose.disabled = true
|
|
5587
|
+
els.framePreviewCancel.disabled = true
|
|
5588
|
+
try {
|
|
5589
|
+
els.framePreviewStatus.hidden = false
|
|
5590
|
+
els.framePreviewStatus.textContent = '正在检查末尾透明空帧…'
|
|
5591
|
+
const known =
|
|
5592
|
+
normalizeFramePreviewConfig(item.framePreview) || sourceFramePreviewConfig(item)
|
|
5593
|
+
const frameCount =
|
|
5594
|
+
known && known.rows === config.rows && known.columns === config.columns
|
|
5595
|
+
? known.frameCount
|
|
5596
|
+
: await detectFramePreviewCount(imageId, config.rows, config.columns)
|
|
5597
|
+
item.framePreview = { ...config, frameCount }
|
|
5598
|
+
state.frameSheetPlayback[imageId] = { index: 0, acc: 0 }
|
|
5599
|
+
await saveLayout()
|
|
5600
|
+
closeFramePreviewModal(true)
|
|
5601
|
+
renderCanvas()
|
|
5602
|
+
renderChrome()
|
|
5603
|
+
} catch (err) {
|
|
5604
|
+
els.framePreviewStatus.hidden = false
|
|
5605
|
+
els.framePreviewStatus.textContent = String(err.message || err)
|
|
5606
|
+
} finally {
|
|
5607
|
+
els.framePreviewConfirm.disabled = false
|
|
5608
|
+
els.framePreviewClose.disabled = false
|
|
5609
|
+
els.framePreviewCancel.disabled = false
|
|
5610
|
+
}
|
|
5611
|
+
}
|
|
5612
|
+
|
|
5613
|
+
async function stopFrameSheetPreview(imageId) {
|
|
5614
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5615
|
+
const config = normalizeFramePreviewConfig(item?.framePreview)
|
|
5616
|
+
if (!item || !config) return
|
|
5617
|
+
item.framePreview = { ...config, enabled: false }
|
|
5618
|
+
delete state.frameSheetPlayback[imageId]
|
|
5619
|
+
await saveLayout()
|
|
5620
|
+
renderCanvas()
|
|
5621
|
+
renderChrome()
|
|
5622
|
+
}
|
|
5623
|
+
|
|
5624
|
+
function openFrameEditorSplitModal(imageId) {
|
|
5625
|
+
const item = state.layoutItems.find((candidate) => candidate.id === imageId)
|
|
5626
|
+
if (!isPlainImageItem(item)) {
|
|
5627
|
+
state.error = '请对普通图片使用序列帧编辑'
|
|
5628
|
+
renderChrome()
|
|
5629
|
+
return
|
|
5630
|
+
}
|
|
5631
|
+
const preview = frameSheetPreviewMeta(item)
|
|
5632
|
+
state.frameEditorSourceImageId = imageId
|
|
5633
|
+
els.frameEditorSplitRows.value = String(preview?.rows || 2)
|
|
5634
|
+
els.frameEditorSplitColumns.value = String(preview?.columns || 2)
|
|
5635
|
+
els.frameEditorSplitStatus.hidden = true
|
|
5636
|
+
els.frameEditorSplitStatus.textContent = ''
|
|
5637
|
+
els.frameEditorSplitModal.hidden = false
|
|
5638
|
+
updateFrameEditorSplitInfo()
|
|
5639
|
+
els.frameEditorSplitRows.focus()
|
|
5640
|
+
}
|
|
5641
|
+
|
|
5642
|
+
function closeFrameEditorSplitModal(_force = false) {
|
|
5643
|
+
els.frameEditorSplitModal.hidden = true
|
|
5644
|
+
state.frameEditorSourceImageId = null
|
|
5645
|
+
}
|
|
5646
|
+
|
|
5647
|
+
function readFrameEditorSplitGrid() {
|
|
5648
|
+
const rows = Number(els.frameEditorSplitRows.value)
|
|
5649
|
+
const columns = Number(els.frameEditorSplitColumns.value)
|
|
5650
|
+
if (
|
|
5651
|
+
!Number.isInteger(rows) ||
|
|
5652
|
+
!Number.isInteger(columns) ||
|
|
5653
|
+
rows < 1 ||
|
|
5654
|
+
columns < 1 ||
|
|
5655
|
+
rows > 64 ||
|
|
5656
|
+
columns > 64
|
|
5657
|
+
) {
|
|
5658
|
+
throw new Error('行数和列数必须是 1–64 的整数')
|
|
5659
|
+
}
|
|
5660
|
+
return { rows, columns }
|
|
5661
|
+
}
|
|
5662
|
+
|
|
5663
|
+
function updateFrameEditorSplitInfo() {
|
|
5664
|
+
try {
|
|
5665
|
+
const { rows, columns } = readFrameEditorSplitGrid()
|
|
5666
|
+
els.frameEditorSplitInfo.textContent = `将生成 ${rows * columns} 个可编辑帧格;切分允许 1×1。`
|
|
5667
|
+
els.frameEditorSplitConfirm.disabled = false
|
|
5668
|
+
} catch (err) {
|
|
5669
|
+
els.frameEditorSplitInfo.textContent = String(err.message || err)
|
|
5670
|
+
els.frameEditorSplitConfirm.disabled = true
|
|
5671
|
+
}
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5674
|
+
async function confirmFrameEditorSplit() {
|
|
5675
|
+
const sourceId = state.frameEditorSourceImageId
|
|
5676
|
+
if (!sourceId || els.frameEditorSplitConfirm.disabled) return
|
|
5677
|
+
let grid
|
|
5678
|
+
try {
|
|
5679
|
+
grid = readFrameEditorSplitGrid()
|
|
5680
|
+
} catch (err) {
|
|
5681
|
+
els.frameEditorSplitStatus.hidden = false
|
|
5682
|
+
els.frameEditorSplitStatus.textContent = String(err.message || err)
|
|
5683
|
+
return
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5686
|
+
const source = state.layoutItems.find((candidate) => candidate.id === sourceId)
|
|
5687
|
+
if (!isPlainImageItem(source)) {
|
|
5688
|
+
closeFrameEditorSplitModal(true)
|
|
5689
|
+
state.error = '原始图片不存在,无法创建序列帧编辑器'
|
|
5690
|
+
renderChrome()
|
|
5691
|
+
return
|
|
5692
|
+
}
|
|
5693
|
+
const preview = frameSheetPreviewMeta(source)
|
|
5694
|
+
const usesPreviewGrid = preview?.rows === grid.rows && preview?.columns === grid.columns
|
|
5695
|
+
const frameCount = usesPreviewGrid ? preview.frameCount : grid.rows * grid.columns
|
|
5696
|
+
const fps = preview?.fps || 8
|
|
5697
|
+
const editorId = createCanvasClientId('frameeditor')
|
|
5698
|
+
const width = source.width || DEFAULT_W
|
|
5699
|
+
state.layoutItems.push({
|
|
5700
|
+
id: editorId,
|
|
5701
|
+
type: 'frame_editor',
|
|
5702
|
+
name: '序列帧编辑',
|
|
5703
|
+
x: (Number(source.x) || 0) + width + 60,
|
|
5704
|
+
y: Number(source.y) || 0,
|
|
5705
|
+
width: FRAME_EDITOR_W,
|
|
5706
|
+
height: null,
|
|
5707
|
+
sourceId,
|
|
5708
|
+
sourceRows: grid.rows,
|
|
5709
|
+
sourceColumns: grid.columns,
|
|
5710
|
+
rows: grid.rows,
|
|
5711
|
+
columns: grid.columns,
|
|
5712
|
+
fps,
|
|
5713
|
+
frames: Array.from({ length: frameCount }, (_, index) => ({
|
|
5714
|
+
kind: 'source_cell',
|
|
5715
|
+
index,
|
|
5716
|
+
})),
|
|
5717
|
+
error: null,
|
|
5718
|
+
})
|
|
5719
|
+
await saveLayout()
|
|
5720
|
+
closeFrameEditorSplitModal(true)
|
|
5721
|
+
setSelection([editorId])
|
|
5722
|
+
renderCanvas()
|
|
5723
|
+
renderChrome()
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5308
5726
|
function selectSmartSplitAnchor(anchor) {
|
|
5309
5727
|
state.smartSplitAnchor = anchor
|
|
5310
5728
|
for (const button of els.smartSplitAnchorGrid.querySelectorAll('button[data-anchor]')) {
|
|
@@ -5325,7 +5743,7 @@ function setSmartSplitControlsDisabled(disabled) {
|
|
|
5325
5743
|
|
|
5326
5744
|
function openSmartSplitModal(imageId) {
|
|
5327
5745
|
const node = state.layoutItems.find((it) => it.id === imageId)
|
|
5328
|
-
if (!node
|
|
5746
|
+
if (!node) {
|
|
5329
5747
|
state.error = '请对普通图片使用智能切分'
|
|
5330
5748
|
renderChrome()
|
|
5331
5749
|
return
|
|
@@ -5345,18 +5763,18 @@ function closeSmartSplitModal() {
|
|
|
5345
5763
|
|
|
5346
5764
|
async function confirmSmartSplit() {
|
|
5347
5765
|
const imageId = state.smartSplitImageId
|
|
5348
|
-
if (!imageId ||
|
|
5766
|
+
if (!imageId || hasActiveOperations()) return
|
|
5349
5767
|
await runSmartSplit(imageId, state.smartSplitAnchor)
|
|
5350
5768
|
}
|
|
5351
5769
|
|
|
5352
5770
|
async function runSmartSplit(imageId, anchor = 'center') {
|
|
5353
5771
|
const node = state.layoutItems.find((it) => it.id === imageId)
|
|
5354
|
-
if (
|
|
5772
|
+
if (!node || isVideoItem(node) || isPending(node)) {
|
|
5355
5773
|
state.error = '请对普通图片使用智能切分'
|
|
5356
5774
|
renderChrome()
|
|
5357
5775
|
return
|
|
5358
5776
|
}
|
|
5359
|
-
|
|
5777
|
+
const activeOperation = beginActiveOperation('smart-split')
|
|
5360
5778
|
state.error = ''
|
|
5361
5779
|
setSmartSplitControlsDisabled(true)
|
|
5362
5780
|
els.smartSplitStatus.hidden = false
|
|
@@ -5388,7 +5806,7 @@ async function runSmartSplit(imageId, anchor = 'center') {
|
|
|
5388
5806
|
els.smartSplitStatus.textContent = state.error
|
|
5389
5807
|
renderChrome()
|
|
5390
5808
|
} finally {
|
|
5391
|
-
|
|
5809
|
+
endActiveOperation(activeOperation)
|
|
5392
5810
|
setSmartSplitControlsDisabled(false)
|
|
5393
5811
|
renderChrome()
|
|
5394
5812
|
}
|
|
@@ -5417,7 +5835,7 @@ function updateManualSplitPreview() {
|
|
|
5417
5835
|
els.manualSplitInfo.textContent = valid
|
|
5418
5836
|
? `网格 ${rows}×${columns} · 将生成 ${count} 张图片`
|
|
5419
5837
|
: '请输入有效的行列数(至少两格)'
|
|
5420
|
-
els.manualSplitConfirm.disabled = !valid ||
|
|
5838
|
+
els.manualSplitConfirm.disabled = !valid || hasActiveOperations()
|
|
5421
5839
|
|
|
5422
5840
|
const preview = els.manualSplitPreview
|
|
5423
5841
|
preview.innerHTML = ''
|
|
@@ -5457,14 +5875,14 @@ function openManualSplitModal(imageId) {
|
|
|
5457
5875
|
}
|
|
5458
5876
|
|
|
5459
5877
|
function closeManualSplitModal() {
|
|
5460
|
-
if (
|
|
5878
|
+
if (hasActiveOperations() && state.manualSplitImageId) return
|
|
5461
5879
|
els.manualSplitModal.hidden = true
|
|
5462
5880
|
state.manualSplitImageId = null
|
|
5463
5881
|
}
|
|
5464
5882
|
|
|
5465
5883
|
async function confirmManualSplit() {
|
|
5466
5884
|
const imageId = state.manualSplitImageId
|
|
5467
|
-
if (!imageId ||
|
|
5885
|
+
if (!imageId || hasActiveOperations()) return
|
|
5468
5886
|
let grid
|
|
5469
5887
|
try {
|
|
5470
5888
|
grid = readManualSplitGrid()
|
|
@@ -5475,7 +5893,7 @@ async function confirmManualSplit() {
|
|
|
5475
5893
|
}
|
|
5476
5894
|
|
|
5477
5895
|
const node = state.layoutItems.find((item) => item.id === imageId)
|
|
5478
|
-
|
|
5896
|
+
const activeOperation = beginActiveOperation('manual-split')
|
|
5479
5897
|
state.error = ''
|
|
5480
5898
|
els.manualSplitConfirm.disabled = true
|
|
5481
5899
|
els.manualSplitClose.disabled = true
|
|
@@ -5515,7 +5933,7 @@ async function confirmManualSplit() {
|
|
|
5515
5933
|
els.manualSplitStatus.textContent = state.error
|
|
5516
5934
|
renderChrome()
|
|
5517
5935
|
} finally {
|
|
5518
|
-
|
|
5936
|
+
endActiveOperation(activeOperation)
|
|
5519
5937
|
els.manualSplitConfirm.disabled = false
|
|
5520
5938
|
els.manualSplitClose.disabled = false
|
|
5521
5939
|
els.manualSplitCancel.disabled = false
|
|
@@ -5526,244 +5944,54 @@ async function confirmManualSplit() {
|
|
|
5526
5944
|
}
|
|
5527
5945
|
}
|
|
5528
5946
|
|
|
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)
|
|
5947
|
+
let lastFrameSheetPreviewTimestamp = 0
|
|
5948
|
+
|
|
5949
|
+
function tickFrameSheetPreviews(timestamp) {
|
|
5950
|
+
if (!lastFrameSheetPreviewTimestamp) lastFrameSheetPreviewTimestamp = timestamp
|
|
5951
|
+
const deltaSeconds = Math.min(0.25, Math.max(0, timestamp - lastFrameSheetPreviewTimestamp) / 1000)
|
|
5952
|
+
lastFrameSheetPreviewTimestamp = timestamp
|
|
5953
|
+
|
|
5954
|
+
for (const preview of els.world.querySelectorAll('.frame-sheet-preview[data-asset-id]')) {
|
|
5955
|
+
const assetId = preview.dataset.assetId
|
|
5956
|
+
const item = state.layoutItems.find((candidate) => candidate.id === assetId)
|
|
5957
|
+
const meta = frameSheetPreviewMeta(item)
|
|
5958
|
+
if (!meta || meta.frameCount < 2) continue
|
|
5959
|
+
const play = state.frameSheetPlayback[assetId] || { index: 0, acc: 0 }
|
|
5960
|
+
play.acc += deltaSeconds
|
|
5961
|
+
const frameDuration = 1 / meta.fps
|
|
5962
|
+
let changed = false
|
|
5963
|
+
const elapsedFrames = Math.floor(play.acc / frameDuration)
|
|
5964
|
+
if (elapsedFrames > 0) {
|
|
5965
|
+
play.acc -= elapsedFrames * frameDuration
|
|
5966
|
+
play.index = (play.index + elapsedFrames) % meta.frameCount
|
|
5967
|
+
changed = true
|
|
5569
5968
|
}
|
|
5969
|
+
state.frameSheetPlayback[assetId] = play
|
|
5970
|
+
if (changed) applyFrameSheetPreviewFrame(preview, meta, play.index)
|
|
5570
5971
|
}
|
|
5571
|
-
}
|
|
5572
5972
|
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
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
|
|
5608
|
-
}
|
|
5609
|
-
|
|
5610
|
-
let grid
|
|
5611
|
-
try {
|
|
5612
|
-
grid = readSeqSheetGrid()
|
|
5613
|
-
if (grid.rows * grid.cols < item.frames.length) {
|
|
5614
|
-
throw new Error(`格子不足:需要至少 ${item.frames.length} 格`)
|
|
5973
|
+
for (const preview of els.world.querySelectorAll('.frame-editor-preview-stage[data-editor-id]')) {
|
|
5974
|
+
const editorId = preview.dataset.editorId
|
|
5975
|
+
const editor = normalizeFrameEditor(
|
|
5976
|
+
state.layoutItems.find((candidate) => candidate.id === editorId),
|
|
5977
|
+
)
|
|
5978
|
+
if (!editor || editor.frames.length < 2) continue
|
|
5979
|
+
const play = state.frameSheetPlayback[editorId] || { index: 0, acc: 0 }
|
|
5980
|
+
play.acc += deltaSeconds
|
|
5981
|
+
const frameDuration = 1 / editor.fps
|
|
5982
|
+
const elapsedFrames = Math.floor(play.acc / frameDuration)
|
|
5983
|
+
if (elapsedFrames > 0) {
|
|
5984
|
+
play.acc -= elapsedFrames * frameDuration
|
|
5985
|
+
play.index = (play.index + elapsedFrames) % editor.frames.length
|
|
5986
|
+
applyFrameEditorPreviewFrame(preview, editor, play.index)
|
|
5615
5987
|
}
|
|
5616
|
-
|
|
5617
|
-
els.seqSheetStatus.hidden = false
|
|
5618
|
-
els.seqSheetStatus.textContent = String(err.message || err)
|
|
5619
|
-
return
|
|
5988
|
+
state.frameSheetPlayback[editorId] = play
|
|
5620
5989
|
}
|
|
5621
5990
|
|
|
5622
|
-
|
|
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
|
-
}
|
|
5991
|
+
requestAnimationFrame(tickFrameSheetPreviews)
|
|
5672
5992
|
}
|
|
5673
5993
|
|
|
5674
|
-
|
|
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
|
|
5704
|
-
}
|
|
5705
|
-
|
|
5706
|
-
images.sort(
|
|
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()
|
|
5735
|
-
}
|
|
5736
|
-
|
|
5737
|
-
let lastTs = 0
|
|
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)
|
|
5994
|
+
requestAnimationFrame(tickFrameSheetPreviews)
|
|
5767
5995
|
|
|
5768
5996
|
const SLOW_ASSET_SYNC_INTERVAL_MS = 5 * 60 * 1000
|
|
5769
5997
|
|
|
@@ -5776,13 +6004,13 @@ function scheduleSlowAssetSync() {
|
|
|
5776
6004
|
|
|
5777
6005
|
async function runCutout(imageId) {
|
|
5778
6006
|
const source = state.layoutItems.find((item) => item.id === imageId)
|
|
5779
|
-
if (!source ||
|
|
6007
|
+
if (!source || isPending(source) || isVideoItem(source)) {
|
|
5780
6008
|
state.error = '请对普通图片使用抠图'
|
|
5781
6009
|
renderChrome()
|
|
5782
6010
|
return
|
|
5783
6011
|
}
|
|
5784
6012
|
|
|
5785
|
-
const pendingId =
|
|
6013
|
+
const pendingId = createCanvasClientId('job_cutout')
|
|
5786
6014
|
const pending = {
|
|
5787
6015
|
id: pendingId,
|
|
5788
6016
|
type: 'pending',
|
|
@@ -5794,14 +6022,13 @@ async function runCutout(imageId) {
|
|
|
5794
6022
|
references: [{ id: imageId, role: 'source' }],
|
|
5795
6023
|
}
|
|
5796
6024
|
state.layoutItems.push(pending)
|
|
5797
|
-
|
|
6025
|
+
const activeOperation = beginActiveOperation('cutout')
|
|
5798
6026
|
state.error = ''
|
|
5799
6027
|
setSelection([pendingId])
|
|
5800
|
-
await saveLayout(state.layoutItems)
|
|
5801
|
-
renderCanvas()
|
|
5802
|
-
renderChrome()
|
|
5803
|
-
|
|
5804
6028
|
try {
|
|
6029
|
+
await saveLayout(state.layoutItems)
|
|
6030
|
+
renderCanvas()
|
|
6031
|
+
renderChrome()
|
|
5805
6032
|
const result = await api('/api/cutout', {
|
|
5806
6033
|
method: 'POST',
|
|
5807
6034
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -5810,27 +6037,13 @@ async function runCutout(imageId) {
|
|
|
5810
6037
|
client_operation_id: pendingId,
|
|
5811
6038
|
}),
|
|
5812
6039
|
})
|
|
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
6040
|
await reload()
|
|
5826
6041
|
setSelection([result.id])
|
|
5827
6042
|
} catch (err) {
|
|
5828
6043
|
state.error = String(err.message || err)
|
|
5829
|
-
state.layoutItems = state.layoutItems.filter((item) => item.id !== pendingId)
|
|
5830
|
-
await saveLayout(state.layoutItems)
|
|
5831
6044
|
await reload()
|
|
5832
6045
|
} finally {
|
|
5833
|
-
|
|
6046
|
+
endActiveOperation(activeOperation)
|
|
5834
6047
|
renderCanvas()
|
|
5835
6048
|
renderChrome()
|
|
5836
6049
|
}
|