@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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { mediaTools, runMedia, probeMedia, hashFile } from './media.js'
|
|
4
|
+
import { cropGeometry } from './image-media.js'
|
|
5
|
+
|
|
6
|
+
const finite = (value) => typeof value === 'number' && Number.isFinite(value)
|
|
7
|
+
const keys = {
|
|
8
|
+
rect: ['x', 'y', 'width', 'height', 'strokeWidth'],
|
|
9
|
+
ellipse: ['x', 'y', 'width', 'height', 'strokeWidth'],
|
|
10
|
+
line: ['x', 'y', 'x2', 'y2', 'strokeWidth'],
|
|
11
|
+
brush: ['points', 'strokeWidth'],
|
|
12
|
+
text: ['x', 'y', 'text', 'fontSize']
|
|
13
|
+
}
|
|
14
|
+
export function annotationCommands(commands) {
|
|
15
|
+
if (!Array.isArray(commands) || !commands.length || commands.length > 100)
|
|
16
|
+
throw new Error('标注须为1–100条指令')
|
|
17
|
+
let points = 0
|
|
18
|
+
return commands.map((command) => {
|
|
19
|
+
if (
|
|
20
|
+
!command ||
|
|
21
|
+
typeof command !== 'object' ||
|
|
22
|
+
Array.isArray(command) ||
|
|
23
|
+
!keys[command.type] ||
|
|
24
|
+
Object.keys(command).some(
|
|
25
|
+
(k) => !['type', 'color', 'rotation', ...keys[command.type]].includes(k)
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
throw new Error('标注类型/字段未接入')
|
|
29
|
+
const result = {
|
|
30
|
+
...command,
|
|
31
|
+
color: command.color ?? '#ff4d4f',
|
|
32
|
+
rotation: command.rotation ?? 0
|
|
33
|
+
}
|
|
34
|
+
if (
|
|
35
|
+
!/^#[a-fA-F0-9]{6}(?:[a-fA-F0-9]{2})?$/.test(result.color) ||
|
|
36
|
+
!finite(result.rotation) ||
|
|
37
|
+
Math.abs(result.rotation) > 360
|
|
38
|
+
)
|
|
39
|
+
throw new Error('标注颜色须为#RRGGBB或#RRGGBBAA,rotation为-360至360度')
|
|
40
|
+
if (command.type === 'text') {
|
|
41
|
+
result.fontSize = command.fontSize ?? 16
|
|
42
|
+
if (
|
|
43
|
+
typeof command.text !== 'string' ||
|
|
44
|
+
!command.text.trim() ||
|
|
45
|
+
command.text.length > 2000 ||
|
|
46
|
+
/[\x00-\x1f\x7f]/.test(command.text) ||
|
|
47
|
+
!finite(result.fontSize) ||
|
|
48
|
+
result.fontSize < 1 ||
|
|
49
|
+
result.fontSize > 1024
|
|
50
|
+
)
|
|
51
|
+
throw new Error('文字标注须为非空单行文本,fontSize为1–1024原图像素')
|
|
52
|
+
if (result.rotation !== 0) throw new Error('文字旋转尚未接入;可用未旋转文字,图形旋转已接入')
|
|
53
|
+
} else {
|
|
54
|
+
result.strokeWidth = command.strokeWidth ?? 1
|
|
55
|
+
if (!finite(result.strokeWidth) || result.strokeWidth < 1 || result.strokeWidth > 512)
|
|
56
|
+
throw new Error('strokeWidth为1–512原图像素')
|
|
57
|
+
}
|
|
58
|
+
if (command.type === 'brush') {
|
|
59
|
+
if (
|
|
60
|
+
!Array.isArray(command.points) ||
|
|
61
|
+
command.points.length < 2 ||
|
|
62
|
+
command.points.some(
|
|
63
|
+
(p) =>
|
|
64
|
+
!p ||
|
|
65
|
+
typeof p !== 'object' ||
|
|
66
|
+
Array.isArray(p) ||
|
|
67
|
+
Object.keys(p).some((k) => !['x', 'y'].includes(k)) ||
|
|
68
|
+
!finite(p.x) ||
|
|
69
|
+
!finite(p.y)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
throw new Error('画笔需要至少2个有效{x,y}点')
|
|
73
|
+
points += command.points.length
|
|
74
|
+
if (points > 20000) throw new Error('本次画笔总点数超过20000')
|
|
75
|
+
result.points = command.points.map((p) => ({ x: p.x, y: p.y }))
|
|
76
|
+
} else {
|
|
77
|
+
const fields =
|
|
78
|
+
command.type === 'line'
|
|
79
|
+
? ['x', 'y', 'x2', 'y2']
|
|
80
|
+
: ['x', 'y', ...(['rect', 'ellipse'].includes(command.type) ? ['width', 'height'] : [])]
|
|
81
|
+
if (fields.some((k) => !finite(command[k]))) throw new Error('标注坐标须为有限原图像素数值')
|
|
82
|
+
if (['rect', 'ellipse'].includes(command.type)) {
|
|
83
|
+
if (!command.width || !command.height) throw new Error('矩形/椭圆尺寸不能为0')
|
|
84
|
+
result.x = Math.min(command.x, command.x + command.width)
|
|
85
|
+
result.y = Math.min(command.y, command.y + command.height)
|
|
86
|
+
result.width = Math.abs(command.width)
|
|
87
|
+
result.height = Math.abs(command.height)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
export function annotationGeometry(source, commands) {
|
|
94
|
+
const geometry = cropGeometry(source, {
|
|
95
|
+
rect: { left: 0, top: 0, width: source.width, height: source.height }
|
|
96
|
+
})
|
|
97
|
+
const inside = (x, y) => x >= 0 && y >= 0 && x <= source.width && y <= source.height
|
|
98
|
+
for (const command of commands) {
|
|
99
|
+
const positions =
|
|
100
|
+
command.type === 'brush'
|
|
101
|
+
? command.points
|
|
102
|
+
: command.type === 'line'
|
|
103
|
+
? [
|
|
104
|
+
{ x: command.x, y: command.y },
|
|
105
|
+
{ x: command.x2, y: command.y2 }
|
|
106
|
+
]
|
|
107
|
+
: [
|
|
108
|
+
{ x: command.x, y: command.y },
|
|
109
|
+
...(['rect', 'ellipse'].includes(command.type)
|
|
110
|
+
? [{ x: command.x + command.width, y: command.y + command.height }]
|
|
111
|
+
: [])
|
|
112
|
+
]
|
|
113
|
+
if (positions.some((p) => !inside(p.x, p.y)))
|
|
114
|
+
throw new Error('标注区域超出原图;坐标使用原图像素')
|
|
115
|
+
}
|
|
116
|
+
return { source: geometry.source, output: geometry.output }
|
|
117
|
+
}
|
|
118
|
+
async function verifyFontGlyphs(file, text) {
|
|
119
|
+
const stat = await fs.stat(file)
|
|
120
|
+
if (stat.size > 100 * 1024 * 1024) throw new Error('字体超过可核验大小')
|
|
121
|
+
const data = await fs.readFile(file)
|
|
122
|
+
const u16 = (n) => data.readUInt16BE(n),
|
|
123
|
+
u32 = (n) => data.readUInt32BE(n)
|
|
124
|
+
const face = data.toString('ascii', 0, 4) === 'ttcf' ? u32(12) : 0
|
|
125
|
+
let cmap = -1
|
|
126
|
+
for (let i = 0; i < u16(face + 4); i++) {
|
|
127
|
+
const entry = face + 12 + i * 16
|
|
128
|
+
if (data.toString('ascii', entry, entry + 4) === 'cmap') {
|
|
129
|
+
cmap = u32(entry + 8)
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (cmap < 0) throw new Error('字体没有可核验的Unicode字符映射')
|
|
134
|
+
const tables = []
|
|
135
|
+
for (let i = 0; i < u16(cmap + 2); i++) {
|
|
136
|
+
const entry = cmap + 4 + i * 8,
|
|
137
|
+
platform = u16(entry),
|
|
138
|
+
encoding = u16(entry + 2),
|
|
139
|
+
offset = cmap + u32(entry + 4)
|
|
140
|
+
if (platform === 0 || (platform === 3 && [1, 10].includes(encoding))) {
|
|
141
|
+
const format = u16(offset)
|
|
142
|
+
if ([4, 12].includes(format)) tables.push({ offset, format })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!tables.length) throw new Error('字体Unicode映射格式尚不能核验')
|
|
146
|
+
const contains = (point) =>
|
|
147
|
+
tables.some(({ offset, format }) => {
|
|
148
|
+
if (format === 12) {
|
|
149
|
+
for (let i = 0; i < u32(offset + 12); i++) {
|
|
150
|
+
const group = offset + 16 + i * 12,
|
|
151
|
+
start = u32(group),
|
|
152
|
+
end = u32(group + 4)
|
|
153
|
+
if (point >= start && point <= end) return u32(group + 8) + (point - start) !== 0
|
|
154
|
+
}
|
|
155
|
+
} else if (point <= 65535) {
|
|
156
|
+
const count = u16(offset + 6) / 2,
|
|
157
|
+
endBase = offset + 14,
|
|
158
|
+
startBase = endBase + count * 2 + 2,
|
|
159
|
+
deltaBase = startBase + count * 2,
|
|
160
|
+
rangeBase = deltaBase + count * 2
|
|
161
|
+
for (let i = 0; i < count; i++) {
|
|
162
|
+
const start = u16(startBase + i * 2),
|
|
163
|
+
end = u16(endBase + i * 2)
|
|
164
|
+
if (point >= start && point <= end) {
|
|
165
|
+
const range = u16(rangeBase + i * 2),
|
|
166
|
+
delta = u16(deltaBase + i * 2)
|
|
167
|
+
if (!range) return ((point + delta) & 65535) !== 0
|
|
168
|
+
const glyph = u16(rangeBase + i * 2 + range + (point - start) * 2)
|
|
169
|
+
return glyph !== 0 && ((glyph + delta) & 65535) !== 0
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false
|
|
174
|
+
})
|
|
175
|
+
const missing = [...new Set([...text].map((c) => c.codePointAt(0)))].filter((c) => !contains(c))
|
|
176
|
+
if (missing.length) throw new Error('所选字体缺少本次文字字形')
|
|
177
|
+
}
|
|
178
|
+
export async function annotationTools(commands, options = {}) {
|
|
179
|
+
const tools = await mediaTools(options)
|
|
180
|
+
const filters = (
|
|
181
|
+
await runMedia(tools.ffmpeg, ['-hide_banner', '-filters'], { maxBytes: 1024 * 1024 })
|
|
182
|
+
).toString()
|
|
183
|
+
const required = [
|
|
184
|
+
...(commands.some((c) => c.type !== 'text') ? ['drawvg'] : []),
|
|
185
|
+
...(commands.some((c) => c.type === 'text') ? ['drawtext'] : [])
|
|
186
|
+
]
|
|
187
|
+
const missing = required.filter((name) => !new RegExp('\\b' + name + '\\s').test(filters))
|
|
188
|
+
if (missing.length)
|
|
189
|
+
throw Object.assign(
|
|
190
|
+
new Error(
|
|
191
|
+
'现有FFmpeg缺少标注能力 ' +
|
|
192
|
+
missing.join('、') +
|
|
193
|
+
';请先说明兼容版本/构建方案并询问是否更新,或提供已有兼容工具路径'
|
|
194
|
+
),
|
|
195
|
+
{
|
|
196
|
+
code: 'MEDIA_UNAVAILABLE',
|
|
197
|
+
dependency: {
|
|
198
|
+
status: 'dependency_unavailable',
|
|
199
|
+
package: 'FFmpeg',
|
|
200
|
+
required_tools: ['ffmpeg', 'ffprobe'],
|
|
201
|
+
required_capabilities: required,
|
|
202
|
+
missing_capabilities: missing,
|
|
203
|
+
installation_requires_confirmation: true,
|
|
204
|
+
installation_performed: false,
|
|
205
|
+
next: 'ask_user_to_update_or_provide_compatible_tools'
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
if (commands.some((c) => c.type === 'text')) {
|
|
210
|
+
const fontFile =
|
|
211
|
+
options.fontFile ||
|
|
212
|
+
(process.platform === 'win32'
|
|
213
|
+
? path.join(process.env.WINDIR || 'C:\\Windows', 'Fonts', 'msyh.ttc')
|
|
214
|
+
: '')
|
|
215
|
+
try {
|
|
216
|
+
if (!fontFile) throw new Error('missing')
|
|
217
|
+
tools.fontFile = path.resolve(fontFile)
|
|
218
|
+
tools.fontHash = await hashFile(tools.fontFile)
|
|
219
|
+
} catch {
|
|
220
|
+
throw Object.assign(
|
|
221
|
+
new Error(
|
|
222
|
+
'文字标注需要可用字体文件;中文建议提供已安装的中文字体路径。缺失时先询问安装,不能用缺字方框冒充成功'
|
|
223
|
+
),
|
|
224
|
+
{
|
|
225
|
+
code: 'MEDIA_UNAVAILABLE',
|
|
226
|
+
dependency: {
|
|
227
|
+
status: 'awaiting_dependency_installation',
|
|
228
|
+
package: 'CJK font',
|
|
229
|
+
required_fonts: ['中文字体文件'],
|
|
230
|
+
installation_requires_confirmation: true,
|
|
231
|
+
installation_performed: false,
|
|
232
|
+
next: 'ask_user_to_install_font_or_provide_path'
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await verifyFontGlyphs(
|
|
239
|
+
tools.fontFile,
|
|
240
|
+
commands
|
|
241
|
+
.filter((c) => c.type === 'text')
|
|
242
|
+
.map((c) => c.text)
|
|
243
|
+
.join('')
|
|
244
|
+
)
|
|
245
|
+
} catch {
|
|
246
|
+
throw Object.assign(
|
|
247
|
+
new Error(
|
|
248
|
+
'所选字体不能完整核验/显示本次文字,请提供包含所需字形的Unicode字体;不以缺字方框继续'
|
|
249
|
+
),
|
|
250
|
+
{
|
|
251
|
+
code: 'MEDIA_UNAVAILABLE',
|
|
252
|
+
dependency: {
|
|
253
|
+
status: 'dependency_unavailable',
|
|
254
|
+
package: 'font',
|
|
255
|
+
required_fonts: ['包含本次文字字形的Unicode字体'],
|
|
256
|
+
installation_requires_confirmation: true,
|
|
257
|
+
installation_performed: false,
|
|
258
|
+
next: 'ask_user_to_choose_font_or_confirm_installation'
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return tools
|
|
265
|
+
}
|
|
266
|
+
export function vectorScript(command, source, output) {
|
|
267
|
+
const color = command.color.slice(1),
|
|
268
|
+
rgba = [0, 2, 4].map((i) => parseInt(color.slice(i, i + 2), 16) / 255)
|
|
269
|
+
rgba.push(color.length === 8 ? parseInt(color.slice(6), 16) / 255 : 1)
|
|
270
|
+
const script = [
|
|
271
|
+
`scalexy ${output.width / source.width} ${output.height / source.height}`,
|
|
272
|
+
`setrgba ${rgba.join(' ')}`,
|
|
273
|
+
`setlinewidth ${command.strokeWidth}`,
|
|
274
|
+
'setlinecap round',
|
|
275
|
+
'setlinejoin round'
|
|
276
|
+
]
|
|
277
|
+
let center
|
|
278
|
+
if (command.type === 'line')
|
|
279
|
+
center = { x: (command.x + command.x2) / 2, y: (command.y + command.y2) / 2 }
|
|
280
|
+
else if (command.type === 'brush') {
|
|
281
|
+
const xs = command.points.map((p) => p.x),
|
|
282
|
+
ys = command.points.map((p) => p.y)
|
|
283
|
+
center = {
|
|
284
|
+
x: (Math.min(...xs) + Math.max(...xs)) / 2,
|
|
285
|
+
y: (Math.min(...ys) + Math.max(...ys)) / 2
|
|
286
|
+
}
|
|
287
|
+
} else center = { x: command.x + command.width / 2, y: command.y + command.height / 2 }
|
|
288
|
+
if (command.rotation)
|
|
289
|
+
script.push(
|
|
290
|
+
`translate ${center.x} ${center.y}`,
|
|
291
|
+
`rotate ${command.rotation}`,
|
|
292
|
+
`translate ${-center.x} ${-center.y}`
|
|
293
|
+
)
|
|
294
|
+
if (command.type === 'rect')
|
|
295
|
+
script.push(`rect ${command.x} ${command.y} ${command.width} ${command.height}`)
|
|
296
|
+
else if (command.type === 'ellipse')
|
|
297
|
+
script.push(`ellipse ${center.x} ${center.y} ${command.width / 2} ${command.height / 2}`)
|
|
298
|
+
else {
|
|
299
|
+
const points =
|
|
300
|
+
command.type === 'line'
|
|
301
|
+
? [
|
|
302
|
+
{ x: command.x, y: command.y },
|
|
303
|
+
{ x: command.x2, y: command.y2 }
|
|
304
|
+
]
|
|
305
|
+
: command.points
|
|
306
|
+
script.push(
|
|
307
|
+
`moveto ${points[0].x} ${points[0].y}`,
|
|
308
|
+
...points.slice(1).map((p) => `lineto ${p.x} ${p.y}`)
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
script.push('stroke')
|
|
312
|
+
return script.join('\n') + '\n'
|
|
313
|
+
}
|
|
314
|
+
export async function annotateImage(source, target, geometry, commands, tools) {
|
|
315
|
+
const actual = await probeMedia(source, tools)
|
|
316
|
+
if (!['png', 'mjpeg'].includes(actual.codec) || actual.rotation || actual.orientation > 1)
|
|
317
|
+
throw new Error('当前标注仅接入静态PNG/JPEG,方向元数据/其他格式待验证')
|
|
318
|
+
if (actual.width !== geometry.source.width || actual.height !== geometry.source.height)
|
|
319
|
+
throw new Error('标注实际原图尺寸与元数据不一致')
|
|
320
|
+
const directory = path.dirname(target),
|
|
321
|
+
filters = [
|
|
322
|
+
`scale=${geometry.output.width}:${geometry.output.height}:flags=lanczos`,
|
|
323
|
+
'format=bgra'
|
|
324
|
+
]
|
|
325
|
+
if (tools.fontFile) {
|
|
326
|
+
const hash = await hashFile(tools.fontFile)
|
|
327
|
+
if (hash.sha256 !== tools.fontHash.sha256) throw new Error('本次文字字体文件发生变化')
|
|
328
|
+
await fs.copyFile(tools.fontFile, path.join(directory, 'ann-font.bin'))
|
|
329
|
+
}
|
|
330
|
+
for (let i = 0; i < commands.length; i++) {
|
|
331
|
+
const command = commands[i]
|
|
332
|
+
if (command.type === 'text') {
|
|
333
|
+
await fs.writeFile(path.join(directory, `ann-text-${i}.txt`), command.text, {
|
|
334
|
+
encoding: 'utf8'
|
|
335
|
+
})
|
|
336
|
+
const size = (command.fontSize * geometry.output.height) / geometry.source.height
|
|
337
|
+
const x = (command.x * geometry.output.width) / geometry.source.width,
|
|
338
|
+
y = (command.y * geometry.output.height) / geometry.source.height
|
|
339
|
+
filters.push(
|
|
340
|
+
`drawtext=fontfile=ann-font.bin:textfile=ann-text-${i}.txt:expansion=none:fontcolor=0x${command.color.slice(1)}:fontsize=${size}:x=${x}:y=${y}+${size}-ascent`
|
|
341
|
+
)
|
|
342
|
+
} else {
|
|
343
|
+
await fs.writeFile(
|
|
344
|
+
path.join(directory, `ann-vector-${i}.vgs`),
|
|
345
|
+
vectorScript(command, geometry.source, geometry.output),
|
|
346
|
+
{ encoding: 'utf8' }
|
|
347
|
+
)
|
|
348
|
+
filters.push(`drawvg=file=ann-vector-${i}.vgs`)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
filters.push('format=rgba')
|
|
352
|
+
await fs.writeFile(path.join(directory, 'ann-filter.txt'), filters.join(','), {
|
|
353
|
+
encoding: 'utf8'
|
|
354
|
+
})
|
|
355
|
+
await runMedia(
|
|
356
|
+
tools.ffmpeg,
|
|
357
|
+
[
|
|
358
|
+
'-nostdin',
|
|
359
|
+
'-v',
|
|
360
|
+
'error',
|
|
361
|
+
'-protocol_whitelist',
|
|
362
|
+
'file,pipe',
|
|
363
|
+
'-i',
|
|
364
|
+
source,
|
|
365
|
+
'-map',
|
|
366
|
+
'0:v:0',
|
|
367
|
+
'-filter_script:v',
|
|
368
|
+
'ann-filter.txt',
|
|
369
|
+
'-frames:v',
|
|
370
|
+
'1',
|
|
371
|
+
'-c:v',
|
|
372
|
+
'png',
|
|
373
|
+
'-n',
|
|
374
|
+
target
|
|
375
|
+
],
|
|
376
|
+
{ cwd: directory }
|
|
377
|
+
)
|
|
378
|
+
const image = await probeMedia(target, tools)
|
|
379
|
+
if (
|
|
380
|
+
image.codec !== 'png' ||
|
|
381
|
+
image.width !== geometry.output.width ||
|
|
382
|
+
image.height !== geometry.output.height
|
|
383
|
+
)
|
|
384
|
+
throw new Error('标注PNG导出尺寸不一致')
|
|
385
|
+
return { width: image.width, height: image.height, ...(await hashFile(target)) }
|
|
386
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { signRequest, bodyBytes, unwrap, getSetCookie, mergeCookie } from './protocol.js'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import { CLIENT_ID, e1Request } from './cli-authorization.js'
|
|
4
|
+
|
|
5
|
+
export const cancellation = new AbortController()
|
|
6
|
+
|
|
7
|
+
export function baseUrl(value = 'http://localhost:8000/api/v1') {
|
|
8
|
+
const url = new URL(value)
|
|
9
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('API URL 无效')
|
|
10
|
+
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('远程 API 必须使用 HTTPS')
|
|
11
|
+
return url.toString().replace(/\/+$/, '').replace(/(?:\/api\/v1)?$/, '/api/v1')
|
|
12
|
+
}
|
|
13
|
+
function resourceUrl(url) {
|
|
14
|
+
const u = new URL(url)
|
|
15
|
+
if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password) throw new Error('资源 URL 无效')
|
|
16
|
+
if (u.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) throw new Error('远程资源必须使用 HTTPS')
|
|
17
|
+
return u
|
|
18
|
+
}
|
|
19
|
+
export async function request(url, options = {}, timeout = 30000) {
|
|
20
|
+
return fetch(resourceUrl(url), { ...options, redirect: 'error', signal: AbortSignal.any([cancellation.signal, options.signal || AbortSignal.timeout(timeout)]) })
|
|
21
|
+
}
|
|
22
|
+
// Only anonymous file GETs may follow the backend's signed-reference renewal.
|
|
23
|
+
// API/auth requests and uploads retain the strict no-redirect contract above.
|
|
24
|
+
export async function requestFile(url, timeout = 30000) {
|
|
25
|
+
const signal = AbortSignal.any([cancellation.signal, AbortSignal.timeout(timeout)])
|
|
26
|
+
let current = resourceUrl(url)
|
|
27
|
+
for (let redirects = 0; ; redirects++) {
|
|
28
|
+
const response = await fetch(current, { method: 'GET', redirect: 'manual', credentials: 'omit', signal })
|
|
29
|
+
if (response.status === 307) {
|
|
30
|
+
const location = response.headers.get('location')
|
|
31
|
+
await response.body?.cancel()
|
|
32
|
+
if (!location || redirects >= 5) throw new Error('文件引用续期跳转缺少地址或超过5次;重新从有权限的业务下载接口获取链接')
|
|
33
|
+
const next = resourceUrl(new URL(location, current))
|
|
34
|
+
if (current.protocol === 'https:' && next.protocol !== 'https:') throw new Error('文件续期不能从HTTPS降级;请核对服务返回地址')
|
|
35
|
+
current = next
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
const mime = response.headers.get('content-type') || ''
|
|
39
|
+
if (response.status === 200 && !/(?:json|text\/html)/i.test(mime)) return response
|
|
40
|
+
let payload
|
|
41
|
+
if (/json/i.test(mime)) {
|
|
42
|
+
const reader = response.body?.getReader(), chunks = []
|
|
43
|
+
let bytes = 0
|
|
44
|
+
try {
|
|
45
|
+
while (reader) {
|
|
46
|
+
const { done, value } = await reader.read()
|
|
47
|
+
if (done) { try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {} break }
|
|
48
|
+
bytes += value.length
|
|
49
|
+
if (bytes > 65536) break
|
|
50
|
+
chunks.push(value)
|
|
51
|
+
}
|
|
52
|
+
} finally { await reader?.cancel().catch(() => {}) }
|
|
53
|
+
} else await response.body?.cancel()
|
|
54
|
+
const apiCode = Number(payload?.code)
|
|
55
|
+
const error = Object.assign(new Error('文件下载返回错误状态或错误页面(HTTP ' + response.status +
|
|
56
|
+
(Number.isFinite(apiCode) && apiCode !== 0 ? ',code ' + apiCode : '') + ');未保存文件,需通过原业务接口核验权限或刷新链接'),
|
|
57
|
+
{ code: 'API_ERROR', status: response.status })
|
|
58
|
+
if (Number.isFinite(apiCode) && apiCode !== 0) error.apiCode = apiCode
|
|
59
|
+
const requestId = String(payload?.request_id || response.headers.get('x-request-id') || '')
|
|
60
|
+
if (/^[a-zA-Z0-9_-]{1,120}$/.test(requestId)) error.requestId = requestId
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ApiClient {
|
|
66
|
+
constructor(profile, store, timeout = 30000) { Object.assign(this, profile); this.baseUrl = baseUrl(profile.baseUrl); this.store = store; this.timeout = timeout; this.sign = null }
|
|
67
|
+
async save() { await this.store.save(this) }
|
|
68
|
+
async raw(endpoint, options = {}, { signed = true, retry = true, binaryPng = false, jsonMetadata = false } = {}) {
|
|
69
|
+
const method = options.method || 'GET'
|
|
70
|
+
const headers = new Headers(options.headers)
|
|
71
|
+
if (endpoint.startsWith('/canvas/')) headers.set('X-Client-Source', 'canvas')
|
|
72
|
+
const url = `${this.baseUrl}${endpoint}`
|
|
73
|
+
if (signed) {
|
|
74
|
+
if (!this.sign || this.sign.expiresAt < Date.now() + 5000) {
|
|
75
|
+
const data = await this.raw('/auth/sign-session', {}, { signed: false, retry })
|
|
76
|
+
const s = data.sign || data
|
|
77
|
+
if (!s.keyId || !s.apiSecret) throw new Error('签名会话响应不完整')
|
|
78
|
+
this.sign = { ...s, expiresAt: Date.now() + Number(s.expiresIn || 7200) * 1000 }
|
|
79
|
+
}
|
|
80
|
+
Object.entries(signRequest({ method, url, body: options.body, ...this.sign })).forEach(([k, v]) => headers.set(k, v))
|
|
81
|
+
}
|
|
82
|
+
// Fetching the signing session may rotate credentials; use the current pair.
|
|
83
|
+
if (this.accessToken) headers.set('Authorization', `Bearer ${this.accessToken}`)
|
|
84
|
+
if (this.cookie) headers.set('Cookie', this.cookie)
|
|
85
|
+
if (this.deviceId) headers.set('X-Device-Id', this.deviceId)
|
|
86
|
+
const csrf = String(this.cookie || '').split(';').map(x => x.trim()).find(x => /csrf/i.test(x.split('=')[0]))
|
|
87
|
+
if (csrf) headers.set('X-CSRF-Token', csrf.slice(csrf.indexOf('=') + 1))
|
|
88
|
+
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', options.body instanceof URLSearchParams ? 'application/x-www-form-urlencoded' : 'application/json')
|
|
89
|
+
const response = await request(url, { ...options, method, headers, body: options.body === undefined ? undefined : bodyBytes(options.body) }, this.timeout)
|
|
90
|
+
const cookies = getSetCookie(response.headers)
|
|
91
|
+
if (cookies.length) { this.cookie = mergeCookie(this.cookie, cookies); await this.save() }
|
|
92
|
+
if (binaryPng && response.ok && /^image\/png(?:;|$)/i.test(response.headers.get('content-type') || '')) {
|
|
93
|
+
const limit = 128 * 1024 * 1024
|
|
94
|
+
if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); throw new Error('PNG响应超过128MB限制;不重发处理') }
|
|
95
|
+
const chunks = []; let size = 0
|
|
96
|
+
for await (const chunk of response.body) {
|
|
97
|
+
size += chunk.length
|
|
98
|
+
if (size > limit) throw new Error('PNG响应超过128MB限制;不重发处理')
|
|
99
|
+
chunks.push(chunk)
|
|
100
|
+
}
|
|
101
|
+
const headers = Object.fromEntries(['content-type','content-length','content-disposition','x-result-ref','x-result-url','x-source-ref','x-source-url','x-credit-cost','x-request-id']
|
|
102
|
+
.map(key => [key,response.headers.get(key)]).filter(([,value]) => value !== null))
|
|
103
|
+
return { status:response.status,headers,body:Buffer.concat(chunks,size) }
|
|
104
|
+
}
|
|
105
|
+
const payload = await response.json().catch(() => null)
|
|
106
|
+
const requestId = String(payload?.request_id || response.headers.get('x-request-id') || '')
|
|
107
|
+
const diagnostics = error => {
|
|
108
|
+
if (/^[a-zA-Z0-9_-]{1,120}$/.test(requestId)) error.requestId = requestId
|
|
109
|
+
return error
|
|
110
|
+
}
|
|
111
|
+
if (this.authMode === 'cli' && [2011, 2012].includes(payload?.code)) {
|
|
112
|
+
const error = new Error(payload.code === 2012 ? 'CLI 会话已被新的 CLI 登录替换,请重新运行 auth login' : 'CLI 会话已过期,请重新运行 auth login')
|
|
113
|
+
error.code = 'API_ERROR'
|
|
114
|
+
throw diagnostics(error)
|
|
115
|
+
}
|
|
116
|
+
if (signed && retry && payload?.code === 2205) {
|
|
117
|
+
this.sign = null
|
|
118
|
+
return this.raw(endpoint, options, { signed, retry: false, binaryPng, jsonMetadata })
|
|
119
|
+
}
|
|
120
|
+
if ((payload?.code === 2010 || (response.status === 401 && (this.authMode !== 'cli' || payload?.code == null))) && retry && (this.refreshToken || this.cookie)) {
|
|
121
|
+
await this.refresh()
|
|
122
|
+
return this.raw(endpoint, options, { signed, retry: false, binaryPng, jsonMetadata })
|
|
123
|
+
}
|
|
124
|
+
if(Number(payload?.code)===4401) {
|
|
125
|
+
const error=new Error('需签署《统一协议》中的《Seedance2.0模型使用承诺书》(code 4401);生成尚未执行')
|
|
126
|
+
error.code='API_ERROR';error.apiCode=4401;error.status=response.status
|
|
127
|
+
const p=payload.data?.protocol
|
|
128
|
+
if(p && typeof p.required==='boolean' && typeof p.agreed==='boolean')error.protocol={required:p.required,agreed:p.agreed,version:String(p.version || '').slice(0,200),label:'《统一协议》',document_title:'《Seedance2.0模型使用承诺书》'}
|
|
129
|
+
throw diagnostics(error)
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok || payload === null) {
|
|
132
|
+
const error = new Error(`API 请求失败(HTTP ${response.status})`)
|
|
133
|
+
error.status = response.status; error.code = 'API_ERROR'; if (Number.isFinite(payload?.code)) error.apiCode=payload.code; throw diagnostics(error)
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const result = unwrap(payload)
|
|
137
|
+
if (binaryPng) throw new Error('处理响应不是PNG;保留原操作,不重发')
|
|
138
|
+
return jsonMetadata ? {status:response.status,data:result} : result
|
|
139
|
+
} catch (e) {
|
|
140
|
+
const error = new Error(`API 拒绝请求(code ${Number(e.code) || 'unknown'})`)
|
|
141
|
+
error.code = 'API_ERROR'; error.apiCode = Number(e.code); error.status = response.status
|
|
142
|
+
throw diagnostics(error)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async refresh() {
|
|
146
|
+
if (this.authMode === 'cli') {
|
|
147
|
+
if (!this.refreshRequestId) {
|
|
148
|
+
this.refreshRequestId = crypto.randomUUID()
|
|
149
|
+
await this.save()
|
|
150
|
+
}
|
|
151
|
+
const data = await e1Request(this.baseUrl, '/auth/cli/token', { grant_type: 'refresh_token', client_id: CLIENT_ID, device_id: this.deviceId, refresh_token: this.refreshToken, refresh_request_id: this.refreshRequestId }, this.timeout)
|
|
152
|
+
if (!data.access_token || !data.refresh_token || data.session?.client_type !== 'cli' || data.session?.device_id !== this.deviceId) throw new Error('CLI 刷新响应无效')
|
|
153
|
+
this.accessToken = data.access_token
|
|
154
|
+
this.refreshToken = data.refresh_token
|
|
155
|
+
this.refreshRequestId = ''
|
|
156
|
+
this.sign = null
|
|
157
|
+
await this.save()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const data = await this.raw('/auth/refresh', { method: 'POST', ...(this.refreshToken ? { body: { refresh_token: this.refreshToken } } : {}) }, { signed: false, retry: false })
|
|
161
|
+
if (!data.access_token) throw new Error('刷新响应缺少 access_token')
|
|
162
|
+
this.accessToken = data.access_token
|
|
163
|
+
if (data.refresh_token) this.refreshToken = data.refresh_token
|
|
164
|
+
else if (this.cookie) this.refreshToken = ''
|
|
165
|
+
this.sign = null
|
|
166
|
+
await this.save() // persist rotation immediately, including if the next request fails
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { baseUrl } from './api.js'
|
|
2
|
+
|
|
3
|
+
export function loginTarget(apiAddress, webAddress) {
|
|
4
|
+
const api = baseUrl(apiAddress)
|
|
5
|
+
const web = new URL(webAddress || new URL(api).origin)
|
|
6
|
+
// Require the deployment's explicit API origin; no credential forwarding after redirects.
|
|
7
|
+
baseUrl(web.origin)
|
|
8
|
+
if (web.username || web.password || web.hash) throw new Error('登录网站地址不能包含凭据或 fragment')
|
|
9
|
+
const login = web.pathname === '/' ? new URL('/auth/login?redirect=/workspace', web.origin) : web
|
|
10
|
+
const redirect = login.searchParams.get('redirect')
|
|
11
|
+
if (redirect && new URL(redirect, web.origin).origin !== web.origin) throw new Error('登录 redirect 必须指向同一网站')
|
|
12
|
+
return { api, origin: web.origin, url: login.href }
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
const favicon = `data:image/png;base64,${readFileSync(new URL('../assets/favicon-uzoo.png', import.meta.url)).toString('base64')}`
|
|
5
|
+
const logo = `data:image/svg+xml;base64,${readFileSync(new URL('../assets/logo-uzoo.svg', import.meta.url)).toString('base64')}`
|
|
6
|
+
const copy = {
|
|
7
|
+
'zh-CN': {
|
|
8
|
+
brand: 'UzooAI · CLI 授权',
|
|
9
|
+
success: ['CLI 登录成功', '授权已完成,登录凭据已安全保存在此电脑。', '返回终端,继续创作', '您可以关闭此标签页。网页会话保持登录。'],
|
|
10
|
+
received: ['授权已确认', '授权结果已收到,请返回终端查看登录结果。', '返回终端', '您可以关闭此标签页。'],
|
|
11
|
+
denied: ['已取消授权', '您已拒绝此次 CLI 登录,没有建立新的 CLI 会话。', '如需登录,请在终端重新发起授权', '原有网页会话不受影响。'],
|
|
12
|
+
failure: ['CLI 登录未完成', '无法完成令牌兑换或保存登录凭据,请查看终端中的错误信息。', '返回终端重试', '网页确认不代表 CLI 已成功登录。'],
|
|
13
|
+
},
|
|
14
|
+
en: {
|
|
15
|
+
brand: 'UzooAI · CLI authorization',
|
|
16
|
+
success: ['You’re signed in', 'Authorization is complete. Your credentials are securely saved on this computer.', 'Return to your terminal', 'You can close this tab. Your browser stays signed in.'],
|
|
17
|
+
received: ['Authorization confirmed', 'The response was received. Check your terminal for the sign-in result.', 'Return to your terminal', 'You can close this tab.'],
|
|
18
|
+
denied: ['Authorization cancelled', 'You declined CLI sign-in. No new CLI session was created.', 'Start a new request in your terminal to sign in', 'Your existing browser session is unaffected.'],
|
|
19
|
+
failure: ['CLI sign-in incomplete', 'The token exchange or credential save failed. Check your terminal for details.', 'Return to your terminal and try again', 'Browser approval alone does not mean CLI sign-in succeeded.'],
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Static, local assets only. Never interpolate callback parameters or errors.
|
|
24
|
+
export function callbackPage(status = 'received', acceptLanguage = '') {
|
|
25
|
+
const lang = /^en\b/i.test(acceptLanguage) ? 'en' : 'zh-CN'
|
|
26
|
+
const words = copy[lang]
|
|
27
|
+
const [title, description, action, note] = words[status] || words.failure
|
|
28
|
+
const nonce = crypto.randomBytes(18).toString('base64')
|
|
29
|
+
const success = status === 'success' || status === 'received'
|
|
30
|
+
const symbol = success ? '<path d="m15 25 7 7 14-16"/>' : '<path d="m18 18 14 14m0-14L18 32"/>'
|
|
31
|
+
return {
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
34
|
+
'Cache-Control': 'no-store',
|
|
35
|
+
'Referrer-Policy': 'no-referrer',
|
|
36
|
+
'X-Content-Type-Options': 'nosniff',
|
|
37
|
+
'Content-Security-Policy': `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
|
|
38
|
+
},
|
|
39
|
+
html: `<!doctype html><html lang="${lang}"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>${title} · UzooAI</title><link rel="icon" href="${favicon}"><script nonce="${nonce}">history.replaceState(null, '', location.pathname)</script><style nonce="${nonce}">
|
|
40
|
+
*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:32px 20px;background:#080808;color:#f3f3f3;font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}main{width:100%;max-width:460px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:10px;color:#a3a3a3;font-size:13px;letter-spacing:.04em;margin-bottom:44px}.brand img{width:30px;height:30px}.card{padding:38px 28px 30px;background:#111;border:1px solid #282828;border-radius:22px}.status-icon{display:grid;place-items:center;width:64px;height:64px;margin:0 auto 24px;border-radius:50%;background:${success ? '#163227' : '#33261a'};color:${success ? '#7dd8a7' : '#e8bd83'}}svg{width:48px;height:48px;fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}h1{font-size:26px;line-height:1.3;letter-spacing:-.03em;margin:0 0 14px}p{font-size:14px;line-height:1.8;color:#a5a5a5;margin:0;overflow-wrap:anywhere}.terminal{margin-top:28px;padding:15px 18px;border-radius:10px;background:#1d1d1d;font-size:14px;line-height:1.6;color:#e2e2e2}.terminal span{font-family:monospace;color:#888;margin-right:8px}.note{font-size:12px;line-height:1.8;margin-top:24px;color:#777}footer{margin-top:28px;font-size:11px;letter-spacing:.04em;color:#555}@media(max-width:400px){.card{padding:30px 20px}h1{font-size:23px}.brand{margin-bottom:28px}}
|
|
41
|
+
.brand img{width:96px;height:auto}
|
|
42
|
+
</style></head><body><main><div class="brand"><img src="${logo}" alt="UzooAI"><span>${words.brand}</span></div><section class="card" aria-labelledby="result-title"><div class="status-icon"><svg viewBox="0 0 50 50" aria-hidden="true">${symbol}</svg></div><h1 id="result-title">${title}</h1><p>${description}</p><div class="terminal"><span aria-hidden="true">>_</span>${action}</div><p class="note">${note}</p></section><footer>UzooAI · Anime</footer></main></body></html>`,
|
|
43
|
+
}
|
|
44
|
+
}
|