@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/multipart.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Same explicit text-field framing as web signV2.serializeFormData. Hash and
|
|
4
|
+
// transmit this Buffer verbatim; native FormData has an opaque boundary.
|
|
5
|
+
export function multipartText(
|
|
6
|
+
fields,
|
|
7
|
+
boundary = `----SignV2Boundary${crypto.randomBytes(16).toString('hex')}`
|
|
8
|
+
) {
|
|
9
|
+
if (!/^[A-Za-z0-9-]{16,100}$/.test(boundary))
|
|
10
|
+
throw new Error('multipart boundary无效')
|
|
11
|
+
const chunks = []
|
|
12
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
13
|
+
if (
|
|
14
|
+
!/^[a-z_]+$/.test(name) ||
|
|
15
|
+
typeof value !== 'string' ||
|
|
16
|
+
/\u0000/.test(value)
|
|
17
|
+
)
|
|
18
|
+
throw new Error('multipart文本字段无效')
|
|
19
|
+
chunks.push(
|
|
20
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
chunks.push(`--${boundary}--\r\n`)
|
|
24
|
+
return {
|
|
25
|
+
body: Buffer.from(chunks.join(''), 'utf8'),
|
|
26
|
+
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/node-copy.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {isDeepStrictEqual as same} from 'node:util'
|
|
2
|
+
import {buildMediaCopy} from './media-copy.js'
|
|
3
|
+
import {scopedCanvas} from './materials.js'
|
|
4
|
+
import {save} from './graph.js'
|
|
5
|
+
import {storedReferences,readSource,mergeSources} from './generation-input.js'
|
|
6
|
+
|
|
7
|
+
export function remapCopyMentions(value,idMap) {
|
|
8
|
+
if(typeof value!=='string')return value
|
|
9
|
+
return value.replace(/(@@?)[{]ref:([^}]+)[}]/g,(marker,prefix,encoded)=>{
|
|
10
|
+
let id;try{id=decodeURIComponent(encoded)}catch{return marker}
|
|
11
|
+
return idMap.has(id)?prefix+'{ref:'+encodeURIComponent(idMap.get(id))+'}':marker
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function copyTextNodes(client,input,confirmed=false){
|
|
16
|
+
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['doc_id','space','team_uid','copies','expected_version'].includes(k))||typeof input.doc_id!=='string'||!input.doc_id.trim()||!Array.isArray(input.copies)||(!input.copies.length||input.copies.length>100))throw new Error('批量复制需要doc_id/space/team_uid/copies')
|
|
17
|
+
for(const c of input.copies)if(!c||Object.keys(c).some(k=>!['node_id','new_node_id','position'].includes(k))||!['node_id','new_node_id'].every(k=>typeof c[k]==='string'&&c[k].trim())||!c.position||!Number.isFinite(c.position.x)||!Number.isFinite(c.position.y))throw new Error('copies每项需要node_id/new_node_id/position')
|
|
18
|
+
if(new Set(input.copies.map(c=>c.node_id)).size!==input.copies.length||new Set(input.copies.map(c=>c.new_node_id)).size!==input.copies.length)throw new Error('批次源ID或新ID重复')
|
|
19
|
+
if(input.expected_version!==undefined&&(!Number.isSafeInteger(input.expected_version)||input.expected_version<1))throw new Error('expected_version无效')
|
|
20
|
+
const doc=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)
|
|
21
|
+
if(!Number.isSafeInteger(doc.version)||doc.version<1||!Array.isArray(doc.nodes)||!Array.isArray(doc.edges)||!doc.meta||!doc.viewport||typeof doc.name!=='string'||new Set(doc.nodes.map(n=>n.id)).size!==doc.nodes.length)throw new Error('画布快照不完整')
|
|
22
|
+
if(input.expected_version!==undefined&&input.expected_version!==doc.version)throw new Error('画布已变化,重新预览')
|
|
23
|
+
if(input.copies.some(c=>doc.nodes.some(n=>n.id===c.new_node_id)))throw new Error('目标节点ID已存在,先读回,不重复复制')
|
|
24
|
+
const map=new Map(input.copies.map(c=>[c.node_id,c.new_node_id])),meta=structuredClone(doc.meta)
|
|
25
|
+
const prepared=input.copies.map(c=>{
|
|
26
|
+
const {copy,refs}=buildTextCopy(doc,c)
|
|
27
|
+
const mapped=refs.map(r=>map.has(String(r.id))?{...r,id:map.get(String(r.id)),
|
|
28
|
+
...(typeof r.marker==='string'?{marker:remapCopyMentions(r.marker,map)}:{}),
|
|
29
|
+
...(typeof r.legacyMarker==='string'?{legacyMarker:remapCopyMentions(r.legacyMarker,map)}:{})}:r)
|
|
30
|
+
const before=copy.data.prompt
|
|
31
|
+
if(typeof before==='string')copy.data.prompt=remapCopyMentions(before,map)
|
|
32
|
+
for(const holder of [copy.data,copy.data.extra])if(holder){
|
|
33
|
+
if(typeof holder.effectivePrompt==='string')holder.effectivePrompt=remapCopyMentions(holder.effectivePrompt,map)
|
|
34
|
+
for(const key of ['seedance_material_reviews','seedanceMaterialReviews'])if(holder[key]&&typeof holder[key]==='object')holder[key]=Object.fromEntries(Object.entries(holder[key]).map(([id,value])=>[map.get(id)||id,value]))
|
|
35
|
+
}
|
|
36
|
+
if(mapped.length){copy.data.extra={...copy.data.extra,references:mapped};if(Array.isArray(copy.data.references))copy.data.references=mapped;meta.node_references={...meta.node_references,[copy.id]:mapped}}
|
|
37
|
+
return {copy,preview:{node_id:c.node_id,new_node_id:c.new_node_id,position:copy.position,node_type:copy.type,source_task_id:copy.data.extra?.source_task_id||copy.data.last_task_id||null,copied_history_count:copy.data.extra?.copied_generation_history?.length||0,prompt_before:before,prompt_after:copy.data.prompt,reference_node_ids:mapped.map(r=>r.id)}}
|
|
38
|
+
})
|
|
39
|
+
const preview={doc_id:doc.id,expected_version:doc.version,copies:prepared.map(p=>p.preview),copies_edges:false,copies_groups:false,canvas_written:false,generation_submitted:false}
|
|
40
|
+
if(!confirmed)return preview
|
|
41
|
+
if(input.expected_version===undefined)throw new Error('批量复制执行须提供预览expected_version')
|
|
42
|
+
const nodes=[...doc.nodes,...prepared.map(p=>p.copy)],saved=await save(client,doc,{nodes,meta})
|
|
43
|
+
let after;try{after=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)}catch(cause){throw new Error('批量复制保存后读回未确认,不自动重放',{cause})}
|
|
44
|
+
if(!Number.isSafeInteger(after.version)||after.version<saved.version||!same(after.nodes,nodes)||!same(after.meta,meta)||!same(after.edges,doc.edges)||!same(after.viewport,doc.viewport)||after.name!==doc.name)throw new Error('批量复制读回不一致,不自动重放')
|
|
45
|
+
return {...preview,version:after.version,canvas_written:true,copy_verified:true}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildTextCopy(doc,input) {
|
|
49
|
+
const source=doc.nodes.find(n=>n.id===input.node_id),data=source?.data||{}
|
|
50
|
+
if (!source || data.repaint || data.videoRemake || data.clipRedraw || data.draftFinalizeGeneration || data.extra?.repaint || data.extra?.video_remake || data.extra?.clip_redraw || data.extra?.draft_finalize) throw new Error('复制仅支持普通节点;派生流程不可复制')
|
|
51
|
+
if ([data.runtime?.status,data.status].some(status=>['queued','preparing','pending','processing','retrying','submitting','uploading'].includes(status)) || data.uploading) throw new Error('进行中节点不能复制')
|
|
52
|
+
if (data.generation_config || data.node_output) throw new Error('当前节点配置协议未接入复制')
|
|
53
|
+
const connected=[...new Set(doc.edges.filter(e=>e.target===source.id).map(e=>e.source))].map(id=>{
|
|
54
|
+
const n=doc.nodes.find(n=>n.id===id);if(!n)throw new Error('源节点入边缺少端点');return readSource(n)
|
|
55
|
+
})
|
|
56
|
+
const refs=mergeSources(connected,storedReferences(doc,source)).map((r,i)=>({...r,index:Number(r.index)||i+1,isPersistentReference:true}))
|
|
57
|
+
const media=source.type==='text'?null:buildMediaCopy(source)
|
|
58
|
+
const copy=media?.copy||structuredClone(source)
|
|
59
|
+
copy.id=input.new_node_id;copy.position={x:Math.round(input.position.x),y:Math.round(input.position.y)}
|
|
60
|
+
for(const k of ['computedPosition','dimensions','handleBounds','initialized','isParent','dragging','resizing','events','selected'])delete copy[k]
|
|
61
|
+
copy.data.lastRequest=null
|
|
62
|
+
if(media)return {copy,refs}
|
|
63
|
+
const status=data.runtime?.status||data.status||'idle'
|
|
64
|
+
if(status!=='completed'){
|
|
65
|
+
delete copy.data.last_task_id;delete copy.data.lastTaskId
|
|
66
|
+
if(['queued','processing','failed'].includes(status))copy.data.status='idle'
|
|
67
|
+
if(copy.data.runtime)copy.data.runtime={...copy.data.runtime,status:'idle',progress:0,taskId:'',error:''}
|
|
68
|
+
}else if(copy.data.runtime)copy.data.runtime={...copy.data.runtime,status:'completed',progress:100,error:''}
|
|
69
|
+
return {copy,refs}
|
|
70
|
+
}
|
|
71
|
+
export async function copyTextNode(client,input,confirmed=false) {
|
|
72
|
+
if (!input || typeof input!=='object' || Array.isArray(input) ||
|
|
73
|
+
Object.keys(input).some(k=>!['doc_id','space','team_uid','node_id','new_node_id','position','expected_version'].includes(k)) ||
|
|
74
|
+
!['doc_id','node_id','new_node_id'].every(k=>typeof input[k]==='string' && input[k].trim()))
|
|
75
|
+
throw new Error('复制需要doc_id/space/team_uid/node_id/new_node_id/position,可选expected_version')
|
|
76
|
+
if (!input.position || !Number.isFinite(input.position.x) || !Number.isFinite(input.position.y)) throw new Error('必须指定有效position')
|
|
77
|
+
if (input.expected_version!==undefined && (!Number.isSafeInteger(input.expected_version)||input.expected_version<1)) throw new Error('expected_version无效')
|
|
78
|
+
const doc=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)
|
|
79
|
+
if (!Number.isSafeInteger(doc.version)||doc.version<1||!Array.isArray(doc.nodes)||!Array.isArray(doc.edges)||!doc.meta||!doc.viewport||typeof doc.name!=='string') throw new Error('画布快照不完整')
|
|
80
|
+
if (new Set(doc.nodes.map(n=>n.id)).size!==doc.nodes.length) throw new Error('节点ID重复')
|
|
81
|
+
if (input.expected_version!==undefined && input.expected_version!==doc.version) throw new Error('画布已变化,重新预览')
|
|
82
|
+
if (doc.nodes.some(n=>n.id===input.new_node_id)) throw new Error('目标节点ID已存在;先读回,不重复复制')
|
|
83
|
+
const {copy,refs}=buildTextCopy(doc,input)
|
|
84
|
+
const meta=structuredClone(doc.meta)
|
|
85
|
+
if(refs.length){copy.data.extra={...copy.data.extra,references:refs};meta.node_references={...meta.node_references,[copy.id]:refs}}
|
|
86
|
+
const preview={doc_id:doc.id,node_id:input.node_id,new_node_id:copy.id,expected_version:doc.version,position:copy.position,
|
|
87
|
+
node_type:copy.type,source_task_id:copy.data.extra?.source_task_id||copy.data.last_task_id||null,copied_history_count:copy.data.extra?.copied_generation_history?.length||0,reference_node_ids:refs.map(r=>r.id),copies_edges:false,copies_group:false,canvas_written:false,generation_submitted:false}
|
|
88
|
+
if(!confirmed)return preview
|
|
89
|
+
if(input.expected_version===undefined)throw new Error('复制执行须提供预览expected_version')
|
|
90
|
+
const nodes=[...doc.nodes,copy],saved=await save(client,doc,{nodes,meta})
|
|
91
|
+
let after
|
|
92
|
+
try{after=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)}catch(cause){throw new Error('复制保存后读回未确认,不自动重放',{cause})}
|
|
93
|
+
if(!Number.isSafeInteger(after.version)||after.version<saved.version||!same(after.nodes,nodes)||!same(after.meta,meta)||!same(after.edges,doc.edges)||!same(after.viewport,doc.viewport)||after.name!==doc.name)throw new Error('复制读回不一致,不自动重放')
|
|
94
|
+
return {...preview,version:after.version,canvas_written:true,copy_verified:true}
|
|
95
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {isDeepStrictEqual as same} from 'node:util'
|
|
2
|
+
import {scopedCanvas} from './materials.js'
|
|
3
|
+
import {save,sourceType} from './graph.js'
|
|
4
|
+
import {syncGroupMemberships} from './group-membership.js'
|
|
5
|
+
import {generationModes,normalizeMode,storedReferences} from './generation-input.js'
|
|
6
|
+
const derived=n=>n?.type==='video_repain_begain'||n?.data?.repaint||n?.data?.videoRemake||n?.data?.extra?.repaint||n?.data?.extra?.video_remake||n?.data?.extra?.clip_redraw||n?.data?.draftFinalizeGeneration||n?.data?.extra?.draft_finalize
|
|
7
|
+
export async function deleteNodes(client,input,confirmed=false){
|
|
8
|
+
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['doc_id','space','team_uid','node_ids','expected_version'].includes(k))||typeof input.doc_id!=='string'||!input.doc_id.trim()||!Array.isArray(input.node_ids)||!input.node_ids.length||input.node_ids.some(id=>typeof id!=='string'||!id.trim())||new Set(input.node_ids).size!==input.node_ids.length)throw new Error('删除需要doc_id/space/team_uid及不重复node_ids')
|
|
9
|
+
if(input.expected_version!==undefined&&(!Number.isSafeInteger(input.expected_version)||input.expected_version<1))throw new Error('expected_version无效')
|
|
10
|
+
const doc=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)
|
|
11
|
+
if(!Number.isSafeInteger(doc.version)||doc.version<1||!Array.isArray(doc.nodes)||!Array.isArray(doc.edges)||!doc.meta||Array.isArray(doc.meta)||!doc.viewport||typeof doc.name!=='string'||new Set(doc.nodes.map(n=>n.id)).size!==doc.nodes.length)throw new Error('画布快照不完整')
|
|
12
|
+
if(input.expected_version!==undefined&&input.expected_version!==doc.version)throw new Error('画布已变化,重新预览')
|
|
13
|
+
const ids=new Set(input.node_ids),selected=input.node_ids.map(id=>doc.nodes.find(n=>n.id===id))
|
|
14
|
+
if(selected.some(n=>!n))throw new Error('目标节点不存在,先读回核对')
|
|
15
|
+
if(selected.some(derived))throw new Error('普通删除不支持派生流程节点')
|
|
16
|
+
if(selected.some(n=>n.data?.uploading||['queued','processing','retrying','submitting'].includes(n.data?.runtime?.status||n.data?.status)))throw new Error('进行中节点须先核对任务,不通过删除取消任务')
|
|
17
|
+
const removedEdges=doc.edges.filter(e=>ids.has(e.source)||ids.has(e.target))
|
|
18
|
+
if(removedEdges.some(e=>e.data?.role||e.data?.repaintSystem))throw new Error('关联系统线须使用专用流程,不普通删除')
|
|
19
|
+
const next=structuredClone(doc);next.nodes=next.nodes.filter(n=>!ids.has(n.id));next.edges=next.edges.filter(e=>!ids.has(e.source)&&!ids.has(e.target))
|
|
20
|
+
if(next.meta.node_references)for(const id of ids)delete next.meta.node_references[id]
|
|
21
|
+
const groups=next.meta.canvas_groups||[]
|
|
22
|
+
if(!Array.isArray(groups)||groups.some(g=>!g||!Array.isArray(g.nodeIds)))throw new Error('分组快照无效')
|
|
23
|
+
const state={nodes:next.nodes,groups};syncGroupMemberships(state)
|
|
24
|
+
const groupChanges=groups.filter(g=>!same(g,(doc.meta.canvas_groups||[]).find(o=>o.id===g.id)))
|
|
25
|
+
if(groupChanges.some(g=>g.repaint||g.video_remake||g.clip_redraw))throw new Error('删除将影响派生分组,停止')
|
|
26
|
+
const affected=[...new Set(removedEdges.filter(e=>ids.has(e.source)&&!ids.has(e.target)).map(e=>e.target))]
|
|
27
|
+
const modeChanges=[]
|
|
28
|
+
if(affected.some(id=>['image','video'].includes(next.nodes.find(n=>n.id===id)?.type))){
|
|
29
|
+
const catalog=await client.raw('/canvas/models'+(doc.team_uid?'?team_uid='+encodeURIComponent(doc.team_uid):''))
|
|
30
|
+
for(const id of affected){const n=next.nodes.find(n=>n.id===id);if(!n)throw new Error('下游端点缺失');if(!['image','video'].includes(n.type))continue
|
|
31
|
+
if(derived(n)||n.data?.generation_config||n.data?.node_output)throw new Error('下游派生/配置协议不支持普通删除')
|
|
32
|
+
const model=catalog.items?.find(m=>m.label===n.data?.model_name&&m.content_type===n.type&&!m.member_locked);if(!model)throw new Error('下游模型不可用')
|
|
33
|
+
const types=next.edges.filter(e=>e.target===id).map(e=>sourceType(next.nodes.find(n=>n.id===e.source)))
|
|
34
|
+
const modes=(model.generation_modes||[]).map(normalizeMode).filter(m=>generationModes[m]&&types.every(t=>generationModes[m][0].includes(t))&&(!generationModes[m][1].length||generationModes[m][1].some(t=>types.includes(t))))
|
|
35
|
+
if(!modes.length)throw new Error('删除后下游无兼容模式,请先选择模型')
|
|
36
|
+
const before=normalizeMode(n.data?.extra?.generation_mode||n.data?.generation_mode||n.data?.imageMode||n.data?.videoMode),after=modes.includes(before)?before:modes[0]
|
|
37
|
+
n.data.extra={...n.data.extra,generation_mode:after};if(Object.hasOwn(n.data,'generation_mode'))n.data.generation_mode=after
|
|
38
|
+
if(before!==after)modeChanges.push({node_id:id,before,after})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const retained=next.nodes.flatMap(n=>{const refs=storedReferences(next,n).filter(r=>ids.has(String(r.id)));return refs.length?[{node_id:n.id,reference_node_ids:refs.map(r=>r.id)}]:[]})
|
|
42
|
+
const preview={doc_id:doc.id,node_ids:input.node_ids,expected_version:doc.version,removed_edge_ids:removedEdges.map(e=>e.id),group_changes:groupChanges.map(g=>({group_id:g.id,node_ids:g.nodeIds,exited_node_ids:g.exitedNodeIds||[]})),retained_references:retained,mode_changes:modeChanges,canvas_written:false,generation_submitted:false,remote_tasks_deleted:false}
|
|
43
|
+
if(!confirmed)return preview
|
|
44
|
+
if(input.expected_version===undefined)throw new Error('执行删除须提供预览expected_version')
|
|
45
|
+
const saved=await save(client,doc,{nodes:next.nodes,edges:next.edges,meta:next.meta})
|
|
46
|
+
let actual;try{actual=await scopedCanvas(client,input.doc_id,input.space,input.team_uid)}catch(cause){throw new Error('删除保存已尝试但读回未确认,不自动重放',{cause})}
|
|
47
|
+
if(!Number.isSafeInteger(actual.version)||actual.version<saved.version||!same(actual.nodes,next.nodes)||!same(actual.edges,next.edges)||!same(actual.meta,next.meta)||!same(actual.viewport,doc.viewport)||actual.name!==doc.name)throw new Error('删除读回不一致,不自动重放')
|
|
48
|
+
return {...preview,canvas_written:true,delete_verified:true,version:actual.version}
|
|
49
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { isDeepStrictEqual as equal } from 'node:util'
|
|
3
|
+
import { atomic, json, root, safeName } from './store.js'
|
|
4
|
+
import { scopedCanvas } from './materials.js'
|
|
5
|
+
import { segment } from './workflows.js'
|
|
6
|
+
import { requireServiceReady } from './service.js'
|
|
7
|
+
import { sameImageReferences, sameVideoReferences } from './media.js'
|
|
8
|
+
|
|
9
|
+
const text = value => String(value ?? '').trim()
|
|
10
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
const fail = (code, message, cause) => Object.assign(new Error(message), { code,
|
|
12
|
+
...(cause ? { cause, diagnostic: {
|
|
13
|
+
...(cause.status ? { http_status: cause.status } : {}),
|
|
14
|
+
...(cause.apiCode ? { api_code: cause.apiCode } : {}),
|
|
15
|
+
...(cause.requestId ? { request_id: cause.requestId } : {})
|
|
16
|
+
} } : {}) })
|
|
17
|
+
export const nodeHistoryPath = (profile, operation) =>
|
|
18
|
+
path.join(root(), 'node-history', safeName(profile), safeName(operation) + '.json')
|
|
19
|
+
const persist = record => atomic(nodeHistoryPath(record.profile, record.operation), record)
|
|
20
|
+
export function nodeHistoryInput(value, selection = true) {
|
|
21
|
+
if (!object(value) || Object.keys(value).some(key =>
|
|
22
|
+
!['doc_id', 'node_id', 'space', 'team_uid', ...(selection ? ['task_id'] : [])].includes(key)) ||
|
|
23
|
+
!['doc_id', 'node_id'].every(key => typeof value[key] === 'string' && text(value[key])))
|
|
24
|
+
throw new Error('历史输入仅支持doc_id/node_id/space/team_uid及选择时的task_id')
|
|
25
|
+
if (selection && (!['number', 'string'].includes(typeof value.task_id) || !/^[1-9]\d*$/.test(String(value.task_id))))
|
|
26
|
+
throw new Error('task_id须为正安全整数')
|
|
27
|
+
if (selection && !Number.isSafeInteger(Number(value.task_id))) throw new Error('task_id超出安全整数范围')
|
|
28
|
+
return { ...value, ...(selection ? { task_id: String(value.task_id) } : {}) }
|
|
29
|
+
}
|
|
30
|
+
export async function historyContext(client, input) {
|
|
31
|
+
const doc = await scopedCanvas(client, input.doc_id, input.space, input.team_uid)
|
|
32
|
+
if (!Number.isSafeInteger(doc.version) || doc.version < 1 || !Array.isArray(doc.nodes) ||
|
|
33
|
+
!Array.isArray(doc.edges) || !object(doc.meta) || !object(doc.viewport) || typeof doc.name !== 'string' ||
|
|
34
|
+
new Set(doc.nodes.map(node => node.id)).size !== doc.nodes.length)
|
|
35
|
+
throw new Error('画布快照不完整或节点重复,停止历史选择')
|
|
36
|
+
const node = doc.nodes.find(item => item.id === input.node_id), data = node?.data || {}, extra = data.extra || {}
|
|
37
|
+
if (!node || !['image', 'video'].includes(node.type) ||
|
|
38
|
+
data.repaint || extra.repaint || data.videoRemake || extra.video_remake || data.clipRedraw || extra.clip_redraw ||
|
|
39
|
+
extra.draft_finalize || extra.node_kind === 'asset' ||
|
|
40
|
+
(extra.node_kind !== 'generation' && (data.mediaType || data.file_name || data.fileName)))
|
|
41
|
+
throw new Error('历史选择仅接入普通图片/视频生成节点;素材、派生节点和文本暂不支持')
|
|
42
|
+
if (data.generation_config || data.node_output) throw new Error('当前节点使用其他配置协议;先核对真实读回格式,不混合写入历史字段')
|
|
43
|
+
const account = await client.raw('/auth/me')
|
|
44
|
+
if (!text(account.uid)) throw new Error('账号缺少UID')
|
|
45
|
+
if (input.space === 'team' && !account.teams?.some(team => text(team.uid) === input.team_uid && team.status === 'active'))
|
|
46
|
+
throw new Error('目标团队有效成员身份不可确认')
|
|
47
|
+
return { doc, node, owner: text(account.uid) }
|
|
48
|
+
}
|
|
49
|
+
const context = historyContext
|
|
50
|
+
function pageInput({ status, limit = 30, offset = 0 } = {}) {
|
|
51
|
+
if (status !== undefined && !['queued', 'processing', 'completed', 'failed', 'retrying', 'cancelled'].includes(status))
|
|
52
|
+
throw new Error('历史status不支持')
|
|
53
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
|
|
54
|
+
throw new Error('历史分页需要limit 1–100和非负整数offset')
|
|
55
|
+
return { ...(status ? { status } : {}), limit, offset }
|
|
56
|
+
}
|
|
57
|
+
async function page(client, input, options) {
|
|
58
|
+
const params = pageInput(options)
|
|
59
|
+
const result = await client.raw('/canvas/docs/' + segment(input.doc_id) + '/nodes/' + segment(input.node_id) +
|
|
60
|
+
'/history?' + new URLSearchParams(params))
|
|
61
|
+
if (result?.doc_id !== input.doc_id || result.node_id !== input.node_id || !Array.isArray(result.items) ||
|
|
62
|
+
typeof result.has_more !== 'boolean') throw new Error('节点历史响应绑定或分页信息不完整')
|
|
63
|
+
return { ...result, ...params }
|
|
64
|
+
}
|
|
65
|
+
export async function listNodeHistory(client, value, options = {}) {
|
|
66
|
+
const input = nodeHistoryInput(value, false)
|
|
67
|
+
pageInput(options)
|
|
68
|
+
await requireServiceReady(client)
|
|
69
|
+
const ctx=await context(client, input)
|
|
70
|
+
const copied=ctx.node.data?.extra?.copied_generation_history||ctx.node.data?.copiedGenerationHistory||[]
|
|
71
|
+
if(!Array.isArray(copied))throw new Error('复制来源历史无效')
|
|
72
|
+
return { copied_history: !options.status||options.status==='completed' ? structuredClone(copied) : [], copied_history_selectable:false, ...await page(client, input, options), read_only: true, canvas_written: false, generation_submitted: false }
|
|
73
|
+
}
|
|
74
|
+
// The history endpoint provides a fresh signed result. A result URL alone never
|
|
75
|
+
// selects a task and redraw/copied identifiers cannot enter ordinary history.
|
|
76
|
+
export async function selectedHistory(client, input, ctx) {
|
|
77
|
+
let item
|
|
78
|
+
for (let offset = 0; offset < 10000; offset += 100) {
|
|
79
|
+
const result = await page(client, input, { status: 'completed', limit: 100, offset })
|
|
80
|
+
const matches = result.items.filter(row => String(row.task_id) === input.task_id && !row.source)
|
|
81
|
+
if (matches.length > 1) throw new Error('历史任务ID不唯一')
|
|
82
|
+
if (matches.length === 1) { item = matches[0]; break }
|
|
83
|
+
if (!result.has_more) break
|
|
84
|
+
}
|
|
85
|
+
if (!item) throw fail('NODE_HISTORY_UNAVAILABLE', '未在该节点可见的已完成普通历史中找到原任务;不认领其他来源')
|
|
86
|
+
const result = item.result
|
|
87
|
+
if (item.doc_id !== input.doc_id || item.node_id !== input.node_id || item.segment_type !== ctx.node.type ||
|
|
88
|
+
item.status !== 'completed' || item.delivery_blocked || !item.completed_at ||
|
|
89
|
+
!result?.url || !result.file_ref || !String(result.mime_type).startsWith(ctx.node.type + '/'))
|
|
90
|
+
throw fail('NODE_HISTORY_UNAVAILABLE', '历史结果的归属、完成状态或真实媒体引用不完整')
|
|
91
|
+
const url = new URL(result.url, client.baseUrl)
|
|
92
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password ||
|
|
93
|
+
(url.protocol === 'http:' && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
|
|
94
|
+
throw new Error('历史结果地址不适用;不下载、不更换来源')
|
|
95
|
+
return item
|
|
96
|
+
}
|
|
97
|
+
const selected = selectedHistory
|
|
98
|
+
const resultIdentity = result => Object.fromEntries(Object.entries(result).filter(([key]) =>
|
|
99
|
+
!['url', 'file_ref', 'preview_url', 'preview_file_ref'].includes(key)))
|
|
100
|
+
function selectionIdentity(item) {
|
|
101
|
+
return { task_id: String(item.task_id), doc_id: item.doc_id, node_id: item.node_id,
|
|
102
|
+
segment_type: item.segment_type, completed_at: item.completed_at, prompt: item.prompt,
|
|
103
|
+
model_name: item.model_name, aspect_ratio: item.aspect_ratio || item.extra?.aspect_ratio,
|
|
104
|
+
resolution: item.resolution || item.extra?.resolution,
|
|
105
|
+
duration: item.duration, image_count: item.extra?.image_count || 1,
|
|
106
|
+
draft: item.draft || null, result: resultIdentity(item.result) }
|
|
107
|
+
}
|
|
108
|
+
function normalize(value) { return JSON.parse(JSON.stringify(value)) }
|
|
109
|
+
export function historyNodeData(node, item) {
|
|
110
|
+
const data = node.data || {}, extra = data.extra || {}, taskId = String(item.task_id)
|
|
111
|
+
const aspect = text(item.aspect_ratio || item.extra?.aspect_ratio) || data.aspect_ratio || extra.aspect_ratio
|
|
112
|
+
const resolution = text(item.resolution || item.extra?.resolution) || data.resolution || extra.resolution
|
|
113
|
+
const imageCount = Number(item.extra?.image_count) || 1
|
|
114
|
+
const next = { ...data, prompt: text(item.prompt) || data.prompt || '', model_name: text(item.model_name) || data.model_name,
|
|
115
|
+
status: 'completed', last_task_id: taskId, result: { ...structuredClone(item.result),
|
|
116
|
+
task_id: item.result.task_id || taskId, status: item.result.status || 'completed' },
|
|
117
|
+
extra: { ...extra, file_ref: item.result.file_ref,
|
|
118
|
+
...(aspect ? { aspect_ratio: aspect } : {}), ...(resolution ? { resolution } : {}),
|
|
119
|
+
...(node.type === 'image' ? { image_count: imageCount } : { draft: item.draft || null }) } }
|
|
120
|
+
// Keep API aliases already present consistent; do not invent reference or
|
|
121
|
+
// review records. The web selector changes output/config, not input references.
|
|
122
|
+
const aliases = { model: next.model_name, lastTaskId: taskId, resultUrl: item.result.url,
|
|
123
|
+
resultFileRef: item.result.file_ref, file_ref: item.result.file_ref, fileRef: item.result.file_ref,
|
|
124
|
+
aspectRatio: aspect, resolution, imageCount, aspect_ratio: aspect, image_count: imageCount }
|
|
125
|
+
for (const [key, value] of Object.entries(aliases)) if (Object.hasOwn(data, key)) next[key] = value
|
|
126
|
+
// The web serializer persists image_count inside extra only. Clear stale
|
|
127
|
+
// top-level aliases so subsequent reads do not mix two configurations.
|
|
128
|
+
if (node.type === 'image') { delete next.image_count; delete next.imageCount }
|
|
129
|
+
if (node.type === 'video') {
|
|
130
|
+
next.duration = Number(item.duration ?? item.result.duration) || data.duration
|
|
131
|
+
if (Object.hasOwn(data, 'draft')) next.draft = item.draft || null
|
|
132
|
+
}
|
|
133
|
+
if (object(data.runtime)) next.runtime = { ...data.runtime, status: 'completed', progress: 100, taskId }
|
|
134
|
+
return normalize(next)
|
|
135
|
+
}
|
|
136
|
+
function active(node) {
|
|
137
|
+
return [node.data?.status, node.data?.runtime?.status].some(status => ['queued', 'processing', 'retrying'].includes(status))
|
|
138
|
+
}
|
|
139
|
+
const summary = record => ({ operation: record.operation, state: record.state, task_id: record.input.task_id,
|
|
140
|
+
doc_id: record.input.doc_id, node_id: record.input.node_id, generation_submitted: false, library_saved: false,
|
|
141
|
+
canvas_written: false, selection_verified: record.state === 'applied',
|
|
142
|
+
...(record.diagnostic ? { diagnostic: record.diagnostic } : {}) })
|
|
143
|
+
async function bound(client, profile, operation) {
|
|
144
|
+
const record = await json(nodeHistoryPath(profile, operation), null)
|
|
145
|
+
if (!record || record.schema !== 1 || record.operation !== operation || record.profile !== profile || record.baseUrl !== client.baseUrl)
|
|
146
|
+
throw new Error('历史选择operation不存在或服务不匹配')
|
|
147
|
+
if (!object(record.nodeBefore) || !object(record.selectedIdentity) ||
|
|
148
|
+
(record.attempted && (!object(record.pendingNode) || record.pendingNode.id !== record.input?.node_id)))
|
|
149
|
+
throw new Error('历史选择operation记录不完整;停止,不重发写入')
|
|
150
|
+
const ctx = await context(client, nodeHistoryInput(record.input))
|
|
151
|
+
if (record.owner !== ctx.owner) throw new Error('历史选择账号不匹配')
|
|
152
|
+
return { record, ctx }
|
|
153
|
+
}
|
|
154
|
+
export async function selectNodeHistory(client, profile, value, operation, confirmed = false) {
|
|
155
|
+
const input = nodeHistoryInput(value), file = nodeHistoryPath(profile, operation)
|
|
156
|
+
await requireServiceReady(client)
|
|
157
|
+
const old = await json(file, null)
|
|
158
|
+
if (old?.attempted) {
|
|
159
|
+
if (!equal(old.input, input)) throw new Error('原历史选择输入不可修改')
|
|
160
|
+
return recoverNodeHistory(client, profile, operation)
|
|
161
|
+
}
|
|
162
|
+
const ctx = await context(client, input)
|
|
163
|
+
if (active(ctx.node)) throw new Error('目标节点有进行中任务;先查询原生成,不切换历史')
|
|
164
|
+
const item = await selected(client, input, ctx)
|
|
165
|
+
const identity = normalize(selectionIdentity(item))
|
|
166
|
+
let record = old
|
|
167
|
+
if (!confirmed) {
|
|
168
|
+
if (old && (old.owner !== ctx.owner || old.baseUrl !== client.baseUrl || !equal(old.input, input)))
|
|
169
|
+
throw new Error('历史选择operation已绑定其他输入、服务或账号')
|
|
170
|
+
record = { schema: 1, profile, operation, baseUrl: client.baseUrl, owner: ctx.owner, input,
|
|
171
|
+
nodeBefore: ctx.node, selectedIdentity: identity, selectedMedia: { url: item.result.url, file_ref: item.result.file_ref },
|
|
172
|
+
state: 'prepared', createdAt: old?.createdAt || new Date().toISOString() }
|
|
173
|
+
await persist(record)
|
|
174
|
+
return { ...summary(record), current_task_id: ctx.node.data?.last_task_id || null,
|
|
175
|
+
selection: { task_id: input.task_id, model_name: item.model_name, prompt: item.prompt,
|
|
176
|
+
result: item.result, aspect_ratio: item.aspect_ratio, resolution: item.resolution,
|
|
177
|
+
duration: item.duration, billing_status: item.billing_status, credit_cost: item.credit_cost },
|
|
178
|
+
input_references_changed: false,
|
|
179
|
+
next: '展示原结果→所选历史及配置差异;用户明确选择后同operation加--yes,仅切换普通节点,不生成或下载' }
|
|
180
|
+
}
|
|
181
|
+
const checked = await bound(client, profile, operation)
|
|
182
|
+
record = checked.record
|
|
183
|
+
if (!equal(record.input, input) || !equal(record.selectedIdentity, identity) || !equal(record.nodeBefore, checked.ctx.node))
|
|
184
|
+
throw new Error('预览后节点/历史结果变化;重新预览核对,不覆盖新内容')
|
|
185
|
+
if (!equal(record.selectedMedia, { url: item.result.url, file_ref: item.result.file_ref })) {
|
|
186
|
+
const same = checked.ctx.node.type === 'image' ? sameImageReferences : sameVideoReferences
|
|
187
|
+
if (!record.selectedMedia || !await same(client, record.selectedMedia.file_ref, item.result.file_ref))
|
|
188
|
+
throw new Error('更新的历史结果引用不能确认是原媒体;不切换节点')
|
|
189
|
+
}
|
|
190
|
+
const wanted = { ...checked.ctx.node, data: historyNodeData(checked.ctx.node, item) }
|
|
191
|
+
record.pendingNode = wanted
|
|
192
|
+
record.attempted = true
|
|
193
|
+
record.state = 'saving'
|
|
194
|
+
await persist(record)
|
|
195
|
+
const doc = checked.ctx.doc
|
|
196
|
+
if (equal(checked.ctx.node, wanted)) {
|
|
197
|
+
record.state = 'applied'
|
|
198
|
+
await persist(record)
|
|
199
|
+
return { ...summary(record), unchanged: true, version: doc.version }
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const saved = await client.raw('/canvas/docs/' + segment(doc.id), { method: 'PUT', body: {
|
|
203
|
+
version: doc.version, name: doc.name, viewport: doc.viewport, nodes: doc.nodes.map(node => node.id === wanted.id ? wanted : node),
|
|
204
|
+
edges: doc.edges, meta: doc.meta
|
|
205
|
+
} }, { retry: false })
|
|
206
|
+
if (saved?.version !== doc.version + 1 || !equal(saved.nodes?.find(node => node.id === wanted.id), wanted))
|
|
207
|
+
throw new Error('历史选择保存响应不完整')
|
|
208
|
+
record.state = 'applied'
|
|
209
|
+
await persist(record)
|
|
210
|
+
return { ...summary(record), canvas_written: true, version: saved.version }
|
|
211
|
+
} catch (error) {
|
|
212
|
+
record.state = error.status === 409 || error.apiCode === 3002 ? 'conflict' : 'save_unknown'
|
|
213
|
+
record.diagnostic = { ...(error.status ? { http_status: error.status } : {}),
|
|
214
|
+
...(error.apiCode ? { api_code: error.apiCode } : {}), ...(error.requestId ? { request_id: error.requestId } : {}) }
|
|
215
|
+
await persist(record)
|
|
216
|
+
throw fail('NODE_HISTORY_SAVE_PENDING', '历史选择保存未确认;history-recover只读核对,不重发保存或生成', error)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export async function recoverNodeHistory(client, profile, operation) {
|
|
220
|
+
await requireServiceReady(client)
|
|
221
|
+
const { record, ctx } = await bound(client, profile, operation)
|
|
222
|
+
if (!record.attempted) return summary(record)
|
|
223
|
+
const matches = equal(ctx.node, record.pendingNode)
|
|
224
|
+
if (matches) {
|
|
225
|
+
record.state = 'applied'
|
|
226
|
+
await persist(record)
|
|
227
|
+
}
|
|
228
|
+
return { ...summary(record), state: !matches && record.state === 'applied' ? 'canvas_changed' : record.state,
|
|
229
|
+
selection_verified: matches, canvas_matches_selection: matches, version: ctx.doc.version,
|
|
230
|
+
next: matches ? '原历史选择已核验,无重复写入' : '当前节点与原选择不同;保留当前内容,不自动恢复旧结果或重发保存' }
|
|
231
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { access } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { ApiClient, cancellation } from './api.js'
|
|
7
|
+
import { loginTarget } from './browser-login.js'
|
|
8
|
+
|
|
9
|
+
const connectPage = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Uzoo CLI 登录连接</title>
|
|
10
|
+
<style>body{font:18px/1.7 system-ui;max-width:760px;margin:60px auto;padding:24px;color:#192336}li{margin:18px 0}code{background:#eef1f6;padding:4px 8px}</style>
|
|
11
|
+
<h1>连接 Uzoo CLI</h1><p>请使用正常浏览器完成登录,再主动授权当前网页会话给本机 CLI。</p>
|
|
12
|
+
<ol><li>首次使用:在 Edge 的 <code>edge://extensions</code> 或 Chrome 的 <code>chrome://extensions</code> 中开启开发者模式,点击“加载解压缩的扩展”,选择项目中的 <code>extensions/uzoo-cli-login</code> 文件夹。</li>
|
|
13
|
+
<li>回到本页面,点击浏览器工具栏中的“Uzoo CLI Login”扩展,再点击“连接 CLI 并打开登录页”。</li>
|
|
14
|
+
<li>在网站正常完成登录,进入工作区后再点该扩展,点击“授权当前会话给 CLI”。</li></ol>
|
|
15
|
+
<p>CLI 将验证账号并加密保存凭据。只有你点击授权后,扩展才读取当前网站登录态。</p></html>`
|
|
16
|
+
|
|
17
|
+
export async function startLoginBridge({ target, timeout = 300000, signal = cancellation.signal }) {
|
|
18
|
+
const state = crypto.randomBytes(32).toString('base64url')
|
|
19
|
+
let resolveSession, rejectSession, consumed = false, closed = false, port, timer
|
|
20
|
+
const wait = new Promise((resolve, reject) => { resolveSession = resolve; rejectSession = reject })
|
|
21
|
+
void wait.catch(() => {})
|
|
22
|
+
const server = createServer(async (req, res) => {
|
|
23
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
24
|
+
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
25
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
26
|
+
const send = (status, data) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)) }
|
|
27
|
+
if (closed || req.headers.host !== `127.0.0.1:${port}`) { send(403, { error: 'HOST_REJECTED' }); return }
|
|
28
|
+
if (req.method === 'GET' && req.url === '/connect') {
|
|
29
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'")
|
|
30
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(connectPage); return
|
|
31
|
+
}
|
|
32
|
+
const origin = req.headers.origin || ''
|
|
33
|
+
if (!/^chrome-extension:\/\/[a-p]{32}$/.test(origin)) { send(403, { error: 'EXTENSION_ORIGIN_REQUIRED' }); return }
|
|
34
|
+
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
35
|
+
res.setHeader('Vary', 'Origin')
|
|
36
|
+
if (req.method === 'OPTIONS') {
|
|
37
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST')
|
|
38
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Uzoo-Bridge')
|
|
39
|
+
res.statusCode = 204; res.end(); return
|
|
40
|
+
}
|
|
41
|
+
const provided = Buffer.from(String(req.headers['x-uzoo-bridge'] || ''))
|
|
42
|
+
const expected = Buffer.from(state)
|
|
43
|
+
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) { send(403, { error: 'STATE_REJECTED' }); return }
|
|
44
|
+
if (consumed) { send(409, { error: 'ALREADY_USED' }); return }
|
|
45
|
+
if (['GET', 'POST'].includes(req.method) && req.url === '/config') {
|
|
46
|
+
send(200, { api: target.api, origin: target.origin, loginUrl: target.url }); return
|
|
47
|
+
}
|
|
48
|
+
if (req.method !== 'POST' || req.url !== '/session') { send(404, { error: 'NOT_FOUND' }); return }
|
|
49
|
+
if (!String(req.headers['content-type'] || '').toLowerCase().startsWith('application/json')) { send(415, { error: 'JSON_REQUIRED' }); return }
|
|
50
|
+
try {
|
|
51
|
+
const chunks = []; let size = 0
|
|
52
|
+
for await (const chunk of req) { size += chunk.length; if (size > 65536) { send(413, { error: 'TOO_LARGE' }); return }; chunks.push(chunk) }
|
|
53
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
54
|
+
const valid = (v, limit, required = false) => typeof v === 'string' && v.length <= limit && (!required || v.trim()) && !/[\r\n\0]/.test(v)
|
|
55
|
+
if (value.origin !== target.origin || !valid(value.accessToken, 16384, true) || !valid(value.cookie, 32768) || !valid(value.deviceId, 256)) { send(400, { error: 'INVALID_SESSION' }); return }
|
|
56
|
+
if (consumed) { send(409, { error: 'ALREADY_USED' }); return }
|
|
57
|
+
consumed = true
|
|
58
|
+
send(200, { received: true, note: '请查看 CLI 中的账号验证结果' })
|
|
59
|
+
resolveSession({ baseUrl: target.api, accessToken: value.accessToken, cookie: value.cookie, deviceId: value.deviceId })
|
|
60
|
+
} catch { if (!res.writableEnded) send(400, { error: 'INVALID_JSON' }) }
|
|
61
|
+
})
|
|
62
|
+
server.requestTimeout = 10000
|
|
63
|
+
server.headersTimeout = 10000
|
|
64
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
|
65
|
+
port = server.address().port
|
|
66
|
+
const abort = () => { const e = new Error('登录已取消'); e.name = 'AbortError'; rejectSession(e) }
|
|
67
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
68
|
+
if (signal.aborted) abort()
|
|
69
|
+
timer = setTimeout(() => { const e = new Error('等待浏览器扩展授权超时;重新运行 auth login 即可'); e.code = 'WAIT_TIMEOUT'; rejectSession(e) }, timeout)
|
|
70
|
+
return {
|
|
71
|
+
url: `http://127.0.0.1:${port}/connect#${state}`,
|
|
72
|
+
wait,
|
|
73
|
+
close: async () => {
|
|
74
|
+
if (closed) return
|
|
75
|
+
closed = true; clearTimeout(timer); signal.removeEventListener('abort', abort)
|
|
76
|
+
server.closeAllConnections()
|
|
77
|
+
await new Promise(resolve => server.close(resolve))
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function openLoginBrowser(url, browser = 'default') {
|
|
83
|
+
let exe, args
|
|
84
|
+
if (!['default', 'msedge', 'chrome'].includes(browser)) throw new Error('--browser 必须是 default、msedge 或 chrome')
|
|
85
|
+
if (process.platform === 'win32') {
|
|
86
|
+
if (browser === 'default') { exe = 'rundll32.exe'; args = ['url.dll,FileProtocolHandler', url] }
|
|
87
|
+
else {
|
|
88
|
+
const tail = browser === 'msedge' ? 'Microsoft/Edge/Application/msedge.exe' : 'Google/Chrome/Application/chrome.exe'
|
|
89
|
+
const candidates = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean).map(root => path.join(root, tail))
|
|
90
|
+
for (const candidate of candidates) { try { await access(candidate); exe = candidate; break } catch { /* next installation location */ } }
|
|
91
|
+
if (!exe) throw new Error('找不到指定浏览器,请使用 --browser default')
|
|
92
|
+
args = [url]
|
|
93
|
+
}
|
|
94
|
+
} else if (process.platform === 'darwin') { exe = 'open'; args = browser === 'default' ? [url] : ['-a', browser === 'chrome' ? 'Google Chrome' : 'Microsoft Edge', url] }
|
|
95
|
+
else { exe = browser === 'default' ? 'xdg-open' : browser === 'chrome' ? 'google-chrome' : 'microsoft-edge'; args = [url] }
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
const child = spawn(exe, args, { windowsHide: true, detached: true, stdio: 'ignore' })
|
|
98
|
+
child.once('error', () => reject(new Error('无法打开普通浏览器,请手工打开 CLI 打印的本机连接链接')))
|
|
99
|
+
child.once('spawn', () => { child.unref(); resolve() })
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function normalBrowserLogin({ profile, store, webUrl, browser = 'default', timeout = 300000, requestTimeout = 30000, openUrl = openLoginBrowser, onReady = () => {}, onOpenError = () => {} }) {
|
|
104
|
+
if (!['default', 'msedge', 'chrome'].includes(browser)) throw new Error('--browser 必须是 default、msedge 或 chrome')
|
|
105
|
+
const target = loginTarget(profile.baseUrl, webUrl)
|
|
106
|
+
const bridge = await startLoginBridge({ target, timeout })
|
|
107
|
+
let session
|
|
108
|
+
try {
|
|
109
|
+
onReady(bridge.url)
|
|
110
|
+
try { await openUrl(bridge.url, browser) } catch (error) { onOpenError(error.message) }
|
|
111
|
+
session = await bridge.wait
|
|
112
|
+
} finally { await bridge.close() }
|
|
113
|
+
const client = new ApiClient(session, { save: async () => {} }, requestTimeout)
|
|
114
|
+
let identity
|
|
115
|
+
try { identity = await client.raw('/auth/me') } catch { throw new Error('网页会话已收到,但 API 身份验证失败;原 CLI 凭据未替换,请重新登录授权') }
|
|
116
|
+
await store.save(client)
|
|
117
|
+
return { profile: store.name, loggedIn: true, account: identity, mode: 'browser-extension', hasRefreshCookie: /(?:^|;\s*)refresh_token=([^;]+)/.test(client.cookie || ''), note: '会话已加密保存;后续通过现有刷新接口续期。请关闭使用该会话的网页,避免与 CLI 同时刷新;建议使用独立的普通浏览器用户配置文件' }
|
|
118
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {fileURLToPath} from 'node:url'
|
|
4
|
+
|
|
5
|
+
const metadata=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8'))
|
|
6
|
+
export const packageInfo=Object.freeze({name:metadata.name,version:metadata.version,node_required:metadata.engines.node})
|
|
7
|
+
export const skillEntry=fileURLToPath(new URL('../.agents/skills/uzoo-anime-cli/SKILL.md',import.meta.url))
|
|
8
|
+
export const skillDirectory=path.dirname(skillEntry)
|
package/src/png-alpha.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { inflateSync } from 'node:zlib'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
function crc32(bytes) {
|
|
5
|
+
let c = 0xffffffff
|
|
6
|
+
for (const byte of bytes) { c ^= byte; for (let i=0;i<8;i++) c = (c>>>1) ^ ((c&1)?0xedb88320:0) }
|
|
7
|
+
return (c ^ 0xffffffff) >>> 0
|
|
8
|
+
}
|
|
9
|
+
// Decode only the server's non-interlaced 8-bit RGBA PNG contract. No local
|
|
10
|
+
// inference/renderer dependency; unsupported encodings remain cached for review.
|
|
11
|
+
export function inspectAlphaPng(bytes) {
|
|
12
|
+
if (!Buffer.isBuffer(bytes) || !bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) throw Error('结果不是PNG')
|
|
13
|
+
let offset=8,width,height,header=false,ended=false,dataStarted=false,dataEnded=false
|
|
14
|
+
const chunks=[]
|
|
15
|
+
while(offset<bytes.length){
|
|
16
|
+
if(offset+12>bytes.length)throw Error('PNG块截断')
|
|
17
|
+
const size=bytes.readUInt32BE(offset),end=offset+12+size
|
|
18
|
+
if(end>bytes.length)throw Error('PNG块截断')
|
|
19
|
+
const type=bytes.toString('ascii',offset+4,offset+8),body=bytes.subarray(offset+8,end-4)
|
|
20
|
+
if(crc32(bytes.subarray(offset+4,end-4))!==bytes.readUInt32BE(end-4))throw Error('PNG校验失败')
|
|
21
|
+
if(!header&&type!=='IHDR')throw Error('PNG缺少起始IHDR')
|
|
22
|
+
if(type==='IHDR'){
|
|
23
|
+
if(header||size!==13)throw Error('PNG重复或无效IHDR')
|
|
24
|
+
header=true;width=body.readUInt32BE(0);height=body.readUInt32BE(4)
|
|
25
|
+
if(!width||!height||width*height>64000000||body[8]!==8||body[9]!==6||body[10]||body[11]||body[12])throw Error('仅接入6400万像素内非交错8位RGBA PNG')
|
|
26
|
+
} else if(type==='IDAT'){
|
|
27
|
+
if(dataEnded)throw Error('PNG IDAT不连续')
|
|
28
|
+
dataStarted=true;chunks.push(body)
|
|
29
|
+
} else {
|
|
30
|
+
if(dataStarted)dataEnded=true
|
|
31
|
+
if(type==='IEND') {if(size||!dataStarted||end!==bytes.length)throw Error('PNG结束无效');ended=true;break}
|
|
32
|
+
if(['acTL','fcTL','fdAT'].includes(type))throw Error('不接受动画PNG')
|
|
33
|
+
if(type[0]===type[0].toUpperCase())throw Error('PNG存在未支持的关键块')
|
|
34
|
+
}
|
|
35
|
+
offset=end
|
|
36
|
+
}
|
|
37
|
+
if(!ended)throw Error('PNG缺少IEND')
|
|
38
|
+
const stride=width*4,expected=(stride+1)*height
|
|
39
|
+
const raw=inflateSync(Buffer.concat(chunks),{maxOutputLength:expected})
|
|
40
|
+
if(raw.length!==expected)throw Error('PNG像素数据长度不符')
|
|
41
|
+
let previous=Buffer.alloc(stride),min=255,max=0,transparent=0,visible=0,partial=0
|
|
42
|
+
for(let y=0;y<height;y++){
|
|
43
|
+
const filter=raw[y*(stride+1)],row=Buffer.from(raw.subarray(y*(stride+1)+1,(y+1)*(stride+1)))
|
|
44
|
+
if(filter>4)throw Error('PNG滤波器无效')
|
|
45
|
+
for(let x=0;x<stride;x++){
|
|
46
|
+
const a=x>=4?row[x-4]:0,b=previous[x],c=x>=4?previous[x-4]:0
|
|
47
|
+
let predictor=0
|
|
48
|
+
if(filter===1)predictor=a
|
|
49
|
+
if(filter===2)predictor=b
|
|
50
|
+
if(filter===3)predictor=Math.floor((a+b)/2)
|
|
51
|
+
if(filter===4){const p=a+b-c,pa=Math.abs(p-a),pb=Math.abs(p-b),pc=Math.abs(p-c);predictor=pa<=pb&&pa<=pc?a:pb<=pc?b:c}
|
|
52
|
+
row[x]=(row[x]+predictor)&255
|
|
53
|
+
}
|
|
54
|
+
for(let x=3;x<stride;x+=4){const a=row[x];min=Math.min(min,a);max=Math.max(max,a);if(!a)transparent++;if(a)visible++;if(a>0&&a<255)partial++}
|
|
55
|
+
previous=row
|
|
56
|
+
}
|
|
57
|
+
if(!transparent||!visible)throw Error('PNG缺少透明背景或有效前景;保留原结果,不重复抠图')
|
|
58
|
+
return {width,height,bytes:bytes.length,sha256:crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
59
|
+
alpha:{min,max,transparent_pixels:transparent,visible_pixels:visible,partial_pixels:partial}}
|
|
60
|
+
}
|