@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.
- package/.agents/skills/uzoo-anime-cli/SKILL.md +83 -0
- package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +51 -0
- package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
- package/.agents/skills/uzoo-anime-cli/references/commands.md +250 -0
- package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +152 -0
- package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +175 -0
- package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
- package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +123 -0
- package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +95 -0
- package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +94 -0
- package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
- package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +300 -0
- package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +41 -0
- package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +71 -0
- package/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +242 -3
- package/assets/README.md +4 -0
- package/assets/favicon-uzoo.png +0 -0
- package/assets/logo-uzoo.svg +7 -0
- 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
- 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
- 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
- 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
- package/docs/M0-M5/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +89 -0
- 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
- 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
- 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
- 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
- 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
- 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
- package/docs//345/274/200/345/217/221/350/256/241/345/210/222.md +127 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/docs//351/230/266/346/256/2653B/351/252/214/346/224/266/344/272/244/346/216/245.md +44 -0
- package/docs//351/230/266/346/256/265/344/270/200/351/252/214/346/224/266.md +60 -0
- 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
- 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
- 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
- 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
- package/examples/matting.json +5 -0
- package/examples/real-business/image-request.template.json +7 -0
- package/examples/real-business/node-patch.template.json +11 -0
- package/extensions/uzoo-cli-login/manifest.json +9 -0
- package/extensions/uzoo-cli-login/popup.css +1 -0
- package/extensions/uzoo-cli-login/popup.html +14 -0
- package/extensions/uzoo-cli-login/popup.js +65 -0
- package/package.json +56 -6
- package/scripts/check-package-resources.mjs +56 -0
- package/scripts/check-release.mjs +19 -0
- package/scripts/pack-local.mjs +33 -0
- package/scripts/release-policy.mjs +22 -0
- package/src/agreement-page.js +62 -0
- package/src/agreement.js +419 -0
- package/src/annotation-media.js +386 -0
- package/src/api.js +168 -0
- package/src/browser-login.js +13 -0
- package/src/callback-page.js +44 -0
- package/src/canvas.js +105 -0
- package/src/cli-authorization.js +122 -0
- package/src/cli.js +459 -0
- package/src/download-name.js +17 -0
- package/src/draft-finalize.js +229 -0
- package/src/generation-graph.js +124 -0
- package/src/generation-input.js +472 -0
- package/src/graph-disconnect.js +89 -0
- package/src/graph.js +431 -0
- package/src/group-edit.js +53 -0
- package/src/group-layout.js +113 -0
- package/src/group-membership.js +126 -0
- package/src/history-add.js +119 -0
- package/src/image-annotation.js +246 -0
- package/src/image-crop.js +283 -0
- package/src/image-matting.js +82 -0
- package/src/image-media.js +89 -0
- package/src/image-output.js +385 -0
- package/src/local-output.js +23 -0
- package/src/material-download.js +55 -0
- package/src/material-save.js +254 -0
- package/src/materials.js +147 -0
- package/src/matting-background.js +73 -0
- package/src/matting-jobs.js +63 -0
- package/src/matting-worker.js +49 -0
- package/src/matting.js +170 -0
- package/src/media-copy.js +53 -0
- package/src/media.js +343 -0
- package/src/model-options.js +76 -0
- package/src/multipart.js +28 -0
- package/src/node-copy.js +95 -0
- package/src/node-delete.js +49 -0
- package/src/node-history.js +231 -0
- package/src/normal-login.js +118 -0
- package/src/package-info.js +8 -0
- package/src/png-alpha.js +60 -0
- package/src/protocol.js +83 -0
- package/src/remake.js +738 -0
- package/src/repaint-archive.js +53 -0
- package/src/repaint-delivery.js +783 -0
- package/src/repaint-export.js +63 -0
- package/src/repaint-graph.js +214 -0
- package/src/repaint-history.js +267 -0
- package/src/repaint-materials.js +601 -0
- package/src/repaint-reverse.js +614 -0
- package/src/repaint-sequence.js +132 -0
- package/src/repaint-settings.js +576 -0
- package/src/repaint-video.js +798 -0
- package/src/repaint.js +326 -0
- package/src/resource.js +110 -0
- package/src/review.js +435 -0
- package/src/service.js +85 -0
- package/src/store.js +100 -0
- package/src/video-clip.js +381 -0
- package/src/video-frame.js +296 -0
- package/src/workflows.js +214 -0
package/src/workflows.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
5
|
+
import { atomic, json, root, safeName } from './store.js'
|
|
6
|
+
import { request, requestFile, cancellation } from './api.js'
|
|
7
|
+
import { agreementError, readProtocol } from './agreement.js'
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
9
|
+
import { downloadName } from './download-name.js'
|
|
10
|
+
|
|
11
|
+
export const segment = value => encodeURIComponent(String(value))
|
|
12
|
+
// Mirrors parameters used by the ordinary web nodes and their request builders.
|
|
13
|
+
// Model catalog capabilities and provider/admin settings are not request controls.
|
|
14
|
+
const generationFields = new Set([
|
|
15
|
+
'doc_id', 'node_id', 'segment_type', 'model_name', 'prompt',
|
|
16
|
+
'reference_image_urls', 'reference_video_urls', 'reference_audio_urls',
|
|
17
|
+
'reference_asset_ids', 'reference_canvas_task_ids', 'duration',
|
|
18
|
+
'aspect_ratio', 'resolution', 'quality', 'asset_type', 'enable_asset_review',
|
|
19
|
+
'production_task_id', 'generation_mode', 'protocol_agreed', 'extra'
|
|
20
|
+
])
|
|
21
|
+
const frontendExtraFields = {
|
|
22
|
+
text: ['context_texts', 'max_tokens', 'temperature'],
|
|
23
|
+
image: ['aspect_ratio', 'resolution', 'quality', 'generation_mode', 'image_count'],
|
|
24
|
+
video: ['aspect_ratio', 'resolution', 'generation_mode', 'draft_preview']
|
|
25
|
+
}
|
|
26
|
+
export function generationExtra(kind, extra = {}, { fromNode = false } = {}) {
|
|
27
|
+
if (!extra || Array.isArray(extra) || typeof extra !== 'object') throw new Error('extra 必须是 JSON 对象')
|
|
28
|
+
if (Object.hasOwn(extra, 'generate_audio') || Object.hasOwn(extra, 'generateAudio'))
|
|
29
|
+
throw new Error('前端视频节点没有声音开关,CLI 不支持 generate_audio;未预估或提交生成')
|
|
30
|
+
if (kind === 'image' && Object.hasOwn(extra, 'image_count') && extra.image_count !== 1)
|
|
31
|
+
throw new Error('当前前端普通图片流程仅接入单张 image_count=1;未预估或提交多张生成')
|
|
32
|
+
const allowed = frontendExtraFields[kind] || []
|
|
33
|
+
const unknown = Object.keys(extra).filter(key => !allowed.includes(key))
|
|
34
|
+
if (!fromNode && unknown.length) throw new Error(`当前 CLI 未接入这些前端生成参数:extra.${unknown.join(', extra.')};请先核对实际前端流程`)
|
|
35
|
+
return Object.fromEntries(Object.entries(extra).filter(([key]) => allowed.includes(key)))
|
|
36
|
+
}
|
|
37
|
+
function validateGenerationShape(body) {
|
|
38
|
+
if (!body || Array.isArray(body) || typeof body !== 'object') throw new Error('请求必须是 JSON 对象')
|
|
39
|
+
for (const k of ['doc_id', 'node_id', 'model_name']) if (typeof body[k] !== 'string' || !body[k].trim()) throw new Error(`请求缺少 ${k}`)
|
|
40
|
+
if (!['text', 'image', 'video'].includes(body.segment_type)) throw new Error('segment_type 必须是 text/image/video')
|
|
41
|
+
if (typeof body.prompt !== 'string') throw new Error('prompt 必须是字符串')
|
|
42
|
+
}
|
|
43
|
+
export function validateGeneration(body) {
|
|
44
|
+
validateGenerationShape(body)
|
|
45
|
+
if (Object.hasOwn(body, 'generate_audio') || Object.hasOwn(body, 'generateAudio'))
|
|
46
|
+
throw new Error('前端视频节点没有声音开关,CLI 不支持 generate_audio;未预估或提交生成')
|
|
47
|
+
const unknown = Object.keys(body).filter(key => !generationFields.has(key))
|
|
48
|
+
if (unknown.length) throw new Error(`当前 CLI 未接入这些前端生成参数:${unknown.join(', ')};请先核对实际前端流程`)
|
|
49
|
+
generationExtra(body.segment_type, body.extra)
|
|
50
|
+
}
|
|
51
|
+
export function journalPath(profile, operation) { return path.join(root(), 'operations', safeName(profile), `${safeName(operation)}.json`) }
|
|
52
|
+
export async function generationProtocol(client) {
|
|
53
|
+
const protocol=await readProtocol(client)
|
|
54
|
+
return {protocol,status:protocol.required&&!protocol.agreed?'awaiting_agreement':protocol.agreed?'agreed':'not_required',api_code:protocol.required&&!protocol.agreed?4401:0}
|
|
55
|
+
}
|
|
56
|
+
async function agreementContext(client,body) {
|
|
57
|
+
const doc=await client.raw(`/canvas/docs/${segment(body.doc_id)}`)
|
|
58
|
+
if(doc.id!==body.doc_id || !['owner','editor'].includes(doc.role) || !Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) throw new Error('协议确认前后无法核验目标画布,未继续生成')
|
|
59
|
+
const target=doc.nodes.find(n=>n.id===body.node_id)
|
|
60
|
+
if(!target)throw new Error('协议确认前后目标节点不存在,未继续生成')
|
|
61
|
+
const sources=[...new Set(doc.edges.filter(e=>e.target===body.node_id).map(e=>e.source))].sort().map(id=>({id,data:doc.nodes.find(n=>n.id===id)?.data}))
|
|
62
|
+
return {team_uid:doc.team_uid || null,project_id:doc.canvas_project_id || null,target:{id:target.id,type:target.type,data:target.data},sources}
|
|
63
|
+
}
|
|
64
|
+
export async function submit(client, profile, body, operation, confirmed, expectedCost, {onAgreementRequired}={}) {
|
|
65
|
+
validateGenerationShape(body)
|
|
66
|
+
const file = journalPath(profile, operation)
|
|
67
|
+
let record = await json(file, null)
|
|
68
|
+
const identity = await client.raw('/auth/me')
|
|
69
|
+
const owner = String(identity.uid ?? identity.id ?? '')
|
|
70
|
+
if (!owner) throw new Error('账号响应缺少 uid/id,无法绑定任务')
|
|
71
|
+
const hash = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex')
|
|
72
|
+
if (record && (record.hash !== hash || record.baseUrl !== client.baseUrl || record.owner !== owner)) throw new Error('operation 已绑定其他请求、服务或账号,请勿复用')
|
|
73
|
+
if (record?.taskId) return client.raw(`/canvas/generations/${segment(record.taskId)}`)
|
|
74
|
+
// Existing remote tasks remain queryable with their original saved request.
|
|
75
|
+
// Never rewrite an old operation to pass a newer parameter contract.
|
|
76
|
+
validateGeneration(body)
|
|
77
|
+
let estimate = await client.raw('/canvas/generations/estimate', { method: 'POST', body })
|
|
78
|
+
if (estimate.will_intercept) throw new Error('预估接口拦截了请求,请调整输入')
|
|
79
|
+
if (!Number.isFinite(estimate.estimated_credit_cost) || estimate.estimated_credit_cost < 0)
|
|
80
|
+
throw new Error('预估费用无效,不能提交;请核验费用接口')
|
|
81
|
+
const approvedCost=expectedCost ?? record?.approvedCost
|
|
82
|
+
if (confirmed && approvedCost === undefined)
|
|
83
|
+
throw new Error('首次生成提交或原记录缺少确认金额时必须传 --expected-cost,先通过交互卡片确认实际钱包、请求参数和预估费用')
|
|
84
|
+
if (confirmed && (!Number.isFinite(approvedCost) || approvedCost < 0 || estimate.estimated_credit_cost !== approvedCost)) throw new Error('生成费用与当次确认不一致;未提交,请重新展示费用并确认')
|
|
85
|
+
let protocol
|
|
86
|
+
if(body.segment_type==='video' && /seedance/i.test(body.model_name)) {
|
|
87
|
+
protocol=(await generationProtocol(client)).protocol
|
|
88
|
+
if(confirmed && protocol.required && !protocol.agreed) {
|
|
89
|
+
if(!record)record={operation,baseUrl:client.baseUrl,owner,body,hash,key:crypto.randomUUID(),createdAt:new Date().toISOString()}
|
|
90
|
+
record.state='blocked_agreement';record.approvedCost=approvedCost;record.protocolVersion=protocol.version;record.lastFailure={status:200,apiCode:4401,code:'AGREEMENT_REQUIRED'};await atomic(file,record)
|
|
91
|
+
if(!onAgreementRequired)throw agreementError('AGREEMENT_REQUIRED','当前账号需签署《统一协议》中的《Seedance2.0模型使用承诺书》(code 4401);生成尚未提交,不是模型执行失败',protocol)
|
|
92
|
+
const context=await agreementContext(client,body)
|
|
93
|
+
try{await onAgreementRequired({operation,body,estimate,protocol,owner})}catch(error){record.agreementWaitCode=error.code || 'AGREEMENT_PAGE_UNAVAILABLE';await atomic(file,record);throw error}
|
|
94
|
+
const currentIdentity=await client.raw('/auth/me')
|
|
95
|
+
const currentOwner=String(currentIdentity.uid ?? currentIdentity.id ?? '')
|
|
96
|
+
if(currentOwner!==owner)throw agreementError('AGREEMENT_ACCOUNT_CHANGED','协议确认后账号已变化,未继续原生成',protocol)
|
|
97
|
+
const state=await readProtocol(client)
|
|
98
|
+
if(state.version!==protocol.version || state.required&&!state.agreed)throw agreementError('AGREEMENT_CHANGED','协议状态或版本已变化,未继续原生成,请先完成当前版本签署',state)
|
|
99
|
+
record.state='needs_confirmation';record.agreement={status:'agreed',version:state.version};delete record.lastFailure;await atomic(file,record)
|
|
100
|
+
if(!isDeepStrictEqual(await agreementContext(client,body),context))throw new Error('协议确认期间画布归属、节点参数或上游已变化,未继续生成;请重新核对请求和费用')
|
|
101
|
+
estimate=await client.raw('/canvas/generations/estimate',{method:'POST',body})
|
|
102
|
+
if(estimate.will_intercept || estimate.estimated_credit_cost!==approvedCost)throw new Error('协议确认后费用或校验结果已变化,未提交;请重新展示预估并取得当次确认')
|
|
103
|
+
protocol=state
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!confirmed) return { submitted: false, operation, estimate, ...(protocol?{protocol}:{}), next: '确认参数和费用后使用同一 operation 加 --yes 提交' }
|
|
107
|
+
if (!record) {
|
|
108
|
+
record = { operation, baseUrl: client.baseUrl, owner, body, hash, key: crypto.randomUUID(), createdAt: new Date().toISOString(), approvedCost, state: 'prepared' }
|
|
109
|
+
await atomic(file, record)
|
|
110
|
+
}
|
|
111
|
+
if (!record.syncBaseline && ['prepared','needs_confirmation','blocked_agreement'].includes(record.state)) {
|
|
112
|
+
const doc=await client.raw(`/canvas/docs/${segment(body.doc_id)}`)
|
|
113
|
+
const node=doc.nodes?.find(n=>n.id===body.node_id)
|
|
114
|
+
if(doc.id!==body.doc_id || !node || node.type!==body.segment_type)throw new Error('提交前无法核验原节点,未提交生成')
|
|
115
|
+
record.syncBaseline={doc_id:doc.id,team_uid:doc.team_uid||null,node}
|
|
116
|
+
await atomic(file,record)
|
|
117
|
+
}
|
|
118
|
+
record.approvedCost = approvedCost
|
|
119
|
+
record.state = 'submitting'; await atomic(file, record)
|
|
120
|
+
try {
|
|
121
|
+
const task = await client.raw('/canvas/generations', { method: 'POST', body: record.body, headers: { 'Idempotency-Key': record.key } }, { retry: false })
|
|
122
|
+
if (!task.task_id && !task.id) throw new Error('提交响应缺少任务 ID')
|
|
123
|
+
record.taskId = task.task_id ?? task.id; record.state = 'submitted'
|
|
124
|
+
await atomic(file, record)
|
|
125
|
+
return { operation, estimate, task }
|
|
126
|
+
} catch (error) {
|
|
127
|
+
record.state = error.apiCode===4401 ? 'blocked_agreement' : 'unknown'
|
|
128
|
+
record.lastFailure = { ...(Number.isInteger(error.status) ? { status:error.status } : {}), ...(Number.isInteger(error.apiCode) ? { apiCode:error.apiCode } : {}), code:error.apiCode===4401?'AGREEMENT_REQUIRED':typeof error.code==='string'?error.code:'REQUEST_FAILED' }
|
|
129
|
+
await atomic(file, record)
|
|
130
|
+
if(error.apiCode===4401)throw agreementError('AGREEMENT_REQUIRED','《统一协议》《Seedance2.0模型使用承诺书》需要签署或版本已更新(code 4401);本次生成提交被前置检查阻止,不是生成任务执行失败;保留原 operation',error.protocol || protocol)
|
|
131
|
+
const detail=record.lastFailure.apiCode ? `(接口返回 code ${record.lastFailure.apiCode},HTTP ${record.lastFailure.status})` : record.lastFailure.status ? `(HTTP ${record.lastFailure.status})` : ''
|
|
132
|
+
const failure = new Error(`提交结果未确认${detail};使用 generation recover ${operation} 以原幂等键恢复,勿创建新 operation`,{cause:error})
|
|
133
|
+
failure.code = 'SUBMISSION_UNKNOWN'; throw failure
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function recover(client, profile, operation, confirmed, expectedCost, options) {
|
|
137
|
+
const record = await json(journalPath(profile, operation), null)
|
|
138
|
+
if (!record) throw new Error('未找到 operation')
|
|
139
|
+
return submit(client, profile, record.body, operation, confirmed, expectedCost, options)
|
|
140
|
+
}
|
|
141
|
+
export async function wait(client, id, interval, timeout) {
|
|
142
|
+
const deadline = Date.now() + timeout
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
let data
|
|
145
|
+
try { data = await client.raw(`/canvas/generations/${segment(id)}`, { signal: AbortSignal.timeout(Math.max(1, Math.min(client.timeout, deadline - Date.now()))) }) }
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.name === 'TimeoutError' && Date.now() >= deadline) break
|
|
148
|
+
throw error
|
|
149
|
+
}
|
|
150
|
+
const status = String(data.status || data.state || '').toLowerCase()
|
|
151
|
+
if (status === 'completed') return data
|
|
152
|
+
if (['failed', 'cancelled', 'canceled'].includes(status)) { const e = new Error(`任务 ${id} 已${status};使用 generation get/logs 查看详情`); e.code = 'TASK_FAILED'; throw e }
|
|
153
|
+
await sleep(Math.min(interval, Math.max(1, deadline - Date.now())), undefined, { signal: cancellation.signal })
|
|
154
|
+
}
|
|
155
|
+
const e = new Error(`任务 ${id} 等待超时;可用同一 ID 继续查询`); e.code = 'WAIT_TIMEOUT'; throw e
|
|
156
|
+
}
|
|
157
|
+
export async function upload(client, args) {
|
|
158
|
+
const { file, doc, node, type, assetType, name, mime } = args
|
|
159
|
+
if (!['image', 'video', 'audio'].includes(type)) throw new Error('--type 必须是 image/video/audio')
|
|
160
|
+
if (!node && (type !== 'image' || !['character', 'scene', 'prop'].includes(assetType) || !name)) throw new Error('资产上传需要 --type image --asset-type character|scene|prop --name;音视频请提供 --node')
|
|
161
|
+
const handle = await fs.open(file, 'r')
|
|
162
|
+
try {
|
|
163
|
+
const stat = await handle.stat()
|
|
164
|
+
if (!stat.isFile() || !stat.size) throw new Error('上传文件必须为非空普通文件')
|
|
165
|
+
const endpoint = `/canvas/docs/${segment(doc)}/${node ? `nodes/${segment(node)}` : 'assets'}`
|
|
166
|
+
const body = { filename: path.basename(file), file_size: stat.size, expected_type: type, mime_type: mime, ...(!node ? { asset_type: assetType, name } : {}) }
|
|
167
|
+
const session = await client.raw(`${endpoint}/upload-session`, { method: 'POST', body })
|
|
168
|
+
const parts = await uploadParts(client, handle, stat, session, mime)
|
|
169
|
+
return client.raw(`${endpoint}/upload-complete`, { method: 'POST', body: { upload_id: session.upload_id, object_key: session.object_key, expected_parts: parts.length, parts } }, { retry: false })
|
|
170
|
+
} finally { await handle.close() }
|
|
171
|
+
}
|
|
172
|
+
export async function uploadParts(client, handle, stat, session, mime) {
|
|
173
|
+
const count = Math.ceil(stat.size / session.part_size)
|
|
174
|
+
if (!Number.isSafeInteger(session.part_size) || session.part_size <= 0 || session.part_size > 100 * 1024 * 1024 || count !== session.total_parts || session.presigned_parts?.length !== count) throw new Error('上传会话分片参数无效')
|
|
175
|
+
const parts = []
|
|
176
|
+
for (let i = 0; i < count; i++) {
|
|
177
|
+
const part = session.presigned_parts[i]
|
|
178
|
+
if (part.part_number !== i + 1) throw new Error('上传会话分片不连续')
|
|
179
|
+
const length = Math.min(session.part_size, stat.size - i * session.part_size)
|
|
180
|
+
const buffer = Buffer.alloc(length)
|
|
181
|
+
let offset = 0
|
|
182
|
+
while (offset < length) { const { bytesRead } = await handle.read(buffer, offset, length - offset, i * session.part_size + offset); if (!bytesRead) throw new Error('文件在上传时被截断'); offset += bytesRead }
|
|
183
|
+
const response = await request(part.url, { method: 'PUT', headers: { 'Content-Type': mime }, body: buffer }, client.timeout)
|
|
184
|
+
const etag = response.headers.get('etag')
|
|
185
|
+
await response.body?.cancel()
|
|
186
|
+
if (!response.ok || !etag) throw new Error(`分片 ${i + 1} 上传失败或缺少 ETag;未执行 complete`)
|
|
187
|
+
parts.push({ part_number: i + 1, etag: etag.replaceAll('"', '') })
|
|
188
|
+
}
|
|
189
|
+
const now = await handle.stat()
|
|
190
|
+
if (now.size !== stat.size || now.mtimeMs !== stat.mtimeMs) throw new Error('上传时文件发生变化,未执行 complete')
|
|
191
|
+
return parts
|
|
192
|
+
}
|
|
193
|
+
export async function download(client, id, output, expectedHash) {
|
|
194
|
+
const data = await client.raw(`/canvas/generations/${segment(id)}/download`)
|
|
195
|
+
if (typeof data?.url !== 'string' || !data.url.trim()) throw new Error('原任务下载接口未返回有效链接')
|
|
196
|
+
const url = new URL(data.url, client.baseUrl)
|
|
197
|
+
const response = await requestFile(url, client.timeout)
|
|
198
|
+
if (!response.ok || /(?:json|text\/html)/i.test(response.headers.get('content-type') || '')) { await response.body?.cancel(); throw new Error('下载返回错误状态或非媒体错误页面') }
|
|
199
|
+
const filename = downloadName(response.headers, data.filename) || `generation-${segment(id)}`
|
|
200
|
+
const target = path.resolve(output || filename)
|
|
201
|
+
const tmp = `${target}.${crypto.randomUUID()}.part`
|
|
202
|
+
let handle
|
|
203
|
+
try {
|
|
204
|
+
handle = await fs.open(tmp, 'wx', 0o600)
|
|
205
|
+
const hash = crypto.createHash('sha256'); let bytes = 0
|
|
206
|
+
for await (const chunk of response.body) { await handle.writeFile(chunk); hash.update(chunk); bytes += chunk.length }
|
|
207
|
+
const digest = hash.digest('hex')
|
|
208
|
+
const length = response.headers.get('content-length')
|
|
209
|
+
if (!bytes || (length && !response.headers.get('content-encoding') && bytes !== Number(length)) || (expectedHash && digest !== expectedHash.toLowerCase())) throw new Error('下载文件完整性校验失败')
|
|
210
|
+
await handle.close(); handle = null
|
|
211
|
+
await fs.link(tmp, target)
|
|
212
|
+
return { output: target, bytes, sha256: digest, filename }
|
|
213
|
+
} finally { await handle?.close(); await fs.rm(tmp, { force: true }) }
|
|
214
|
+
}
|