@uzooai/anime-ai-cli 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.agents/skills/uzoo-anime-cli/SKILL.md +83 -0
  2. package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +51 -0
  3. package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
  4. package/.agents/skills/uzoo-anime-cli/references/commands.md +250 -0
  5. package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +152 -0
  6. package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +175 -0
  7. package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
  8. package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +123 -0
  9. package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +95 -0
  10. package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +94 -0
  11. package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
  12. package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +300 -0
  13. package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +41 -0
  14. package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +71 -0
  15. package/CHANGELOG.md +92 -0
  16. package/LICENSE +21 -0
  17. package/README.md +242 -3
  18. package/assets/README.md +4 -0
  19. package/assets/favicon-uzoo.png +0 -0
  20. package/assets/logo-uzoo.svg +7 -0
  21. package/docs/CLI/345/275/223/345/211/215/350/214/203/345/233/264/344/270/216/346/234/254/345/234/260/346/211/223/345/214/205-20261103.md +134 -0
  22. package/docs/CLI/346/216/210/346/235/203/346/216/245/345/217/243/344/270/216/344/273/244/347/211/214/350/207/252/345/212/250/345/210/267/346/226/260/350/256/276/350/256/241.md +540 -0
  23. package/docs/CLI/347/274/272/345/217/243/346/240/270/345/257/271/344/270/216/346/224/266/345/260/276-20261105.md +65 -0
  24. package/docs/CLI/347/275/221/351/241/265/344/277/235/345/255/230/350/212/202/347/202/271/347/274/272/345/244/261/344/272/244/346/216/245.md +52 -0
  25. package/docs/M0-M5/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +89 -0
  26. package/docs/npm/347/272/277/344/270/212/345/217/221/345/270/203/344/270/216/345/256/211/350/243/205/344/275/277/347/224/250.md +151 -0
  27. package/docs//344/270/213/350/275/275/346/216/245/345/217/243/351/200/202/351/205/215/344/270/216/351/252/214/346/224/266.md +107 -0
  28. package/docs//344/270/232/345/212/241/350/203/275/345/212/233/350/241/245/345/205/250/344/270/216Skill/347/273/223/346/236/204/350/247/204/345/210/222.md +311 -0
  29. package/docs//345/205/250/345/261/200/351/231/204/344/273/266/350/265/204/346/272/220/345/234/260/345/235/200/344/270/216/346/250/241/345/236/213/350/257/273/345/217/226/345/267/256/345/274/202.md +47 -0
  30. package/docs//345/206/205/351/203/250Windows/345/256/211/350/243/205/344/270/216/345/215/207/347/272/247/351/252/214/346/224/266/346/270/205/345/215/225.md +34 -0
  31. package/docs//345/233/276/347/211/207/346/212/240/345/233/276/346/216/245/345/205/245/344/270/216/351/252/214/346/224/266-20261104.md +110 -0
  32. package/docs//345/274/200/345/217/221/350/256/241/345/210/222.md +127 -0
  33. package/docs//345/274/202/346/255/245/346/212/240/345/233/276CLI/346/235/203/351/231/220/344/272/244/346/216/245-20261105.md +50 -0
  34. package/docs//345/274/202/346/255/245/346/212/240/345/233/276/347/234/237/345/256/236/351/252/214/346/224/266-20261105.md +44 -0
  35. package/docs//346/226/260/344/274/232/350/257/235/344/273/273/345/212/241/344/272/244/344/273/243-/351/230/266/346/256/2652A.md +79 -0
  36. package/docs//346/226/260/351/203/250/347/275/262/347/224/273/345/270/203/345/206/262/347/252/201/351/252/214/346/224/266-20261102.md +61 -0
  37. package/docs//346/231/256/351/200/232/346/265/217/350/247/210/345/231/250/347/231/273/345/275/225.md +53 -0
  38. package/docs//346/255/243/345/274/217/347/253/231/345/210/207/346/215/242/344/270/216/351/252/214/346/224/266-20261104.md +90 -0
  39. package/docs//347/224/273/345/270/203CLI/344/270/213/344/270/200/351/230/266/346/256/265/345/274/200/345/217/221/350/256/241/345/210/222.md +136 -0
  40. package/docs//347/224/273/345/270/203CLI/351/235/236/350/275/254/347/273/230/345/256/214/345/226/204/350/256/241/345/210/222.md +183 -0
  41. package/docs//347/224/273/345/270/203/350/203/275/345/212/233/345/245/221/347/272/246/346/240/270/345/257/271.md +59 -0
  42. package/docs//347/234/237/345/256/236/344/270/232/345/212/241/346/216/245/345/205/245/344/270/216/346/265/213/350/257/225.md +1004 -0
  43. package/docs//347/264/240/346/235/220/345/205/245/345/272/223/344/270/216/347/224/273/345/270/203/344/277/235/345/255/230/351/227/256/351/242/230/345/256/232/344/275/215.md +52 -0
  44. package/docs//351/230/266/346/256/2653B/351/252/214/346/224/266/344/272/244/346/216/245.md +44 -0
  45. package/docs//351/230/266/346/256/265/344/270/200/351/252/214/346/224/266.md +60 -0
  46. package/docs//351/235/236/350/275/254/347/273/230/346/214/201/347/273/255/345/256/214/345/226/204/344/270/216/351/252/214/346/224/266-20261103.md +76 -0
  47. package/docs//351/235/236/350/275/254/347/273/230/347/254/254/344/272/214/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +39 -0
  48. package/docs//351/235/236/350/275/254/347/273/230/350/241/245/345/205/205/346/235/241/344/273/266/351/252/214/346/224/266-20261103.md +113 -0
  49. package/docs//351/235/236/350/275/254/347/273/230/351/246/226/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +71 -0
  50. package/examples/matting.json +5 -0
  51. package/examples/real-business/image-request.template.json +7 -0
  52. package/examples/real-business/node-patch.template.json +11 -0
  53. package/extensions/uzoo-cli-login/manifest.json +9 -0
  54. package/extensions/uzoo-cli-login/popup.css +1 -0
  55. package/extensions/uzoo-cli-login/popup.html +14 -0
  56. package/extensions/uzoo-cli-login/popup.js +65 -0
  57. package/package.json +56 -6
  58. package/scripts/check-package-resources.mjs +56 -0
  59. package/scripts/check-release.mjs +19 -0
  60. package/scripts/pack-local.mjs +33 -0
  61. package/scripts/release-policy.mjs +22 -0
  62. package/src/agreement-page.js +62 -0
  63. package/src/agreement.js +419 -0
  64. package/src/annotation-media.js +386 -0
  65. package/src/api.js +168 -0
  66. package/src/browser-login.js +13 -0
  67. package/src/callback-page.js +44 -0
  68. package/src/canvas.js +105 -0
  69. package/src/cli-authorization.js +122 -0
  70. package/src/cli.js +459 -0
  71. package/src/download-name.js +17 -0
  72. package/src/draft-finalize.js +229 -0
  73. package/src/generation-graph.js +124 -0
  74. package/src/generation-input.js +472 -0
  75. package/src/graph-disconnect.js +89 -0
  76. package/src/graph.js +431 -0
  77. package/src/group-edit.js +53 -0
  78. package/src/group-layout.js +113 -0
  79. package/src/group-membership.js +126 -0
  80. package/src/history-add.js +119 -0
  81. package/src/image-annotation.js +246 -0
  82. package/src/image-crop.js +283 -0
  83. package/src/image-matting.js +82 -0
  84. package/src/image-media.js +89 -0
  85. package/src/image-output.js +385 -0
  86. package/src/local-output.js +23 -0
  87. package/src/material-download.js +55 -0
  88. package/src/material-save.js +254 -0
  89. package/src/materials.js +147 -0
  90. package/src/matting-background.js +73 -0
  91. package/src/matting-jobs.js +63 -0
  92. package/src/matting-worker.js +49 -0
  93. package/src/matting.js +170 -0
  94. package/src/media-copy.js +53 -0
  95. package/src/media.js +343 -0
  96. package/src/model-options.js +76 -0
  97. package/src/multipart.js +28 -0
  98. package/src/node-copy.js +95 -0
  99. package/src/node-delete.js +49 -0
  100. package/src/node-history.js +231 -0
  101. package/src/normal-login.js +118 -0
  102. package/src/package-info.js +8 -0
  103. package/src/png-alpha.js +60 -0
  104. package/src/protocol.js +83 -0
  105. package/src/remake.js +738 -0
  106. package/src/repaint-archive.js +53 -0
  107. package/src/repaint-delivery.js +783 -0
  108. package/src/repaint-export.js +63 -0
  109. package/src/repaint-graph.js +214 -0
  110. package/src/repaint-history.js +267 -0
  111. package/src/repaint-materials.js +601 -0
  112. package/src/repaint-reverse.js +614 -0
  113. package/src/repaint-sequence.js +132 -0
  114. package/src/repaint-settings.js +576 -0
  115. package/src/repaint-video.js +798 -0
  116. package/src/repaint.js +326 -0
  117. package/src/resource.js +110 -0
  118. package/src/review.js +435 -0
  119. package/src/service.js +85 -0
  120. package/src/store.js +100 -0
  121. package/src/video-clip.js +381 -0
  122. package/src/video-frame.js +296 -0
  123. package/src/workflows.js +214 -0
