evomeme 0.1.7-beta.1 → 0.1.7-beta.5

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