@uzooai/anime-ai-cli 0.0.0-stage → 0.6.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 (89) hide show
  1. package/.agents/skills/uzoo-anime-cli/SKILL.md +89 -0
  2. package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +41 -0
  3. package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
  4. package/.agents/skills/uzoo-anime-cli/references/commands.md +242 -0
  5. package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +142 -0
  6. package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +153 -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 +113 -0
  9. package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +64 -0
  10. package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +90 -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 +13 -0
  13. package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +37 -0
  14. package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +67 -0
  15. package/CHANGELOG.md +13 -0
  16. package/LICENSE +21 -0
  17. package/README.md +46 -3
  18. package/assets/favicon-uzoo.png +0 -0
  19. package/assets/logo-uzoo.svg +7 -0
  20. package/docs/Agent/346/216/245/345/205/245/344/270/216/347/211/210/346/234/254/346/233/264/346/226/260.md +43 -0
  21. package/examples/matting.json +5 -0
  22. package/extensions/uzoo-cli-login/manifest.json +9 -0
  23. package/extensions/uzoo-cli-login/popup.css +1 -0
  24. package/extensions/uzoo-cli-login/popup.html +14 -0
  25. package/extensions/uzoo-cli-login/popup.js +65 -0
  26. package/package.json +53 -6
  27. package/src/agreement-page.js +62 -0
  28. package/src/agreement.js +419 -0
  29. package/src/annotation-media.js +386 -0
  30. package/src/api.js +168 -0
  31. package/src/browser-login.js +13 -0
  32. package/src/callback-page.js +44 -0
  33. package/src/canvas.js +105 -0
  34. package/src/cli-authorization.js +122 -0
  35. package/src/cli.js +464 -0
  36. package/src/download-name.js +17 -0
  37. package/src/draft-finalize.js +229 -0
  38. package/src/generation-graph.js +124 -0
  39. package/src/generation-input.js +472 -0
  40. package/src/graph-disconnect.js +89 -0
  41. package/src/graph.js +431 -0
  42. package/src/group-edit.js +53 -0
  43. package/src/group-layout.js +113 -0
  44. package/src/group-membership.js +125 -0
  45. package/src/history-add.js +119 -0
  46. package/src/image-annotation.js +246 -0
  47. package/src/image-crop.js +283 -0
  48. package/src/image-matting.js +82 -0
  49. package/src/image-media.js +89 -0
  50. package/src/image-output.js +385 -0
  51. package/src/install.js +237 -0
  52. package/src/local-output.js +23 -0
  53. package/src/material-download.js +55 -0
  54. package/src/material-save.js +254 -0
  55. package/src/materials.js +147 -0
  56. package/src/matting-background.js +73 -0
  57. package/src/matting-jobs.js +63 -0
  58. package/src/matting-worker.js +49 -0
  59. package/src/matting.js +170 -0
  60. package/src/media-copy.js +53 -0
  61. package/src/media.js +343 -0
  62. package/src/model-options.js +76 -0
  63. package/src/multipart.js +28 -0
  64. package/src/node-copy.js +95 -0
  65. package/src/node-delete.js +49 -0
  66. package/src/node-history.js +231 -0
  67. package/src/normal-login.js +118 -0
  68. package/src/package-info.js +8 -0
  69. package/src/png-alpha.js +60 -0
  70. package/src/protocol.js +83 -0
  71. package/src/remake.js +738 -0
  72. package/src/repaint-archive.js +53 -0
  73. package/src/repaint-delivery.js +783 -0
  74. package/src/repaint-export.js +63 -0
  75. package/src/repaint-graph.js +214 -0
  76. package/src/repaint-history.js +267 -0
  77. package/src/repaint-materials.js +601 -0
  78. package/src/repaint-reverse.js +614 -0
  79. package/src/repaint-sequence.js +132 -0
  80. package/src/repaint-settings.js +576 -0
  81. package/src/repaint-video.js +798 -0
  82. package/src/repaint.js +326 -0
  83. package/src/resource.js +110 -0
  84. package/src/review.js +435 -0
  85. package/src/service.js +85 -0
  86. package/src/store.js +100 -0
  87. package/src/video-clip.js +381 -0
  88. package/src/video-frame.js +296 -0
  89. package/src/workflows.js +214 -0
