@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.
- package/.agents/skills/uzoo-anime-cli/SKILL.md +89 -0
- package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +41 -0
- package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
- package/.agents/skills/uzoo-anime-cli/references/commands.md +242 -0
- package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +142 -0
- package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +153 -0
- package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
- package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +113 -0
- package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +64 -0
- package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +90 -0
- package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
- package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +13 -0
- package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +37 -0
- package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +67 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +46 -3
- package/assets/favicon-uzoo.png +0 -0
- package/assets/logo-uzoo.svg +7 -0
- 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
- package/examples/matting.json +5 -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 +53 -6
- 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 +464 -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 +125 -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/install.js +237 -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/repaint.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
6
|
+
import { atomic, json, root, safeName } from './store.js'
|
|
7
|
+
import { request, requestFile, cancellation } from './api.js'
|
|
8
|
+
import { segment } from './workflows.js'
|
|
9
|
+
import { spaceContext } from './canvas.js'
|
|
10
|
+
import { object, repaintOf, mediaUrl, readCanvas, assertJob, jobPath, assertAvailable, claimRepaint, syncRepaintGraph } from './repaint-graph.js'
|
|
11
|
+
|
|
12
|
+
const hash = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
13
|
+
const persist = record => atomic(recordPath(record.profile, record.operation), record)
|
|
14
|
+
export const recordPath = (profile, operation) => path.join(root(), 'repaint', safeName(profile), `${safeName(operation)}.json`)
|
|
15
|
+
const api = (client, endpoint, options = {}, controls) => client.raw(endpoint, { ...options, headers: { ...options.headers, 'X-Client-Source': 'canvas' } }, controls)
|
|
16
|
+
const failUnknown = message => { const e = new Error(message); e.code = 'SUBMISSION_UNKNOWN'; return e }
|
|
17
|
+
function inputBody(value) {
|
|
18
|
+
if (!object(value)) throw new Error('转绘输入必须是 JSON 对象')
|
|
19
|
+
const allowed = ['doc_id', 'source_node_id', 'entry_node_id', 'space', 'team_uid', 'segmentation_mode', 'uniform_interval_seconds']
|
|
20
|
+
if (Object.keys(value).some(k => !allowed.includes(k))) throw new Error('转绘输入含未知字段;本阶段不支持字幕、附件或自动视频生成')
|
|
21
|
+
for (const field of ['doc_id', 'source_node_id', 'entry_node_id']) if (typeof value[field] !== 'string' || !value[field].trim()) throw new Error(`转绘输入缺少 ${field}`)
|
|
22
|
+
const context = spaceContext({ space: value.space, teamUid: value.team_uid })
|
|
23
|
+
const mode = value.segmentation_mode || 'intelligent'
|
|
24
|
+
if (!['intelligent', 'uniform'].includes(mode)) throw new Error('segmentation_mode 必须是 intelligent 或 uniform')
|
|
25
|
+
if (mode === 'uniform' && (!Number.isFinite(value.uniform_interval_seconds) || value.uniform_interval_seconds < 4 || value.uniform_interval_seconds > 15)) throw new Error('uniform 模式必须明确提供 4–15 秒 uniform_interval_seconds')
|
|
26
|
+
if (mode === 'intelligent' && value.uniform_interval_seconds !== undefined) throw new Error('智能模式不能传均匀切分间隔')
|
|
27
|
+
return { doc_id: value.doc_id, source_node_id: value.source_node_id, entry_node_id: value.entry_node_id,
|
|
28
|
+
space: value.space, ...context, segmentation_mode: mode,
|
|
29
|
+
...(mode === 'uniform' ? { uniform_interval_seconds: value.uniform_interval_seconds } : {}) }
|
|
30
|
+
}
|
|
31
|
+
async function identity(client) {
|
|
32
|
+
const me = await client.raw('/auth/me')
|
|
33
|
+
const owner = String(me?.uid ?? me?.id ?? '')
|
|
34
|
+
if (!owner) throw new Error('账号响应缺少 uid/id,无法绑定流程')
|
|
35
|
+
return owner
|
|
36
|
+
}
|
|
37
|
+
async function loadRecord(client, profile, operation) {
|
|
38
|
+
const record = await json(recordPath(profile, operation), null)
|
|
39
|
+
if (!record) throw new Error('未找到转绘 operation;先运行 repaint start 预估')
|
|
40
|
+
if (record.schema !== 1 || record.operation !== operation || record.profile !== profile || !object(record.input) || record.baseUrl !== client.baseUrl || record.owner !== await identity(client)) throw new Error('operation 与当前服务、账号或记录版本不一致')
|
|
41
|
+
return record
|
|
42
|
+
}
|
|
43
|
+
function sourceKey(doc, input) {
|
|
44
|
+
const source = doc.nodes.find(n => n.id === input.source_node_id)
|
|
45
|
+
return hash({ id: source.id, type: source.type, url: mediaUrl(source), file_ref: source.data?.extra?.file_ref || '', file_size: source.data?.file_size || 0, last_task_id: source.data?.last_task_id || null })
|
|
46
|
+
}
|
|
47
|
+
async function checkSource(client, record, allowMissingDetail = false) {
|
|
48
|
+
const doc = await readCanvas(client, record.input)
|
|
49
|
+
if (sourceKey(doc, record.input) !== record.sourceKey) throw new Error('源视频已变化,停止原流程;不能将旧任务接到新素材')
|
|
50
|
+
if (record.lastStatus && !allowMissingDetail && !doc.nodes.some(node => node.id === record.detailId)) throw new Error('流程详情已被删除,停止;经用户明确授权后才可用 repaint recover NAME --restore-detail 预览恢复')
|
|
51
|
+
return doc
|
|
52
|
+
}
|
|
53
|
+
async function initialEstimate(client, input, doc) {
|
|
54
|
+
const pricing = await api(client, '/video-segmentation/pricing')
|
|
55
|
+
const credits = input.segmentation_mode === 'intelligent' ? pricing?.base_processing_credits : 0
|
|
56
|
+
if (pricing?.currency !== 'OO' || !Number.isSafeInteger(credits) || credits < 0 ||
|
|
57
|
+
(input.segmentation_mode === 'uniform' && pricing.manual_segmentation?.base_processing_free !== true)) throw new Error('分割价格响应不完整,不能提交')
|
|
58
|
+
return { currency: 'OO', credits, phase: 'analysis', wallet: input.space === 'team' ? 'team' : 'personal',
|
|
59
|
+
team_uid: input.team_uid || null, team_name: doc.team_name || null, segmentation_mode: input.segmentation_mode,
|
|
60
|
+
uniform_interval_seconds: input.uniform_interval_seconds ?? null, reserve_on: pricing.base_processing?.reserve_on || 'analysis_submit',
|
|
61
|
+
excludes: ['reverse_prompt', 'delivery_prompt', 'video_generation'] }
|
|
62
|
+
}
|
|
63
|
+
export function repaintDurationPreview(duration, input, limits) {
|
|
64
|
+
const seconds = typeof duration === 'number' && Number.isFinite(duration) && duration > 0 ? duration : null
|
|
65
|
+
if (seconds !== null &&
|
|
66
|
+
((Number.isFinite(limits.min_duration_seconds) && seconds < limits.min_duration_seconds) ||
|
|
67
|
+
(Number.isFinite(limits.max_duration_seconds) && seconds > limits.max_duration_seconds)))
|
|
68
|
+
throw new Error('源视频实际时长不符合当前上传限制;未提交分析')
|
|
69
|
+
return {
|
|
70
|
+
source_duration_seconds: seconds,
|
|
71
|
+
duration_verified: false,
|
|
72
|
+
duration_evidence: seconds === null ? 'unavailable' : 'stored_media_metadata',
|
|
73
|
+
...(input.segmentation_mode === 'uniform' ? {
|
|
74
|
+
minimum_segment_seconds: 4,
|
|
75
|
+
maximum_possible_segments: seconds === null ? null : Math.floor(seconds / 4),
|
|
76
|
+
segment_count_verified: false,
|
|
77
|
+
note: seconds !== null && seconds < 8
|
|
78
|
+
? '按每段至少4秒,当前源片不能分为两段;末段余量会合并。实际段数以服务端方案为准'
|
|
79
|
+
: '间隔不保证段数;每段至少4秒,末段可能合并或重分。缺少可信媒体时长时先核验,不承诺多段'
|
|
80
|
+
} : {})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function limitsFor(client) {
|
|
84
|
+
const limits = await api(client, '/video-segmentation/upload-limits')
|
|
85
|
+
if (!Number.isSafeInteger(limits?.max_size_bytes) || limits.max_size_bytes < 1 || !Array.isArray(limits.allowed_mime_types) || !limits.allowed_mime_types.length) throw new Error('上传限制响应不完整')
|
|
86
|
+
return limits
|
|
87
|
+
}
|
|
88
|
+
async function downloadSource(client, doc, input, limits, dir) {
|
|
89
|
+
const source = doc.nodes.find(n => n.id === input.source_node_id)
|
|
90
|
+
const response = await requestFile(new URL(mediaUrl(source), client.baseUrl), client.timeout)
|
|
91
|
+
const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
92
|
+
if (!response.ok || !limits.allowed_mime_types.includes(mime)) { await response.body?.cancel(); throw new Error('源视频读取失败或 MIME 不符合上传限制;未提交分析,请先更新可访问的视频素材地址') }
|
|
93
|
+
const length = Number(response.headers.get('content-length'))
|
|
94
|
+
if (length > limits.max_size_bytes) { await response.body?.cancel(); throw new Error('源视频超过上传限制') }
|
|
95
|
+
const file = path.join(dir, 'source')
|
|
96
|
+
const handle = await fs.open(file, 'wx', 0o600)
|
|
97
|
+
let size = 0
|
|
98
|
+
const digest = crypto.createHash('sha256')
|
|
99
|
+
try {
|
|
100
|
+
for await (const chunk of response.body) {
|
|
101
|
+
size += chunk.length
|
|
102
|
+
if (size > limits.max_size_bytes) throw new Error('源视频超过上传限制,未提交分析')
|
|
103
|
+
digest.update(chunk); await handle.writeFile(chunk)
|
|
104
|
+
}
|
|
105
|
+
} finally { await handle.close() }
|
|
106
|
+
if (!size || (length && !response.headers.get('content-encoding') && size !== length)) throw new Error('源视频下载不完整,未提交分析')
|
|
107
|
+
const filename = path.basename(String(source.data.file_name || source.data.fileName || `source.${mime === 'video/quicktime' ? 'mov' : 'mp4'}`)).replaceAll('\\', '_')
|
|
108
|
+
return { file, size, mime, filename, sha256: digest.digest('hex') }
|
|
109
|
+
}
|
|
110
|
+
function validateSession(session, source) {
|
|
111
|
+
const count = Math.ceil(source.size / session?.part_size)
|
|
112
|
+
if (!session?.upload_id || !session.job_id || !session.object_key || !Number.isSafeInteger(session.part_size) || session.part_size <= 0 || session.part_size > 100 * 1024 * 1024 ||
|
|
113
|
+
count !== session.total_parts || session.presigned_parts?.length !== count || session.presigned_parts.some((p, i) => p.part_number !== i + 1 || typeof p.url !== 'string')) throw new Error('分片会话无效,未执行分析提交')
|
|
114
|
+
}
|
|
115
|
+
async function uploadParts(client, record, source) {
|
|
116
|
+
const session = record.session
|
|
117
|
+
validateSession(session, source)
|
|
118
|
+
if (record.sourceHash && record.sourceHash !== source.sha256) throw new Error('源视频内容与原上传不同,停止')
|
|
119
|
+
record.sourceHash = source.sha256
|
|
120
|
+
record.parts ||= []
|
|
121
|
+
record.state = 'uploading'; await persist(record)
|
|
122
|
+
const handle = await fs.open(source.file, 'r')
|
|
123
|
+
try {
|
|
124
|
+
for (const part of session.presigned_parts) {
|
|
125
|
+
if (record.parts.some(p => p.part_number === part.part_number)) continue
|
|
126
|
+
const offset = (part.part_number - 1) * session.part_size, length = Math.min(session.part_size, source.size - offset)
|
|
127
|
+
const buffer = Buffer.alloc(length)
|
|
128
|
+
let read = 0
|
|
129
|
+
while (read < length) { const { bytesRead } = await handle.read(buffer, read, length - read, offset + read); if (!bytesRead) throw new Error('源视频缓存被截断'); read += bytesRead }
|
|
130
|
+
const response = await request(part.url, { method: 'PUT', headers: { 'Content-Type': source.mime }, body: buffer }, client.timeout)
|
|
131
|
+
const etag = response.headers.get('etag'); await response.body?.cancel()
|
|
132
|
+
if (!response.ok || !etag) throw new Error('分片上传失败或缺少 ETag,未执行分析提交;保留原 operation')
|
|
133
|
+
record.parts.push({ part_number: part.part_number, etag: etag.replaceAll('"', '') }); await persist(record)
|
|
134
|
+
}
|
|
135
|
+
} finally { await handle.close() }
|
|
136
|
+
record.state = 'uploaded'; await persist(record)
|
|
137
|
+
}
|
|
138
|
+
async function getJob(client, record) {
|
|
139
|
+
if (!record.jobId) throw new Error('尚无可查询任务 ID;未发起分析,上传会话创建结果不明时不会自动新建')
|
|
140
|
+
let job
|
|
141
|
+
try { job = await api(client, jobPath(record.jobId)) }
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (['submitting', 'unknown'].includes(record.state)) throw failUnknown('原任务尚无法确认;保留 operation 稍后 repaint recover,勿再次 complete 或更换 operation 重提')
|
|
144
|
+
throw error
|
|
145
|
+
}
|
|
146
|
+
assertJob(job, record)
|
|
147
|
+
return job
|
|
148
|
+
}
|
|
149
|
+
async function sync(client, record, job, options = {}) {
|
|
150
|
+
await checkSource(client, record, options.restoreDetail === true)
|
|
151
|
+
const canvas = await syncRepaintGraph(client, record, job, options)
|
|
152
|
+
record.state = canvas.materialized ? 'materialized' : ['failed', 'cancelled'].includes(job.status) ? 'failed' : job.status === 'awaiting_review' ? 'awaiting_review' : 'running'
|
|
153
|
+
record.lastStatus = job.status
|
|
154
|
+
record.lastPlanVersion = job.plan_version
|
|
155
|
+
await persist(record)
|
|
156
|
+
return { operation: record.operation, job, canvas }
|
|
157
|
+
}
|
|
158
|
+
export async function startRepaint(client, profile, rawInput, operation, confirmed = false) {
|
|
159
|
+
safeName(operation)
|
|
160
|
+
const input = inputBody(rawInput), owner = await identity(client)
|
|
161
|
+
let record = await json(recordPath(profile, operation), null)
|
|
162
|
+
const inputHash = hash(input)
|
|
163
|
+
if (record && (record.schema !== 1 || record.inputHash !== inputHash || record.baseUrl !== client.baseUrl || record.owner !== owner)) throw new Error('operation 已绑定其他参数、服务或账号,不可复用')
|
|
164
|
+
if (record?.jobId && !['uploading', 'uploaded'].includes(record.state)) {
|
|
165
|
+
if (record.state === 'session_creating') throw new Error('上传会话结果未确认,不会自动重建')
|
|
166
|
+
return recoverRepaint(client, profile, operation)
|
|
167
|
+
}
|
|
168
|
+
if (record?.state === 'session_creating') throw new Error('上传会话结果未确认;本次未提交分析,不会自动新建上传会话')
|
|
169
|
+
const doc = await readCanvas(client, input), key = sourceKey(doc, input)
|
|
170
|
+
assertAvailable(doc, { input, operation, jobId: record?.jobId })
|
|
171
|
+
const existing = doc.nodes.find(n => repaintOf(n).role === 'split-detail' && repaintOf(n).sourceNodeId === input.source_node_id && repaintOf(n).parentNodeId === input.entry_node_id && n.id !== record?.detailId)
|
|
172
|
+
if (existing) throw new Error('该入口与源视频已有分段详情;先使用既有流程,不新建分析')
|
|
173
|
+
if (record && record.sourceKey !== key) throw new Error('operation 的源视频已变化,不可复用')
|
|
174
|
+
const estimate = await initialEstimate(client, input, doc), limits = await limitsFor(client)
|
|
175
|
+
const sourceNode = doc.nodes.find(n => n.id === input.source_node_id)
|
|
176
|
+
// A generation node's requested duration is not the media's actual duration.
|
|
177
|
+
const duration = sourceNode.data?.result?.duration ??
|
|
178
|
+
(sourceNode.type === 'video_upload' ? sourceNode.data?.duration : null)
|
|
179
|
+
const sourcePreview = repaintDurationPreview(duration, input, limits)
|
|
180
|
+
if (!record) {
|
|
181
|
+
if (confirmed) throw new Error('先运行不带 --yes 的 repaint start 展示参数、钱包和费用,再确认提交')
|
|
182
|
+
const prefix = `cli-repaint-${crypto.randomUUID()}`
|
|
183
|
+
record = { schema: 1, profile: safeName(profile), operation, input, inputHash, sourceKey: key, baseUrl: client.baseUrl, owner,
|
|
184
|
+
prefix, detailId: `${prefix}-detail`, groupId: `${prefix}-group`, createdAt: new Date().toISOString(), state: 'prepared' }
|
|
185
|
+
}
|
|
186
|
+
if (!confirmed) {
|
|
187
|
+
record.estimate = estimate; record.estimateHash = hash(estimate); await persist(record)
|
|
188
|
+
return { submitted: false, operation, input, estimate, limits, source_preview: sourcePreview, next: '展示本次参数、付款钱包和费用,明确确认后使用同一 operation 加 --yes;先核验素材实际时长,不按间隔承诺段数' }
|
|
189
|
+
}
|
|
190
|
+
if (hash(estimate) !== record.estimateHash) throw new Error('预估费用或钱包已变化;重新运行不带 --yes 的 start 并取得本次确认')
|
|
191
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'uzoo-repaint-'))
|
|
192
|
+
try {
|
|
193
|
+
const source = await downloadSource(client, doc, input, limits, dir)
|
|
194
|
+
await checkSource(client, record)
|
|
195
|
+
if (!record.session) {
|
|
196
|
+
record.state = 'session_creating'; await persist(record)
|
|
197
|
+
const fields = new URLSearchParams({ filename: source.filename, file_size: String(source.size), mime_type: source.mime,
|
|
198
|
+
segmentation_mode: input.segmentation_mode, scope: input.space, canvas_doc_id: input.doc_id, canvas_node_id: record.detailId,
|
|
199
|
+
...(input.team_uid ? { team_uid: input.team_uid } : {}),
|
|
200
|
+
...(input.uniform_interval_seconds !== undefined ? { uniform_interval_seconds: String(input.uniform_interval_seconds) } : {}) })
|
|
201
|
+
const session = await api(client, '/video-segmentation/upload-sessions', { method: 'POST', body: fields }, { retry: false })
|
|
202
|
+
// Retain the allocated ID even when validating a malformed session fails.
|
|
203
|
+
record.session = session; record.jobId = String(session?.job_id || ''); record.state = 'uploading'; await persist(record)
|
|
204
|
+
validateSession(session, source)
|
|
205
|
+
}
|
|
206
|
+
await uploadParts(client, record, source)
|
|
207
|
+
// Upload may take minutes; recheck ownership, source and pricing before the billable complete.
|
|
208
|
+
const currentDoc = await checkSource(client, record)
|
|
209
|
+
if (hash(await initialEstimate(client, input, currentDoc)) !== record.estimateHash) throw new Error('上传后价格或钱包已变化;未 complete,请重新预估并确认')
|
|
210
|
+
await claimRepaint(client, record)
|
|
211
|
+
record.state = 'submitting'; await persist(record)
|
|
212
|
+
let job
|
|
213
|
+
try {
|
|
214
|
+
job = await api(client, `/video-segmentation/upload-sessions/${segment(record.session.upload_id)}/complete`, {
|
|
215
|
+
method: 'POST', body: { object_key: record.session.object_key, expected_parts: record.parts.length, parts: record.parts },
|
|
216
|
+
}, { retry: false })
|
|
217
|
+
assertJob(job, record)
|
|
218
|
+
} catch {
|
|
219
|
+
record.state = 'unknown'; await persist(record)
|
|
220
|
+
throw failUnknown(`分析提交结果未确认;使用 repaint recover ${operation} 查询预分配的原任务,勿再次 complete`)
|
|
221
|
+
}
|
|
222
|
+
record.state = 'submitted'; await persist(record)
|
|
223
|
+
return { submitted: true, estimate, ...await sync(client, record, job) }
|
|
224
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
|
225
|
+
}
|
|
226
|
+
export async function repaintGet(client, profile, operation) {
|
|
227
|
+
const record = await loadRecord(client, profile, operation)
|
|
228
|
+
await checkSource(client, record, true)
|
|
229
|
+
const job = await getJob(client, record)
|
|
230
|
+
return { operation, state: record.state, job }
|
|
231
|
+
}
|
|
232
|
+
export async function confirmRepaint(client, profile, operation, planVersion, confirmed = false) {
|
|
233
|
+
if (!Number.isSafeInteger(planVersion) || planVersion < 1) throw new Error('--plan-version 必须为正整数')
|
|
234
|
+
const record = await loadRecord(client, profile, operation)
|
|
235
|
+
await checkSource(client, record)
|
|
236
|
+
let job = await getJob(client, record)
|
|
237
|
+
if (['plan_updating', 'plan_unknown'].includes(record.state)) throw failUnknown('上次方案修改结果未明;先 repaint recover 核对,不继续确认切片')
|
|
238
|
+
if (job.plan_version !== planVersion) throw new Error('方案版本已变化,重新读取并展示方案')
|
|
239
|
+
if (job.confirmed_at && job.confirmed_plan_version === planVersion && ['queued', 'rendering', 'completed'].includes(job.status)) return sync(client, record, job)
|
|
240
|
+
if (['confirming', 'confirmation_unknown'].includes(record.state)) throw failUnknown('上次确认结果未明;先 repaint recover,不能自动再次确认')
|
|
241
|
+
if (job.status !== 'awaiting_review' || !job.plan?.segments?.length || job.plan.valid === false) throw new Error('任务尚无可确认的有效分割方案')
|
|
242
|
+
const digest = hash({ plan: job.plan, plan_version: job.plan_version })
|
|
243
|
+
if (!confirmed) {
|
|
244
|
+
record.confirmPreview = digest; await persist(record)
|
|
245
|
+
return { submitted: false, operation, phase: 'confirm_segments', plan_version: planVersion, plan: job.plan,
|
|
246
|
+
next: '确认分段方案后加 --yes;只启动切片,不提交反推、提示词或视频生成' }
|
|
247
|
+
}
|
|
248
|
+
if (record.confirmPreview !== digest) throw new Error('先运行不带 --yes 的 confirm 展示当前分段方案并取得确认')
|
|
249
|
+
record.confirmVersion = planVersion; record.state = 'confirming'; await persist(record)
|
|
250
|
+
try {
|
|
251
|
+
job = await api(client, `${jobPath(record.jobId)}/confirm`, { method: 'POST', body: { plan_version: planVersion } }, { retry: false })
|
|
252
|
+
assertJob(job, record)
|
|
253
|
+
} catch {
|
|
254
|
+
record.state = 'confirmation_unknown'; await persist(record)
|
|
255
|
+
throw failUnknown('切片确认结果未明;使用 repaint recover 核对原任务,不重复调用 confirm')
|
|
256
|
+
}
|
|
257
|
+
record.state = 'running'; await persist(record)
|
|
258
|
+
return sync(client, record, job)
|
|
259
|
+
}
|
|
260
|
+
export async function updateRepaintPlan(client, profile, operation, body, confirmed = false) {
|
|
261
|
+
if (!object(body) || Object.keys(body).some(k => !['plan_version', 'boundaries_ms'].includes(k)) ||
|
|
262
|
+
!Number.isSafeInteger(body.plan_version) || body.plan_version < 1 || !Array.isArray(body.boundaries_ms) || body.boundaries_ms.length < 2 || body.boundaries_ms.length > 64 ||
|
|
263
|
+
body.boundaries_ms[0] !== 0 || body.boundaries_ms.some((n, i) => !Number.isSafeInteger(n) || n < 0 || (i && n <= body.boundaries_ms[i - 1]))) throw new Error('方案输入必须为 plan_version 和从 0 开始严格递增的整数 boundaries_ms(2–64 个)')
|
|
264
|
+
const record = await loadRecord(client, profile, operation)
|
|
265
|
+
await checkSource(client, record)
|
|
266
|
+
const job = await getJob(client, record)
|
|
267
|
+
if (['confirming', 'confirmation_unknown'].includes(record.state)) throw failUnknown('上次切片确认结果未明;先 repaint recover 核对,不继续修改方案')
|
|
268
|
+
if (['plan_updating', 'plan_unknown'].includes(record.state)) throw failUnknown('方案保存结果未明;先 repaint get 核对当前方案,不能重复提交旧版本')
|
|
269
|
+
if (job.status !== 'awaiting_review' || job.plan_version !== body.plan_version) throw new Error('只能修改当前待确认方案,版本必须一致')
|
|
270
|
+
if (body.boundaries_ms.at(-1) !== job.plan?.duration_ms) throw new Error('最后一个切点必须等于真实视频总时长')
|
|
271
|
+
if (!confirmed) return { submitted: false, operation, plan: job.plan, proposed: body, next: '确认切点后加 --yes 保存;保存后重新读取方案版本再 confirm' }
|
|
272
|
+
record.state = 'plan_updating'; record.proposedPlan = body; await persist(record)
|
|
273
|
+
let updated
|
|
274
|
+
try {
|
|
275
|
+
updated = await api(client, `${jobPath(record.jobId)}/plan`, { method: 'PATCH', body }, { retry: false })
|
|
276
|
+
assertJob(updated, record)
|
|
277
|
+
} catch {
|
|
278
|
+
record.state = 'plan_unknown'; await persist(record)
|
|
279
|
+
throw failUnknown('方案保存结果未明;使用 repaint recover 核对当前方案,不重放旧版本')
|
|
280
|
+
}
|
|
281
|
+
delete record.confirmPreview
|
|
282
|
+
return sync(client, record, updated)
|
|
283
|
+
}
|
|
284
|
+
export async function recoverRepaint(client, profile, operation, options = {}) {
|
|
285
|
+
const restoreDetail = options.restoreDetail === true
|
|
286
|
+
if (options.confirmed && !restoreDetail) throw new Error('--yes仅用于明确的--restore-detail恢复')
|
|
287
|
+
const record = await loadRecord(client, profile, operation)
|
|
288
|
+
await checkSource(client, record, restoreDetail)
|
|
289
|
+
const job = await getJob(client, record)
|
|
290
|
+
if (restoreDetail) {
|
|
291
|
+
if (job.status !== 'awaiting_review' || record.state === 'materialized' || record.lastStatus === 'completed') throw new Error('仅支持原待审核任务的详情恢复;不能重建已切片节点链')
|
|
292
|
+
if (!options.confirmed) return { operation, submitted: false, restoration_saved: false,
|
|
293
|
+
phase: 'restore_analysis_detail', job_id: job.id, detail_id: record.detailId,
|
|
294
|
+
plan_version: job.plan_version, next: '协调暂停画布编辑,经用户明确授权后加--restore-detail --yes;仅恢复原任务详情/系统线,不重新分析或确认切片' }
|
|
295
|
+
}
|
|
296
|
+
if (['confirming', 'confirmation_unknown'].includes(record.state) &&
|
|
297
|
+
!(job.confirmed_at && job.confirmed_plan_version === record.confirmVersion && ['queued', 'rendering', 'completed', 'failed', 'cancelled'].includes(job.status))) throw failUnknown('尚不能确认上次切片请求是否生效;保留原记录,不自动重提')
|
|
298
|
+
if (['plan_updating', 'plan_unknown'].includes(record.state) &&
|
|
299
|
+
!(job.plan_version > record.proposedPlan.plan_version && JSON.stringify(job.plan?.boundaries_ms) === JSON.stringify(record.proposedPlan.boundaries_ms))) throw failUnknown('尚不能确认上次方案保存是否生效;先核对当前任务方案,不自动重提')
|
|
300
|
+
return sync(client, record, job, { restoreDetail })
|
|
301
|
+
}
|
|
302
|
+
export async function waitRepaint(client, profile, operation, interval = 3000, timeout = 600000) {
|
|
303
|
+
const record = await loadRecord(client, profile, operation)
|
|
304
|
+
if (!Number.isSafeInteger(interval) || interval < 1 || !Number.isSafeInteger(timeout) || timeout < 1) throw new Error('等待间隔和超时必须为正整数')
|
|
305
|
+
const deadline = Date.now() + timeout
|
|
306
|
+
while (Date.now() < deadline) {
|
|
307
|
+
await checkSource(client, record)
|
|
308
|
+
const job = await getJob(client, record)
|
|
309
|
+
if (['awaiting_review', 'completed', 'failed', 'cancelled'].includes(job.status)) {
|
|
310
|
+
const result = await recoverRepaint(client, profile, operation)
|
|
311
|
+
if (['failed', 'cancelled'].includes(job.status)) { const e = new Error(`任务已${job.status};已同步状态,使用 repaint get 查看,不自动重做`); e.code = 'TASK_FAILED'; throw e }
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
await sleep(Math.min(interval, Math.max(1, deadline - Date.now())), undefined, { signal: cancellation.signal })
|
|
315
|
+
}
|
|
316
|
+
const error = new Error(`等待超时;使用 repaint wait/recover ${operation} 继续原任务,不重新分析`)
|
|
317
|
+
error.code = 'WAIT_TIMEOUT'; throw error
|
|
318
|
+
}
|
|
319
|
+
export async function listRepaints(profile) {
|
|
320
|
+
const dir = path.join(root(), 'repaint', safeName(profile))
|
|
321
|
+
const files = await fs.readdir(dir).catch(error => { if (error.code === 'ENOENT') return []; throw error })
|
|
322
|
+
return Promise.all(files.filter(f => f.endsWith('.json')).map(async f => {
|
|
323
|
+
const { operation, state, jobId, createdAt } = await json(path.join(dir, f))
|
|
324
|
+
return { operation, state, job_id: jobId || null, created_at: createdAt }
|
|
325
|
+
}))
|
|
326
|
+
}
|
package/src/resource.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { upload } from './workflows.js'
|
|
2
|
+
import { writeNode } from './canvas.js'
|
|
3
|
+
|
|
4
|
+
// Persist the same canonical media fields that the website saves. Binding only
|
|
5
|
+
// reuses the confirmed upload response; it never uploads the file again.
|
|
6
|
+
export async function bindUpload(client, { docId, nodeId, result, imageNode = false }) {
|
|
7
|
+
if (
|
|
8
|
+
result?.source_doc_id !== docId ||
|
|
9
|
+
result?.source_node_id !== nodeId ||
|
|
10
|
+
!['image', 'video', 'audio'].includes(result.segment_type)
|
|
11
|
+
)
|
|
12
|
+
throw new Error('上传响应与画布/节点绑定不一致')
|
|
13
|
+
const media = result.result
|
|
14
|
+
if (!media?.url || !media.file_ref) throw new Error('上传响应缺少真实资源引用;先查询原上传记录')
|
|
15
|
+
const doc = await client.raw(`/canvas/docs/${encodeURIComponent(docId)}`)
|
|
16
|
+
const node = doc.nodes?.find((n) => n.id === nodeId)
|
|
17
|
+
const kind = node?.data?.extra?.node_kind
|
|
18
|
+
if (
|
|
19
|
+
!node ||
|
|
20
|
+
(!node.type.endsWith('_upload') &&
|
|
21
|
+
kind !== 'asset' &&
|
|
22
|
+
!(imageNode && node.type === 'image' && result.segment_type === 'image')) ||
|
|
23
|
+
node.data?.extra?.repaint ||
|
|
24
|
+
node.data?.extra?.video_remake ||
|
|
25
|
+
node.data?.extra?.clip_redraw
|
|
26
|
+
)
|
|
27
|
+
throw new Error('上传绑定仅支持普通素材节点')
|
|
28
|
+
const data = node.data || {}
|
|
29
|
+
if (imageNode && !/^[1-9]\d*$/.test(String(result.task_id || '')))
|
|
30
|
+
throw new Error('图片处理结果缺少真实上传任务')
|
|
31
|
+
if ((data.mediaType || node.type.replace('_upload', '')) !== result.segment_type)
|
|
32
|
+
throw new Error('上传媒体类型与素材节点不一致')
|
|
33
|
+
const ref = data.file_ref || data.extra?.file_ref || data.result?.file_ref
|
|
34
|
+
if (ref && ref !== media.file_ref) throw new Error('节点已引用其他素材;停止以保留用户编辑')
|
|
35
|
+
const patch = {
|
|
36
|
+
data: {
|
|
37
|
+
uploading: false,
|
|
38
|
+
uploadError: '',
|
|
39
|
+
url: media.url,
|
|
40
|
+
file_ref: media.file_ref,
|
|
41
|
+
mime_type: media.mime_type,
|
|
42
|
+
mediaType: result.segment_type,
|
|
43
|
+
file_name: media.filename,
|
|
44
|
+
file_size: media.file_size,
|
|
45
|
+
...(media.duration ? { duration: media.duration } : {}),
|
|
46
|
+
result: {
|
|
47
|
+
...media,
|
|
48
|
+
status: 'completed',
|
|
49
|
+
...(result.task_id ? { task_id: result.task_id } : {})
|
|
50
|
+
},
|
|
51
|
+
status: 'completed',
|
|
52
|
+
...(imageNode
|
|
53
|
+
? {
|
|
54
|
+
runtime: {
|
|
55
|
+
status: 'completed',
|
|
56
|
+
operation: '',
|
|
57
|
+
progress: 100,
|
|
58
|
+
taskId: result.task_id,
|
|
59
|
+
error: ''
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
: {}),
|
|
63
|
+
...(result.task_id ? { last_task_id: result.task_id } : {}),
|
|
64
|
+
extra: {
|
|
65
|
+
node_kind: imageNode ? 'generation' : 'asset',
|
|
66
|
+
file_ref: media.file_ref,
|
|
67
|
+
...(media.width && media.height ? { aspect_ratio: `${media.width}:${media.height}` } : {})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return writeNode(client, {
|
|
72
|
+
docId,
|
|
73
|
+
nodeId,
|
|
74
|
+
patch,
|
|
75
|
+
update: true,
|
|
76
|
+
expectedSpace: doc.team_uid ? 'team' : 'personal',
|
|
77
|
+
expectedTeamUid: doc.team_uid,
|
|
78
|
+
expectedVersion: doc.version
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
export async function uploadAndBind(client, args) {
|
|
82
|
+
if (args.node) {
|
|
83
|
+
const doc = await client.raw(`/canvas/docs/${encodeURIComponent(args.doc)}`)
|
|
84
|
+
const node = doc.nodes?.find((n) => n.id === args.node)
|
|
85
|
+
if (
|
|
86
|
+
!node ||
|
|
87
|
+
!(node.type.endsWith('_upload') || node.data?.extra?.node_kind === 'asset') ||
|
|
88
|
+
node.data?.extra?.repaint ||
|
|
89
|
+
node.data?.extra?.video_remake ||
|
|
90
|
+
node.data?.extra?.clip_redraw
|
|
91
|
+
)
|
|
92
|
+
throw new Error('请选择普通素材节点后上传')
|
|
93
|
+
if ((node.data?.mediaType || node.type.replace('_upload', '')) !== args.type)
|
|
94
|
+
throw new Error('上传类型与素材节点不一致')
|
|
95
|
+
}
|
|
96
|
+
const result = await upload(client, args)
|
|
97
|
+
if (!args.node) return result
|
|
98
|
+
try {
|
|
99
|
+
const bound = await bindUpload(client, { docId: args.doc, nodeId: args.node, result })
|
|
100
|
+
return { ...result, document_version: bound.version, canvas_bound: true }
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Upload itself already succeeded; preserve its real response for repair.
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
canvas_bound: false,
|
|
106
|
+
binding_error: error.message,
|
|
107
|
+
next: '文件已上传,勿重复上传;保存本响应后 resource bind --input FILE 仅修复节点引用'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|