@@ -0,0 +1,381 @@
1
+ import crypto from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { atomic, json, root, safeName } from './store.js'
4
+ import { scopedCanvas } from './materials.js'
5
+ import { readSource } from './generation-input.js'
6
+ import { segment } from './workflows.js'
7
+
8
+ const text = (value) => String(value ?? '').trim()
9
+ const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
10
+ const fail = (code, message, diagnostic) =>
11
+ Object.assign(new Error(message), { code, ...(diagnostic ? { diagnostic } : {}) })
12
+ const positiveId = (value) => /^[1-9]\d*$/.test(text(value)) && Number.isSafeInteger(Number(value))
13
+ export const clipPath = (profile, operation) =>
14
+ path.join(root(), 'video-clips', safeName(profile), safeName(operation) + '.json')
15
+ export function clipInput(value) {
16
+ if (
17
+ !value ||
18
+ typeof value !== 'object' ||
19
+ Array.isArray(value) ||
20
+ Object.keys(value).some(
21
+ (key) => !['doc_id', 'node_id', 'space', 'team_uid', 'start_ms', 'end_ms'].includes(key)
22
+ )
23
+ )
24
+ throw new Error('剪辑输入无效或包含未接入字段')
25
+ if (!text(value.doc_id) || !text(value.node_id)) throw new Error('剪辑缺少来源 doc_id/node_id')
26
+ if (
27
+ ![value.start_ms, value.end_ms].every(Number.isSafeInteger) ||
28
+ value.start_ms < 0 ||
29
+ value.end_ms - value.start_ms < 500 ||
30
+ value.end_ms - value.start_ms > 120000
31
+ )
32
+ throw new Error('剪辑区间必须为整数毫秒、起点非负,片段时长 500–120000ms')
33
+ return { ...value }
34
+ }
35
+ async function identity(client) {
36
+ const me = await client.raw('/auth/me'),
37
+ owner = text(me.uid ?? me.id)
38
+ if (!owner) throw new Error('剪辑账号信息缺少 UID')
39
+ return owner
40
+ }
41
+ async function context(client, input) {
42
+ const doc = await scopedCanvas(client, input.doc_id, input.space, input.team_uid)
43
+ const node = doc.nodes.find((candidate) => candidate.id === input.node_id)
44
+ if (
45
+ !node ||
46
+ !['video', 'video_upload'].includes(node.type) ||
47
+ node.data?.extra?.repaint ||
48
+ node.data?.extra?.video_remake ||
49
+ node.data?.extra?.clip_redraw
50
+ )
51
+ throw new Error('当前剪辑只支持普通视频生成/素材节点,派生流程须专门接入')
52
+ const source = readSource(node),
53
+ data = node.data || {}
54
+ if (!source.url || source.uploading || source.uploadError)
55
+ throw new Error('源视频缺少已上传完成的真实输出')
56
+ const taskId =
57
+ data.sourceTaskId ||
58
+ data.extra?.source_task_id ||
59
+ data.lastTaskId ||
60
+ data.last_task_id ||
61
+ data.runtime?.taskId
62
+ if (!positiveId(taskId))
63
+ throw new Error('当前剪辑要求同画布真实任务关联;无任务的素材库 source_ref 路线待接入')
64
+ const refs = [
65
+ data.resultFileRef,
66
+ data.fileRef,
67
+ data.file_ref,
68
+ data.extra?.file_ref,
69
+ data.result?.file_ref,
70
+ data.node_output?.file_ref
71
+ ]
72
+ .map(text)
73
+ .filter(Boolean)
74
+ const task = await client.raw('/canvas/generations/' + segment(taskId))
75
+ if (new Set(refs).size !== 1 || refs[0] !== text(task.result?.file_ref))
76
+ throw new Error('源视频引用与原任务结果不一致;先核对当前媒体')
77
+ if (
78
+ String(task.task_id ?? task.id) !== String(taskId) ||
79
+ task.doc_id !== doc.id ||
80
+ task.segment_type !== 'video' ||
81
+ task.status !== 'completed'
82
+ )
83
+ throw new Error('剪辑来源与同画布已完成视频任务不匹配')
84
+ const duration = Number(task.result?.duration || data.duration)
85
+ if (!Number.isFinite(duration) || duration <= 0 || input.end_ms > Math.round(duration * 1000))
86
+ throw new Error('剪辑结束点超出源视频时长,或缺少真实时长')
87
+ return {
88
+ doc,
89
+ node,
90
+ ref: refs[0],
91
+ body: { source_task_id: Number(taskId), start_ms: input.start_ms, end_ms: input.end_ms }
92
+ }
93
+ }
94
+ function normalizeClip(clip, record) {
95
+ if (
96
+ !positiveId(clip?.id) ||
97
+ Number(clip.source_task_id) !== record.body.source_task_id ||
98
+ clip.start_ms !== record.body.start_ms ||
99
+ clip.end_ms !== record.body.end_ms ||
100
+ clip.duration_ms !== record.body.end_ms - record.body.start_ms ||
101
+ !clip.url ||
102
+ !clip.file_ref ||
103
+ !text(clip.mime_type).startsWith('video/')
104
+ )
105
+ throw fail(
106
+ 'CLIP_RESULT_UNKNOWN',
107
+ '剪辑响应的来源/区间/媒体引用不完整;先查询原片段,不再次剪辑'
108
+ )
109
+ if (clip.canvas_task_id != null && !positiveId(clip.canvas_task_id))
110
+ throw fail('CLIP_RESULT_UNKNOWN', '片段的真实画布任务关联无效;不把片段 ID 当任务 ID')
111
+ return structuredClone(clip)
112
+ }
113
+ const summary = (record) => ({
114
+ operation: record.operation,
115
+ state: record.state,
116
+ doc_id: record.input.doc_id,
117
+ source_node_id: record.input.node_id,
118
+ result_node_id: record.resultNodeId,
119
+ clip_id: record.clip?.id || null,
120
+ canvas_task_id: record.clip?.canvas_task_id || null,
121
+ canvas_synced: record.canvasSynced === true,
122
+ ...(record.clip ? { clip: record.clip } : {}),
123
+ ...(record.lastFailure ? { last_failure: record.lastFailure } : {})
124
+ })
125
+ async function bound(client, profile, operation) {
126
+ const record = await json(clipPath(profile, operation), null)
127
+ if (!record) throw new Error('未找到原剪辑 operation')
128
+ if (record.baseUrl !== client.baseUrl || record.owner !== (await identity(client)))
129
+ throw new Error('剪辑 operation 的账号或服务不匹配')
130
+ const current = await context(client, record.input)
131
+ if (hash(current.body) !== hash(record.body) || current.ref !== record.sourceRef)
132
+ throw new Error('剪辑源任务/视频引用已变化,停止恢复')
133
+ return { record, current }
134
+ }
135
+ export async function listClips(client, limit = 50, offset = 0) {
136
+ if (
137
+ !Number.isSafeInteger(limit) ||
138
+ limit < 1 ||
139
+ limit > 200 ||
140
+ !Number.isSafeInteger(offset) ||
141
+ offset < 0
142
+ )
143
+ throw new Error('片段分页参数无效')
144
+ return client.raw('/clip-redraw/clips?' + new URLSearchParams({ limit, offset }))
145
+ }
146
+ async function synchronize(client, profile, record, current) {
147
+ const { doc, node: source } = current,
148
+ clip = record.clip
149
+ if (!clip) return summary(record)
150
+ // Never restore a node already observed as saved and subsequently deleted.
151
+ const existing = doc.nodes.find((node) => node.id === record.resultNodeId)
152
+ if (record.canvasSavedOnce && !existing) throw new Error('剪辑结果节点已被删除;停止,不自动重建')
153
+ if (existing) {
154
+ if (
155
+ text(existing.data?.extra?.file_ref) !== clip.file_ref ||
156
+ (clip.canvas_task_id && String(existing.data?.last_task_id) !== String(clip.canvas_task_id))
157
+ )
158
+ throw new Error('结果节点已存在且素材关联变化;保留当前内容')
159
+ const edge = doc.edges.find(
160
+ (edge) =>
161
+ edge.source === source.id &&
162
+ edge.target === existing.id &&
163
+ edge.data?.role === 'video-clip-link'
164
+ )
165
+ if (!edge) throw new Error('剪辑连线已被删除或变更;不自动重建连线')
166
+ record.canvasSynced = true
167
+ record.canvasSavedOnce = true
168
+ record.state = 'completed'
169
+ await atomic(clipPath(profile, record.operation), record)
170
+ return summary(record)
171
+ }
172
+ if (!Number.isSafeInteger(doc.version) || !doc.viewport || !doc.meta || !Array.isArray(doc.edges))
173
+ throw new Error('剪辑画布快照不完整,停止保存')
174
+ const taskId = clip.canvas_task_id
175
+ const aspect = clip.width && clip.height ? clip.width + ':' + clip.height : ''
176
+ const output = {
177
+ url: clip.url,
178
+ file_ref: clip.file_ref,
179
+ mime_type: clip.mime_type,
180
+ filename: clip.file_name,
181
+ file_size: clip.file_size,
182
+ width: clip.width,
183
+ height: clip.height,
184
+ duration: clip.duration_ms / 1000,
185
+ status: 'completed',
186
+ ...(taskId ? { task_id: taskId } : {})
187
+ }
188
+ const result = {
189
+ id: record.resultNodeId,
190
+ type: 'video_upload',
191
+ position: {
192
+ x: Math.round((source.position?.x || 0) + (source.width || 486) + 80),
193
+ y: Math.round(source.position?.y || 0)
194
+ },
195
+ data: {
196
+ title: clip.file_name || '视频剪辑',
197
+ mediaType: 'video',
198
+ url: clip.url,
199
+ file_ref: clip.file_ref,
200
+ file_name: clip.file_name || '',
201
+ file_size: clip.file_size || 0,
202
+ mime_type: clip.mime_type,
203
+ duration: clip.duration_ms / 1000,
204
+ width: clip.width,
205
+ height: clip.height,
206
+ hasAudio: clip.has_audio === true,
207
+ clipId: clip.id,
208
+ clip,
209
+ result: output,
210
+ ...(taskId
211
+ ? { last_task_id: taskId, sourceTaskId: String(taskId), status: 'completed' }
212
+ : {}),
213
+ extra: {
214
+ node_kind: 'asset',
215
+ file_ref: clip.file_ref,
216
+ aspect_ratio: aspect,
217
+ clip,
218
+ ...(taskId ? { source_task_id: String(taskId) } : {})
219
+ }
220
+ }
221
+ }
222
+ const edge = {
223
+ id: 'clip-link-' + record.resultNodeId,
224
+ source: source.id,
225
+ target: result.id,
226
+ sourceHandle: '',
227
+ targetHandle: '',
228
+ type: 'default',
229
+ data: { role: 'video-clip-link' }
230
+ }
231
+ // Mark the save attempt before sending. If its response is lost, recovery only
232
+ // accepts the persisted node; absence then needs investigation, never recreation.
233
+ record.canvasSavedOnce = true
234
+ await atomic(clipPath(profile, record.operation), record)
235
+ let saved
236
+ try {
237
+ saved = await client.raw(
238
+ '/canvas/docs/' + segment(doc.id),
239
+ {
240
+ method: 'PUT',
241
+ body: {
242
+ version: doc.version,
243
+ name: doc.name,
244
+ viewport: doc.viewport,
245
+ nodes: [...doc.nodes, result],
246
+ edges: [...doc.edges, edge],
247
+ meta: doc.meta
248
+ }
249
+ },
250
+ { retry: false }
251
+ )
252
+ } catch (error) {
253
+ // A known CAS rejection did not save the node; permit explicit recovery from
254
+ // the fresh document without invoking clipping again.
255
+ if (error.status === 409 || error.apiCode === 3002) record.canvasSavedOnce = false
256
+ record.state = 'sync_pending'
257
+ await atomic(clipPath(profile, record.operation), record)
258
+ throw fail(
259
+ 'CLIP_SYNC_PENDING',
260
+ '原片段已保存,画布同步未确认;先检查连接和最新画布,再恢复原 operation,不再次剪辑'
261
+ )
262
+ }
263
+ const savedNode = saved.nodes?.find((node) => node.id === result.id)
264
+ if (
265
+ saved.id !== doc.id ||
266
+ saved.version <= doc.version ||
267
+ savedNode?.data?.extra?.file_ref !== clip.file_ref ||
268
+ !saved.edges?.some((edge) => edge.id === 'clip-link-' + record.resultNodeId)
269
+ )
270
+ throw fail('CLIP_SYNC_PENDING', '剪辑保存响应不完整;读回原结果节点,不重复截取')
271
+ record.canvasSynced = true
272
+ record.state = 'completed'
273
+ await atomic(clipPath(profile, record.operation), record)
274
+ return summary(record)
275
+ }
276
+ export async function startClip(client, profile, value, operation, confirmed = false) {
277
+ const input = clipInput(value),
278
+ file = clipPath(profile, operation)
279
+ const existing = await json(file, null)
280
+ if (existing) {
281
+ if (hash(existing.input) !== hash(input)) throw new Error('原剪辑 operation 不可修改输入')
282
+ return getClip(client, profile, operation) // Never replay the synchronous POST.
283
+ }
284
+ const owner = await identity(client),
285
+ current = await context(client, input)
286
+ if (!confirmed)
287
+ return {
288
+ operation,
289
+ submitted: false,
290
+ generation_submitted: false,
291
+ wallet: { space: input.space, team_uid: input.team_uid || null },
292
+ ...current.body,
293
+ duration_ms: input.end_ms - input.start_ms,
294
+ next: '核对源视频与区间、暂停同画布编辑后,加 --yes 截取并保存新素材节点;不是片段重拍'
295
+ }
296
+ const baseline = await listClips(client, 1, 0)
297
+ if (!Array.isArray(baseline.items)) throw new Error('片段列表响应不完整,停止提交')
298
+ const record = {
299
+ operation,
300
+ owner,
301
+ input,
302
+ body: current.body,
303
+ sourceRef: current.ref,
304
+ baseUrl: client.baseUrl,
305
+ baselineId: Math.max(0, ...baseline.items.map((clip) => Number(clip.id) || 0)),
306
+ resultNodeId: 'clip-' + crypto.randomUUID(),
307
+ state: 'submitting',
308
+ createdAt: new Date().toISOString()
309
+ }
310
+ await atomic(file, record)
311
+ try {
312
+ record.clip = normalizeClip(
313
+ await client.raw('/video/clips', { method: 'POST', body: record.body }, { retry: false }),
314
+ record
315
+ )
316
+ } catch (error) {
317
+ record.state = 'unknown'
318
+ record.lastFailure = {
319
+ ...(Number.isInteger(error.status) ? { http_status: error.status } : {}),
320
+ ...(Number.isFinite(error.apiCode) ? { api_code: error.apiCode } : {}),
321
+ ...(error.requestId ? { request_id: error.requestId } : {})
322
+ }
323
+ await atomic(file, record)
324
+ throw fail(
325
+ 'CLIP_RESULT_UNKNOWN',
326
+ '剪辑提交结果未确认;先检查后端连接并查询片段列表,原 operation 不重新 POST',
327
+ record.lastFailure
328
+ )
329
+ }
330
+ record.state = 'clipped'
331
+ await atomic(file, record)
332
+ return recoverClip(client, profile, operation)
333
+ }
334
+ export async function getClip(client, profile, operation) {
335
+ const { record, current } = await bound(client, profile, operation)
336
+ if (record.canvasSavedOnce && record.clip) {
337
+ const result = current.doc.nodes.find((node) => node.id === record.resultNodeId)
338
+ const line = current.doc.edges.some(
339
+ (edge) =>
340
+ edge.source === record.input.node_id &&
341
+ edge.target === record.resultNodeId &&
342
+ edge.data?.role === 'video-clip-link'
343
+ )
344
+ if (!result || result.data?.extra?.file_ref !== record.clip.file_ref || !line)
345
+ return {
346
+ ...summary(record),
347
+ state: 'canvas_changed',
348
+ canvas_synced: false,
349
+ next: '原剪辑已产出,但结果节点或连线已删除/变化;核对用户编辑,不自动重建或再次剪辑'
350
+ }
351
+ }
352
+ return {
353
+ ...summary(record),
354
+ ...(!record.clip
355
+ ? {
356
+ next: '查询 video clips;确认原片段 ID 后 clip-recover 原 operation --clip-id ID,不重新截取'
357
+ }
358
+ : {})
359
+ }
360
+ }
361
+ export async function recoverClip(client, profile, operation, clipId) {
362
+ const { record, current } = await bound(client, profile, operation)
363
+ if (clipId !== undefined) {
364
+ if (!positiveId(clipId) || Number(clipId) <= record.baselineId)
365
+ throw new Error('恢复片段 ID 无效或早于本次截取,不关联旧片段')
366
+ if (record.clip && Number(record.clip.id) !== Number(clipId))
367
+ throw new Error('原 operation 已关联另一个真实片段')
368
+ let found
369
+ for (let offset = 0; offset < 2000; offset += 200) {
370
+ const page = await listClips(client, 200, offset)
371
+ found = page.items?.find((clip) => String(clip.id) === String(clipId))
372
+ if (found || (page.items?.length || 0) < 200) break
373
+ }
374
+ if (!found) throw new Error('未在当前账号片段列表找到指定片段;停止,不重新截取')
375
+ record.clip = normalizeClip(found, record)
376
+ await atomic(clipPath(profile, operation), record)
377
+ }
378
+ if (!record.clip) return getClip(client, profile, operation)
379
+ normalizeClip(record.clip, record)
380
+ return synchronize(client, profile, record, current)
381
+ }
@@ -0,0 +1,296 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { atomic, json, root, safeName } from './store.js'
5
+ import { scopedCanvas } from './materials.js'
6
+ import { readSource } from './generation-input.js'
7
+ import { segment } from './workflows.js'
8
+ import { imageOutputWorkflow } from './image-output.js'
9
+ import { mediaTools, downloadVideo, extractFrames, hashFile, sameVideoReferences } from './media.js'
10
+ const defaultMedia = { mediaTools, downloadVideo, extractFrames }
11
+
12
+ const text = (value) => String(value ?? '').trim()
13
+ const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
14
+ export const framePath = (profile, operation) =>
15
+ path.join(root(), 'video-frames', safeName(profile), safeName(operation) + '.json')
16
+ export function frameInput(value) {
17
+ if (
18
+ !value ||
19
+ typeof value !== 'object' ||
20
+ Array.isArray(value) ||
21
+ Object.keys(value).some(
22
+ (key) => !['doc_id', 'node_id', 'space', 'team_uid', 'times_ms'].includes(key)
23
+ )
24
+ )
25
+ throw new Error('截帧输入无效或包含未接入字段')
26
+ if (!text(value.doc_id) || !text(value.node_id)) throw new Error('截帧缺少 doc_id/node_id')
27
+ if (
28
+ !Array.isArray(value.times_ms) ||
29
+ !value.times_ms.length ||
30
+ value.times_ms.length > 3 ||
31
+ value.times_ms.some((t) => !Number.isSafeInteger(t) || t < 0) ||
32
+ new Set(value.times_ms).size !== value.times_ms.length
33
+ )
34
+ throw new Error('截帧 times_ms 须为 1–3 个不重复的非负整数毫秒')
35
+ return { ...value, times_ms: [...value.times_ms].sort((a, b) => a - b) }
36
+ }
37
+ async function identity(client) {
38
+ const me = await client.raw('/auth/me'),
39
+ owner = text(me.uid ?? me.id)
40
+ if (!owner) throw new Error('截帧账号响应缺少 UID')
41
+ return owner
42
+ }
43
+ async function context(client, input) {
44
+ const doc = await scopedCanvas(client, input.doc_id, input.space, input.team_uid)
45
+ const node = doc.nodes.find((node) => node.id === input.node_id)
46
+ if (
47
+ !node ||
48
+ !['video', 'video_upload'].includes(node.type) ||
49
+ node.data?.extra?.repaint ||
50
+ node.data?.extra?.video_remake ||
51
+ node.data?.extra?.clip_redraw
52
+ )
53
+ throw new Error('截帧仅支持普通视频/视频素材节点')
54
+ const source = readSource(node),
55
+ data = node.data || {}
56
+ const refs = [
57
+ data.resultFileRef,
58
+ data.fileRef,
59
+ data.file_ref,
60
+ data.extra?.file_ref,
61
+ data.result?.file_ref,
62
+ data.node_output?.file_ref
63
+ ]
64
+ .map(text)
65
+ .filter(Boolean)
66
+ if (!source.url || source.uploading || source.uploadError || new Set(refs).size !== 1)
67
+ throw new Error('源视频未上传完成或媒体引用不一致')
68
+ const taskId = text(
69
+ data.sourceTaskId || data.extra?.source_task_id || data.last_task_id || data.lastTaskId
70
+ )
71
+ if (!/^[1-9]\d*$/.test(taskId))
72
+ throw new Error('本轮截帧要求同画布真实视频任务关联,缺少任务的素材路线待接入')
73
+ const task = await client.raw('/canvas/generations/' + segment(taskId))
74
+ if (
75
+ String(task.task_id ?? task.id) !== taskId ||
76
+ task.doc_id !== doc.id ||
77
+ task.segment_type !== 'video' ||
78
+ task.status !== 'completed'
79
+ )
80
+ throw new Error('截帧源任务/画布/视频引用不匹配')
81
+ if (!(await sameVideoReferences(client, refs[0], task.result?.file_ref)))
82
+ throw new Error('截帧源视频与任务结果不是同一媒体;停止,不替换当前素材')
83
+ const duration = Number(task.result?.duration || data.duration)
84
+ if (
85
+ !Number.isFinite(duration) ||
86
+ duration <= 0 ||
87
+ input.times_ms.some((t) => t >= duration * 1000)
88
+ )
89
+ throw new Error('截帧时刻超出已知视频时长或位于末尾')
90
+ return {
91
+ doc,
92
+ node,
93
+ url: client.baseUrl + '/files/' + encodeURIComponent(refs[0]),
94
+ binding: { task_id: taskId, file_ref: refs[0] }
95
+ }
96
+ }
97
+ const fileName = (title, time) =>
98
+ (String(title || 'video')
99
+ .trim()
100
+ .replace(/[\\/:*?"<>|]+/g, '_')
101
+ .slice(0, 80) || 'video') +
102
+ '-frame-' +
103
+ time +
104
+ '.jpg'
105
+ const titleAt = (time) =>
106
+ '截图 ' +
107
+ String(Math.floor(time / 60000)).padStart(2, '0') +
108
+ ':' +
109
+ ((time % 60000) / 1000).toFixed(2).padStart(5, '0')
110
+ export function placeFrame(nodes, source) {
111
+ const base = {
112
+ x: Math.round((source.position?.x || 0) + (Number(source.width) || 320) + 80),
113
+ y: Math.round(source.position?.y || 0)
114
+ }
115
+ let position = { ...base }
116
+ for (let attempt = 0; attempt <= nodes.length; attempt++) {
117
+ const overlaps = nodes.some((node) => {
118
+ const left = Number(node.position?.x) || 0,
119
+ top = Number(node.position?.y) || 0
120
+ return (
121
+ position.x < left + (Number(node.width) || 320) + 32 &&
122
+ position.x + 352 > left &&
123
+ position.y < top + (Number(node.height) || 300) + 32 &&
124
+ position.y + 332 > top
125
+ )
126
+ })
127
+ if (!overlaps) return position
128
+ position = { x: base.x, y: position.y + 332 }
129
+ }
130
+ throw new Error('无法为截帧分配无重叠位置')
131
+ }
132
+ const summary = (record) => ({
133
+ operation: record.operation,
134
+ state: record.state,
135
+ generation_submitted: false,
136
+ library_saved: false,
137
+ doc_id: record.input.doc_id,
138
+ source_node_id: record.input.node_id,
139
+ items: record.items.map((item) => ({
140
+ node_id: item.nodeId,
141
+ time_ms: item.time,
142
+ frame_time_ms: item.local?.frame_time_ms ?? null,
143
+ state: item.state,
144
+ canvas_bound: item.bound === true,
145
+ task_id: item.response?.task_id || null,
146
+ width: item.local?.width || null,
147
+ height: item.local?.height || null
148
+ })),
149
+ next:
150
+ record.state === 'completed'
151
+ ? '读取画布和图片核验;不会自动入库'
152
+ : '先检查后端连接和原节点;frame-recover 原 operation --yes 只恢复已知阶段,不重发未知 complete'
153
+ })
154
+ const { persistNodes, runUploads } = imageOutputWorkflow({
155
+ recordPath: framePath,
156
+ readContext: context,
157
+ nodeTitle: (item) => titleAt(item.time),
158
+ nodePosition: placeFrame,
159
+ edgeRole: 'video-frame-capture-link',
160
+ edgePrefix: 'frame-link-',
161
+ codePrefix: 'FRAME',
162
+ mime: 'image/jpeg',
163
+ summarize: summary
164
+ })
165
+ async function bound(client, profile, operation) {
166
+ const record = await json(framePath(profile, operation), null)
167
+ if (!record) throw new Error('未找到截帧 operation')
168
+ if (record.baseUrl !== client.baseUrl || record.owner !== (await identity(client)))
169
+ throw new Error('截帧 operation 的账号或服务不匹配')
170
+ const current = await context(client, record.input)
171
+ if (digest(record.binding) !== digest(current.binding))
172
+ throw new Error('截帧源素材/任务已变化,停止恢复')
173
+ return { record, current }
174
+ }
175
+ async function prepareLocal(client, profile, record, current, toolsOptions, media = defaultMedia) {
176
+ if (record.items.every((item) => item.local)) return
177
+ const tools = await media.mediaTools(toolsOptions)
178
+ await fs.mkdir(record.directory, { recursive: true })
179
+ const sourceFile = path.join(record.directory, 'source.mp4')
180
+ if (!record.download) {
181
+ // An interrupted download is local and reversible; remove only our fixed cached file.
182
+ await fs.rm(sourceFile, { force: true })
183
+ record.download = await media.downloadVideo(client, current.url, sourceFile)
184
+ await atomic(framePath(profile, record.operation), record)
185
+ } else if (digest(await hashFile(sourceFile)) !== digest(record.download))
186
+ throw new Error('本地源视频缓存发生变化')
187
+ // A failed local extraction has no remote side effects. Re-export only the
188
+ // operation-owned unfinished JPEGs, never alter a completed/uploaded artifact.
189
+ for (const item of record.items)
190
+ await fs.rm(path.join(record.directory, item.fileName), { force: true })
191
+ const frames = await media.extractFrames(
192
+ sourceFile,
193
+ record.input.times_ms,
194
+ record.items.map((item) => path.join(record.directory, item.fileName)),
195
+ tools
196
+ )
197
+ record.items.forEach((item, index) => {
198
+ item.local = frames[index]
199
+ })
200
+ record.state = 'local_ready'
201
+ await atomic(framePath(profile, record.operation), record)
202
+ }
203
+ export async function startFrames(
204
+ client,
205
+ profile,
206
+ value,
207
+ operation,
208
+ confirmed = false,
209
+ toolsOptions = {},
210
+ media = defaultMedia
211
+ ) {
212
+ const input = frameInput(value),
213
+ file = framePath(profile, operation),
214
+ old = await json(file, null)
215
+ if (old) {
216
+ if (digest(old.input) !== digest(input)) throw new Error('原截帧 operation 不可修改输入')
217
+ return getFrames(client, profile, operation)
218
+ }
219
+ const tools = await media.mediaTools(toolsOptions),
220
+ current = await context(client, input),
221
+ owner = await identity(client)
222
+ if (!confirmed)
223
+ return {
224
+ operation,
225
+ submitted: false,
226
+ generation_submitted: false,
227
+ library_saved: false,
228
+ source_task_id: current.binding.task_id,
229
+ times_ms: input.times_ms,
230
+ output_format: 'image/jpeg',
231
+ media_tools: 'available',
232
+ next: '核对时间与源视频、暂停画布编辑后,加 --yes 本地截帧并上传画布;不入素材库'
233
+ }
234
+ const directory = path.join(path.dirname(file), safeName(operation) + '-files')
235
+ const record = {
236
+ operation,
237
+ input,
238
+ owner,
239
+ baseUrl: client.baseUrl,
240
+ binding: current.binding,
241
+ directory,
242
+ state: 'preparing',
243
+ items: input.times_ms.map((time) => ({
244
+ time,
245
+ nodeId: 'frame-' + crypto.randomUUID(),
246
+ fileName: fileName(current.node.data?.title, time),
247
+ state: 'prepared'
248
+ })),
249
+ createdAt: new Date().toISOString()
250
+ }
251
+ await atomic(file, record)
252
+ await prepareLocal(client, profile, record, current, tools, media)
253
+ const fresh = await context(client, input)
254
+ if (digest(fresh.binding) !== digest(record.binding))
255
+ throw new Error('截帧期间源素材已变化,停止上传')
256
+ await persistNodes(client, profile, record, fresh)
257
+ return runUploads(client, profile, record)
258
+ }
259
+ export async function getFrames(client, profile, operation) {
260
+ const { record, current } = await bound(client, profile, operation),
261
+ result = summary(record)
262
+ for (const item of result.items)
263
+ if (item.canvas_bound) {
264
+ const original = record.items.find((frame) => frame.nodeId === item.node_id)
265
+ const node = current.doc.nodes.find((n) => n.id === item.node_id)
266
+ if (
267
+ node?.data?.extra?.file_ref !== original.response.result.file_ref ||
268
+ !current.doc.edges.some(
269
+ (e) =>
270
+ e.source === record.input.node_id &&
271
+ e.target === item.node_id &&
272
+ e.data?.role === 'video-frame-capture-link'
273
+ )
274
+ ) {
275
+ item.canvas_bound = false
276
+ item.state = 'canvas_changed'
277
+ result.state = 'canvas_changed'
278
+ }
279
+ }
280
+ return result
281
+ }
282
+ export async function recoverFrames(
283
+ client,
284
+ profile,
285
+ operation,
286
+ confirmed = false,
287
+ tools = {},
288
+ media = defaultMedia
289
+ ) {
290
+ if (!confirmed) return getFrames(client, profile, operation)
291
+ const { record, current } = await bound(client, profile, operation)
292
+ if (record.state === 'completed') return getFrames(client, profile, operation)
293
+ await prepareLocal(client, profile, record, current, tools, media)
294
+ await persistNodes(client, profile, record, (await bound(client, profile, operation)).current)
295
+ return runUploads(client, profile, record)
296
+ }