@@ -0,0 +1,125 @@
1
+ // Frontend membership semantics; no runtime frontend dependency.
2
+ const DEFAULT_NODE_WIDTH = 200
3
+ const DEFAULT_NODE_HEIGHT = 100
4
+
5
+ function readNodeSize(node) {
6
+ const width = Number(node?.computedWidth) || Number(node?.width)
7
+ const height = Number(node?.computedHeight) || Number(node?.height)
8
+ return {
9
+ width: Number.isFinite(width) && width > 0 ? width : DEFAULT_NODE_WIDTH,
10
+ height: Number.isFinite(height) && height > 0 ? height : DEFAULT_NODE_HEIGHT,
11
+ }
12
+ }
13
+
14
+ export function getNodeGroupBox(node) {
15
+ const position = node?.position || { x: 0, y: 0 }
16
+ const size = readNodeSize(node)
17
+ return {
18
+ x: Number(position.x) || 0,
19
+ y: Number(position.y) || 0,
20
+ width: size.width,
21
+ height: size.height,
22
+ }
23
+ }
24
+
25
+ export function normalizeGroupBox(group) {
26
+ const position = group?.position || {}
27
+ const size = group?.size || {}
28
+ return {
29
+ x: Number(position.x) || 0,
30
+ y: Number(position.y) || 0,
31
+ width: Math.max(Number(size.width) || 0, 0),
32
+ height: Math.max(Number(size.height) || 0, 0),
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 节点中心点是否落在组框内。
38
+ */
39
+ export function isNodeInsideGroup(node, group) {
40
+ const box = normalizeGroupBox(group)
41
+ if (box.width <= 0 || box.height <= 0) return false
42
+
43
+ const nodeBox = getNodeGroupBox(node)
44
+ const centerX = nodeBox.x + nodeBox.width / 2
45
+ const centerY = nodeBox.y + nodeBox.height / 2
46
+ return (
47
+ centerX >= box.x &&
48
+ centerX <= box.x + box.width &&
49
+ centerY >= box.y &&
50
+ centerY <= box.y + box.height
51
+ )
52
+ }
53
+
54
+ export function syncGroupMemberships(state) {
55
+ if (!state || !Array.isArray(state.groups) || !Array.isArray(state.nodes)) return false
56
+
57
+ const existingNodeIds = new Set(state.nodes.map((node) => String(node?.id || '')).filter(Boolean))
58
+ const claimedNodeIds = new Set()
59
+ let changed = false
60
+
61
+ for (const group of state.groups) {
62
+ const activeIds = (Array.isArray(group.nodeIds) ? group.nodeIds : [])
63
+ .map((id) => String(id || ''))
64
+ .filter(Boolean)
65
+ const exitedIds = (Array.isArray(group.exitedNodeIds) ? group.exitedNodeIds : [])
66
+ .map((id) => String(id || ''))
67
+ .filter(Boolean)
68
+ const aliveIds = new Set([...activeIds, ...exitedIds].filter((id) => existingNodeIds.has(id)))
69
+
70
+ const nextActive = []
71
+ const nextExited = []
72
+
73
+ for (const id of activeIds) {
74
+ if (!aliveIds.has(id) || claimedNodeIds.has(id)) {
75
+ changed = true
76
+ continue
77
+ }
78
+ const node = state.nodes.find((item) => String(item?.id || '') === id)
79
+ if (isNodeInsideGroup(node, group)) {
80
+ nextActive.push(id)
81
+ claimedNodeIds.add(id)
82
+ } else {
83
+ nextExited.push(id)
84
+ changed = true
85
+ }
86
+ }
87
+
88
+ for (const id of exitedIds) {
89
+ if (!aliveIds.has(id) || claimedNodeIds.has(id)) {
90
+ changed = true
91
+ continue
92
+ }
93
+ const node = state.nodes.find((item) => String(item?.id || '') === id)
94
+ if (isNodeInsideGroup(node, group)) {
95
+ nextActive.push(id)
96
+ claimedNodeIds.add(id)
97
+ changed = true
98
+ } else {
99
+ nextExited.push(id)
100
+ }
101
+ }
102
+
103
+ const finalActive = [...new Set(nextActive)]
104
+ const finalExited = [...new Set(nextExited)]
105
+ const currentActive = [...new Set(activeIds)]
106
+ const currentExited = [...new Set(exitedIds)]
107
+ const activeChanged =
108
+ currentActive.length !== finalActive.length ||
109
+ currentActive.some((id, index) => id !== finalActive[index])
110
+ const exitedChanged =
111
+ currentExited.length !== finalExited.length ||
112
+ currentExited.some((id, index) => id !== finalExited[index])
113
+
114
+ if (activeChanged) {
115
+ group.nodeIds = finalActive
116
+ changed = true
117
+ }
118
+ if (exitedChanged) {
119
+ group.exitedNodeIds = finalExited
120
+ changed = true
121
+ }
122
+ }
123
+
124
+ return changed
125
+ }
@@ -0,0 +1,119 @@
1
+ import path from 'node:path'
2
+ import { isDeepStrictEqual as same } from 'node:util'
3
+ import { atomic, json, root, safeName } from './store.js'
4
+ import { nodeHistoryInput, historyContext, selectedHistory } from './node-history.js'
5
+ import { requireServiceReady } from './service.js'
6
+ import { captureMediaHistory } from './media-copy.js'
7
+ import { sameImageReferences, sameVideoReferences } from './media.js'
8
+
9
+ const plain = v => JSON.parse(JSON.stringify(v))
10
+ export const historyAddPath = (profile, operation) => path.join(root(), 'history-add', safeName(profile), safeName(operation) + '.json')
11
+ const persist = r => atomic(historyAddPath(r.profile, r.operation), r)
12
+ export function historyAddInput(value) {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k =>
14
+ !['doc_id','node_id','space','team_uid','task_id','new_node_id','position','title','expected_version'].includes(k))) throw Error('历史添加输入无效')
15
+ const { new_node_id, position, title, expected_version, ...selection } = value
16
+ const input = nodeHistoryInput(selection)
17
+ if (typeof new_node_id !== 'string' || !new_node_id.trim() || new_node_id === input.node_id ||
18
+ !position || Object.keys(position).some(k => !['x','y'].includes(k)) || !Number.isFinite(position.x) || !Number.isFinite(position.y)) throw Error('历史添加需要不同的new_node_id及有效position')
19
+ if (title !== undefined && (typeof title !== 'string' || !title.trim())) throw Error('title无效')
20
+ if (expected_version !== undefined && (!Number.isSafeInteger(expected_version) || expected_version < 1)) throw Error('expected_version无效')
21
+ return {...input,new_node_id,position:{x:Math.round(position.x),y:Math.round(position.y)},...(title === undefined ? {} : {title}),...(expected_version === undefined ? {} : {expected_version})}
22
+ }
23
+ function promptOnly(item) {
24
+ const prompt = String(item.prompt || '').trim(), names = new Map()
25
+ const block = prompt.match(/<!-- flow:auto-material-preparation:start -->\r?\n([\s\S]*?)\r?\n<!-- flow:auto-material-preparation:end -->/)?.[1] || ''
26
+ for (const match of block.matchAll(/(@(?:图片|视频|音频)\d+)是([^;;\r\n]+?)(?=[;;。]|$)/g)) names.set(match[1],match[2])
27
+ const allowed = new Set(['image','video','audio'].flatMap(type => (item[`reference_${type}_urls`] || []).map((_,i) => '@'+({image:'图片',video:'视频',audio:'音频'}[type])+(i+1))))
28
+ return prompt.replace(/<!-- flow:auto-material-preparation:start -->\r?\n[\s\S]*?\r?\n<!-- flow:auto-material-preparation:end -->(?:\r?\n){0,2}/g,'')
29
+ .replace(/@(?:图片|视频|音频)\d+/g, marker => allowed.has(marker) ? names.get(marker) || marker : marker)
30
+ }
31
+ // Counterpart of buildCompletedHistoryNodeData: new ordinary generation node,
32
+ // output only, no inherited inputs, review authorization, draft or running task.
33
+ // Store provenance in copied history so web serialization retains sourceTaskId.
34
+ export function buildHistoryAddedNode(source, item, input) {
35
+ const type = source.type, result = structuredClone(item.result)
36
+ for (const key of ['task_id','taskId','task_no','taskNo']) delete result[key]
37
+ const data = {title:input.title || `${type === 'image' ? '图片历史' : '视频历史'}${item.task_id}`,
38
+ prompt:promptOnly(item),model_name:String(item.model_name || ''),status:'completed',last_task_id:null,
39
+ duration:Number(item.duration ?? result.duration) || null,result:{...result,status:result.status || 'completed'},error_message:'',
40
+ extra:{node_kind:'generation',file_ref:result.file_ref,source_task_id:String(item.task_id),
41
+ source_doc_id:item.doc_id,source_node_id:item.node_id,
42
+ ...(item.aspect_ratio || item.extra?.aspect_ratio ? {aspect_ratio:item.aspect_ratio || item.extra.aspect_ratio} : {}),
43
+ ...(item.resolution || item.extra?.resolution ? {resolution:item.resolution || item.extra.resolution} : {}),
44
+ image_count:Number(item.extra?.image_count) || 1,
45
+ ...(type === 'image' ? {image_to_image_enabled:true,asset_type:'character'} : {})}}
46
+ const node = {id:input.new_node_id,type,position:input.position,width:486,height:420,data}
47
+ data.extra.copied_generation_history = captureMediaHistory({...node,id:item.node_id})
48
+ const history = data.extra.copied_generation_history[0]
49
+ Object.assign(history,{docId:item.doc_id,nodeId:item.node_id,segmentType:type,contentType:type,taskNo:String(item.task_no || ''),
50
+ mode:String(item.generation_mode || item.extra?.generation_mode || ''),prompt:String(item.prompt || ''),
51
+ createdAt:item.created_at || '',completedAt:item.completed_at || '',generatedAt:item.completed_at || item.created_at || '',
52
+ raw:{...structuredClone(item),id:String(item.task_id),source:'copied'}})
53
+ return plain(node)
54
+ }
55
+ function identity(item) {
56
+ const copy = plain(item)
57
+ for (const key of ['url','file_ref','preview_url','preview_file_ref']) delete copy.result[key]
58
+ return copy
59
+ }
60
+ const summary = r => ({operation:r.operation,state:r.state,doc_id:r.input.doc_id,source_node_id:r.input.node_id,
61
+ source_task_id:r.input.task_id,new_node_id:r.input.new_node_id,canvas_written:false,generation_submitted:false,library_saved:false,
62
+ add_verified:r.state === 'applied'})
63
+ async function bound(client, profile, operation) {
64
+ const r = await json(historyAddPath(profile,operation),null)
65
+ if (!r || r.schema !== 1 || r.profile !== profile || r.operation !== operation || r.baseUrl !== client.baseUrl ||
66
+ !r.before || !r.pendingNode || r.pendingNode.id !== r.input?.new_node_id) throw Error('历史添加operation不存在、不完整或服务不匹配')
67
+ historyAddInput(r.input)
68
+ const ctx = await historyContext(client,r.input)
69
+ if (ctx.owner !== r.owner) throw Error('历史添加账号不匹配')
70
+ return {r,ctx}
71
+ }
72
+ export async function addNodeHistory(client, profile, value, operation, confirmed = false) {
73
+ const input = historyAddInput(value)
74
+ await requireServiceReady(client)
75
+ const old = await json(historyAddPath(profile,operation),null)
76
+ if (old && (!same(old.input,input) || old.baseUrl !== client.baseUrl || old.profile !== profile)) throw Error('原历史添加operation绑定不可更改')
77
+ if (old?.attempted) return recoverHistoryAdd(client,profile,operation)
78
+ const ctx = await historyContext(client,input)
79
+ if (old && ctx.owner !== old.owner) throw Error('历史添加账号不匹配')
80
+ if (ctx.doc.nodes.some(n => n.id === input.new_node_id)) throw Error('新节点ID已存在;不覆盖')
81
+ if (input.expected_version !== undefined && input.expected_version !== ctx.doc.version) throw Error('画布版本已变化,重新预览')
82
+ const item = await selectedHistory(client,input,ctx)
83
+ if (!confirmed) {
84
+ const r = {schema:1,profile,operation,baseUrl:client.baseUrl,owner:ctx.owner,input,before:ctx.doc,
85
+ identity:identity(item),media:{url:item.result.url,file_ref:item.result.file_ref},pendingNode:buildHistoryAddedNode(ctx.node,item,input),state:'prepared'}
86
+ await persist(r)
87
+ return {...summary(r),expected_version:ctx.doc.version,node:r.pendingNode,source_unchanged:true,references_restored:false}
88
+ }
89
+ const {r,ctx:checked} = await bound(client,profile,operation)
90
+ if (!same(checked.doc,r.before) || !same(identity(item),r.identity)) throw Error('预览后画布或历史已变化;不覆盖')
91
+ if (!same(r.media,{url:item.result.url,file_ref:item.result.file_ref}) &&
92
+ !await (ctx.node.type === 'image' ? sameImageReferences : sameVideoReferences)(client,r.media.file_ref,item.result.file_ref)) throw Error('历史更新引用不能确认是原媒体')
93
+ r.pendingNode = buildHistoryAddedNode(ctx.node,item,input)
94
+ r.attempted = true; r.state = 'saving'; await persist(r)
95
+ const doc = checked.doc
96
+ try {
97
+ const saved = await client.raw('/canvas/docs/'+encodeURIComponent(input.doc_id),{method:'PUT',body:{version:doc.version,name:doc.name,
98
+ viewport:doc.viewport,nodes:[...doc.nodes,r.pendingNode],edges:doc.edges,meta:doc.meta}},{retry:false})
99
+ if (saved?.version !== doc.version+1) throw Error('保存响应版本不完整')
100
+ const result = await recoverHistoryAdd(client,profile,operation)
101
+ if (!result.add_verified || !result.canvas_preserved) throw Error('历史添加保存后读回不一致')
102
+ return {...result,canvas_written:true}
103
+ } catch (cause) {
104
+ r.state = cause.status === 409 || cause.apiCode === 3002 ? 'conflict' : 'save_unknown'; await persist(r)
105
+ throw Object.assign(Error('历史添加保存未确认;仅用原history-add-recover核对,不重发保存',{cause}),{code:'NODE_HISTORY_SAVE_PENDING'})
106
+ }
107
+ }
108
+ export async function recoverHistoryAdd(client,profile,operation) {
109
+ await requireServiceReady(client)
110
+ const {r,ctx} = await bound(client,profile,operation)
111
+ if (!r.attempted) return summary(r)
112
+ const matches = same(ctx.doc.nodes.find(n => n.id === r.input.new_node_id),r.pendingNode)
113
+ const preserved = same(ctx.doc.nodes.filter(n => n.id !== r.input.new_node_id),r.before.nodes) &&
114
+ ['edges','viewport','meta','name'].every(k => same(ctx.doc[k],r.before[k]))
115
+ if (matches) {r.state='applied';await persist(r)}
116
+ return {...summary(r),state:!matches && r.state === 'applied' ? 'canvas_changed' : r.state,
117
+ add_verified:matches,canvas_preserved:preserved,source_unchanged:same(ctx.node,r.before.nodes.find(n => n.id === r.input.node_id)),
118
+ version:ctx.doc.version,next:matches ? '原历史结果新增已核验;其他内容后续变化分别报告' : '保留当前画布;不自动覆盖、重建或重发保存'}
119
+ }
@@ -0,0 +1,246 @@
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 { downloadImage, hashFile } from './media.js'
6
+ import { readImageSource } from './image-crop.js'
7
+ import {
8
+ annotationCommands,
9
+ annotationGeometry,
10
+ annotationTools,
11
+ annotateImage
12
+ } from './annotation-media.js'
13
+ import { imageOutputWorkflow } from './image-output.js'
14
+
15
+ const mediaDefault = { annotationTools, annotateImage, downloadImage }
16
+ const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
17
+ export const annotationPath = (profile, operation) =>
18
+ path.join(root(), 'image-annotations', safeName(profile), safeName(operation) + '.json')
19
+ export function annotationInput(value) {
20
+ if (
21
+ !value ||
22
+ typeof value !== 'object' ||
23
+ Array.isArray(value) ||
24
+ Object.keys(value).some(
25
+ (k) => !['doc_id', 'node_id', 'space', 'team_uid', 'commands', 'font_file'].includes(k)
26
+ )
27
+ )
28
+ throw new Error('标注输入无效或包含未接入字段')
29
+ if (
30
+ typeof value.doc_id !== 'string' ||
31
+ !value.doc_id.trim() ||
32
+ typeof value.node_id !== 'string' ||
33
+ !value.node_id.trim()
34
+ )
35
+ throw new Error('标注缺少doc_id/node_id')
36
+ if (
37
+ value.font_file !== undefined &&
38
+ (typeof value.font_file !== 'string' || !value.font_file.trim())
39
+ )
40
+ throw new Error('font_file须为已有本机字体路径')
41
+ return { ...value, commands: annotationCommands(value.commands) }
42
+ }
43
+ async function identity(client) {
44
+ const me = await client.raw('/auth/me'),
45
+ uid = String(me.uid ?? me.id ?? '')
46
+ if (!uid) throw new Error('标注账号缺少UID')
47
+ return uid
48
+ }
49
+ async function context(client, input) {
50
+ const current = await readImageSource(client, input)
51
+ return {
52
+ ...current,
53
+ geometry: annotationGeometry(current.source, input.commands),
54
+ binding: { ...current.binding, source_type: current.node.type }
55
+ }
56
+ }
57
+ const summary = (record) => ({
58
+ operation: record.operation,
59
+ state: record.state,
60
+ generation_submitted: false,
61
+ library_saved: false,
62
+ doc_id: record.input.doc_id,
63
+ source_node_id: record.input.node_id,
64
+ ...record.geometry,
65
+ output_type: record.outputType,
66
+ command_count: record.input.commands.length,
67
+ command_types: [...new Set(record.input.commands.map((c) => c.type))],
68
+ items: record.items.map((item) => ({
69
+ node_id: item.nodeId,
70
+ state: item.state,
71
+ canvas_bound: item.bound === true,
72
+ task_id: item.response?.task_id || null,
73
+ width: item.local?.width || null,
74
+ height: item.local?.height || null
75
+ })),
76
+ next:
77
+ record.state === 'completed'
78
+ ? '读取画布/PNG核验;不自动入库或生成'
79
+ : '先查后端和原任务;annotate-recover原operation --yes恢复已知阶段,不重发未知complete'
80
+ })
81
+ const { persistNodes, runUploads } = imageOutputWorkflow({
82
+ recordPath: annotationPath,
83
+ readContext: context,
84
+ nodeTitle: (item) => item.title,
85
+ nodePosition: (_nodes, source) => ({
86
+ x: Number(source.position?.x || 0) + Number(source.width || 320) + 80,
87
+ y: Number(source.position?.y || 0)
88
+ }),
89
+ nodeType: (record) => record.outputType,
90
+ edgeRole: 'image-edit-link',
91
+ edgePrefix: 'annotation-link-',
92
+ codePrefix: 'ANNOTATION',
93
+ mime: 'image/png',
94
+ summarize: summary
95
+ })
96
+ async function bound(client, profile, operation) {
97
+ const record = await json(annotationPath(profile, operation), null)
98
+ if (!record) throw new Error('未找到标注operation')
99
+ if (record.baseUrl !== client.baseUrl || record.owner !== (await identity(client)))
100
+ throw new Error('标注operation账号或服务不匹配')
101
+ const current = await context(client, record.input)
102
+ if (
103
+ digest(current.binding) !== digest(record.binding) ||
104
+ digest(current.geometry) !== digest(record.geometry)
105
+ )
106
+ throw new Error('标注原图/节点类型/任务已变化,停止恢复')
107
+ return { record, current }
108
+ }
109
+ async function prepareLocal(client, profile, record, current, options, media) {
110
+ const item = record.items[0]
111
+ if (item.local) return
112
+ const tools = await media.annotationTools(record.input.commands, {
113
+ ...options,
114
+ fontFile: record.rendering?.fontFile || record.input.font_file
115
+ })
116
+ if (record.rendering?.fontHash && digest(record.rendering.fontHash) !== digest(tools.fontHash))
117
+ throw new Error('原标注字体文件已变化,停止恢复')
118
+ await fs.mkdir(record.directory, { recursive: true })
119
+ const source = path.join(record.directory, 'source.img')
120
+ if (!record.download) {
121
+ await fs.rm(source, { force: true })
122
+ record.download = await media.downloadImage(client, current.url, source)
123
+ await atomic(annotationPath(profile, record.operation), record)
124
+ } else if (digest(await hashFile(source)) !== digest(record.download))
125
+ throw new Error('本地标注原图缓存已变化')
126
+ const target = path.join(record.directory, item.fileName)
127
+ await fs.rm(target, { force: true })
128
+ item.local = await media.annotateImage(
129
+ source,
130
+ target,
131
+ record.geometry,
132
+ record.input.commands,
133
+ tools
134
+ )
135
+ record.state = 'local_ready'
136
+ await atomic(annotationPath(profile, record.operation), record)
137
+ }
138
+ export async function startAnnotation(
139
+ client,
140
+ profile,
141
+ value,
142
+ operation,
143
+ confirmed = false,
144
+ options = {},
145
+ media = mediaDefault
146
+ ) {
147
+ const input = annotationInput(value),
148
+ file = annotationPath(profile, operation),
149
+ old = await json(file, null)
150
+ if (old) {
151
+ if (digest(old.input) !== digest(input)) throw new Error('原标注operation不可改变输入')
152
+ return getAnnotation(client, profile, operation)
153
+ }
154
+ const tools = await media.annotationTools(input.commands, {
155
+ ...options,
156
+ fontFile: input.font_file
157
+ }),
158
+ current = await context(client, input),
159
+ owner = await identity(client)
160
+ const outputType = current.node.type === 'image' ? 'image' : 'image_upload'
161
+ if (!confirmed)
162
+ return {
163
+ operation,
164
+ submitted: false,
165
+ generation_submitted: false,
166
+ library_saved: false,
167
+ source_task_id: current.binding.task_id,
168
+ ...current.geometry,
169
+ output_format: 'image/png',
170
+ output_type: outputType,
171
+ commands: input.commands,
172
+ media_tools: 'available',
173
+ next: '核对标注位置和源图,暂停同画布编辑后加--yes;本地合成、真实上传并新增下游节点,不生成/入库'
174
+ }
175
+ const title = current.node.data?.title || '图片'
176
+ const record = {
177
+ operation,
178
+ input,
179
+ owner,
180
+ baseUrl: client.baseUrl,
181
+ binding: current.binding,
182
+ geometry: current.geometry,
183
+ outputType,
184
+ rendering: {
185
+ engine: 'ffmpeg-drawvg/drawtext',
186
+ ...(tools.fontFile ? { fontFile: tools.fontFile, fontHash: tools.fontHash } : {})
187
+ },
188
+ directory: path.join(path.dirname(file), safeName(operation) + '-files'),
189
+ createdAt: new Date().toISOString(),
190
+ state: 'preparing',
191
+ items: [
192
+ {
193
+ nodeId: 'annotate-' + crypto.randomUUID(),
194
+ title: title + '(标注)',
195
+ fileName:
196
+ (title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').slice(0, 80) || 'edited-image') +
197
+ '.png',
198
+ state: 'prepared'
199
+ }
200
+ ]
201
+ }
202
+ await atomic(file, record)
203
+ await prepareLocal(client, profile, record, current, tools, media)
204
+ await persistNodes(client, profile, record, (await bound(client, profile, operation)).current)
205
+ return runUploads(client, profile, record)
206
+ }
207
+ export async function getAnnotation(client, profile, operation) {
208
+ const { record, current } = await bound(client, profile, operation),
209
+ result = summary(record)
210
+ for (let i = 0; i < record.items.length; i++) {
211
+ const item = record.items[i],
212
+ node = current.doc.nodes.find((n) => n.id === item.nodeId)
213
+ if (
214
+ item.bound &&
215
+ (node?.type !== record.outputType ||
216
+ node.data?.extra?.node_kind !== (record.outputType === 'image' ? 'generation' : 'asset') ||
217
+ node.data?.extra?.file_ref !== item.response.result.file_ref ||
218
+ !current.doc.edges.some(
219
+ (e) =>
220
+ e.source === record.input.node_id &&
221
+ e.target === item.nodeId &&
222
+ e.data?.role === 'image-edit-link'
223
+ ))
224
+ ) {
225
+ result.state = 'canvas_changed'
226
+ result.items[i].state = 'canvas_changed'
227
+ result.items[i].canvas_bound = false
228
+ }
229
+ }
230
+ return result
231
+ }
232
+ export async function recoverAnnotation(
233
+ client,
234
+ profile,
235
+ operation,
236
+ confirmed = false,
237
+ options = {},
238
+ media = mediaDefault
239
+ ) {
240
+ if (!confirmed) return getAnnotation(client, profile, operation)
241
+ const { record, current } = await bound(client, profile, operation)
242
+ if (record.state === 'completed') return getAnnotation(client, profile, operation)
243
+ await prepareLocal(client, profile, record, current, options, media)
244
+ await persistNodes(client, profile, record, (await bound(client, profile, operation)).current)
245
+ return runUploads(client, profile, record)
246
+ }