evomeme 0.1.7 → 0.1.9

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