@playcraft/cli 0.1.53-beta → 0.1.55-beta
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/README.md +21 -12
- package/dist/assets/client.js +157 -0
- package/dist/assets/messages.js +48 -0
- package/dist/cli-root-help.js +2 -0
- package/dist/commands/assets.js +123 -0
- package/dist/commands/image-layer-messages.js +37 -0
- package/dist/commands/image-layer-tools.js +185 -0
- package/dist/commands/image.js +2 -73
- package/dist/commands/library-messages.js +46 -0
- package/dist/commands/library.js +130 -0
- package/dist/index.js +6 -1
- package/dist/utils/agent-api-client.js +35 -0
- package/dist/workspace-runtime/server/http.js +26 -2
- package/dist/workspace-runtime/workspaces/directories.js +5 -2
- package/dist/workspace-runtime/workspaces/empty-template.js +1 -0
- package/dist/workspace-runtime/workspaces/prepare.js +6 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +74 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +67 -0
- package/dist/workspace-runtime/workspaces/worktrees.js +73 -0
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
- package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
- package/project-template/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
- package/project-template/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
- package/project-template/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
- package/project-template/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
- package/project-template/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
- package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
- package/project-template-v2/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
- package/project-template-v2/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
- package/project-template-v2/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
- package/project-template-v2/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
- package/project-templates/empty/AGENTS.md +12 -0
- package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
- package/project-templates/phaser/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
- package/project-templates/phaser/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
- package/project-templates/phaser/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
- package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +31 -4
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/SKILL.md +36 -5
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/ta-image-generation-detail.md +300 -0
- package/project-templates/threejs/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-image-processing/SKILL.md +50 -25
- package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/SKILL.md +53 -0
- package/project-templates/threejs/.claude/skills/playcraft-workspace-assets/references/requests.json +32 -0
package/README.md
CHANGED
|
@@ -210,18 +210,27 @@ echo '{"token":"<sandbox-jwt>","apiUrl":"https://..."}' > .playcraft.json
|
|
|
210
210
|
|
|
211
211
|
> 运行 `playcraft login` 后所有子命令均可直接使用。
|
|
212
212
|
|
|
213
|
-
| 命令
|
|
214
|
-
|
|
|
215
|
-
| `tools generate-image`
|
|
216
|
-
| `tools generate-sfx`
|
|
217
|
-
| `tools generate-bgm`
|
|
218
|
-
| `tools list-image-models`
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
222
|
-
| `
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
213
|
+
| 命令 | 说明 | 示例 |
|
|
214
|
+
| ------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------- |
|
|
215
|
+
| `tools generate-image` | AI 生成图片 | `playcraft tools generate-image --prompt "pixel art sky"` |
|
|
216
|
+
| `tools generate-sfx` | AI 生成音效 | `playcraft tools generate-sfx --prompt "UI click"` |
|
|
217
|
+
| `tools generate-bgm` | AI 生成 BGM | `playcraft tools generate-bgm --prompt "casual game music"` |
|
|
218
|
+
| `tools list-image-models` | 列出可用生图模型 | `playcraft tools list-image-models` |
|
|
219
|
+
| `library search` | 检索有权访问的素材库 | `playcraft library search "角色" --type texture --json` |
|
|
220
|
+
| `library download` | 下载单文件或完整素材包 | `playcraft library download <assetId> --output ./assets/character` |
|
|
221
|
+
| `image decompose-layers` | 与云工作区一致的图层分离 | `playcraft image decompose-layers --input scene.png --output-dir dist/layers` |
|
|
222
|
+
| `image analyze-nine-patch` | 分析九宫格拉伸边距 | `playcraft image analyze-nine-patch --input panel.png --output panel.json` |
|
|
223
|
+
| `assets generate` | 创建可恢复的工作区素材生成任务 | `playcraft assets generate --request request.json --client-request-id <uuid>` |
|
|
224
|
+
| `assets tasks` / `get` / `wait` / `cancel` / `download` | 管理工作区生成任务和结果 | `playcraft assets download <taskId> --output ./assets/result` |
|
|
225
|
+
| `tools list-remixes` | 查看 Remix 项目列表 | `playcraft tools list-remixes` |
|
|
226
|
+
| `tools save-to-git` | 同步沙箱内容到 Git | `playcraft tools save-to-git --project-id <id> --message "feat: ..."` |
|
|
227
|
+
| `tools build-project` | 在沙箱中执行构建 | `playcraft tools build-project --project-id <id>` |
|
|
228
|
+
|
|
229
|
+
| `recommend similar` | 相似 Creative 推荐 | `playcraft recommend similar --channel Applovin` |
|
|
230
|
+
| `recommend improvements` | Tag 改进建议 | `playcraft recommend improvements --channel Facebook` |
|
|
231
|
+
| `recommend random` | 随机 Tag 组合推荐 | `playcraft recommend random --channel Google` |
|
|
232
|
+
|
|
233
|
+
云工作区工具与命令行的完整对应、权限规则和请求示例见[工具覆盖说明](../../docs/cli/cloud-workspace-tool-parity.md)。
|
|
225
234
|
|
|
226
235
|
### 业务工具请求恢复
|
|
227
236
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, mkdtemp, open, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { assetGenerationTaskSchema, assetGenerationTaskListSchema, animationEditorStateSchema, workspaceSessionScopeSchema, ASSET_GENERATION_REQUEST_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { resolveVideoWorkspace } from '../video-replication/client.js';
|
|
8
|
+
import { resolveSandboxPath } from '../utils/sandbox-path.js';
|
|
9
|
+
import { assetMessage as t } from './messages.js';
|
|
10
|
+
export async function readAssetRequest(path) {
|
|
11
|
+
const handle = await open(resolve(resolveSandboxPath(path)), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
12
|
+
try {
|
|
13
|
+
const stat = await handle.stat();
|
|
14
|
+
if (!stat.isFile() || stat.size > ASSET_GENERATION_REQUEST_MAX_BYTES)
|
|
15
|
+
throw new Error(t('requestTooLarge'));
|
|
16
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
await handle.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function resolveAssetScope(options, cwd = process.cwd()) {
|
|
23
|
+
const workspaceId = await resolveVideoWorkspace(options.workspace, cwd);
|
|
24
|
+
let inferred;
|
|
25
|
+
for (let cursor = resolve(cwd);; cursor = dirname(cursor)) {
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(await readFile(join(cursor, '.playcraft/workspace-context.json'), 'utf8'));
|
|
28
|
+
if (value.workspaceId === workspaceId) {
|
|
29
|
+
// Cloud project roots are <workspaceRoot>/<workspaceId>/<engineeringDirectory>.
|
|
30
|
+
if (basename(dirname(cursor)) === workspaceId && workspaceSessionScopeSchema.safeParse(basename(cursor)).success)
|
|
31
|
+
inferred = basename(cursor);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== 'ENOENT')
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
if (dirname(cursor) === cursor)
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const parsed = workspaceSessionScopeSchema.safeParse(options.directory ?? inferred ?? 'basic');
|
|
43
|
+
if (!parsed.success || inferred && options.directory && inferred !== options.directory)
|
|
44
|
+
throw new Error(t('scopeMismatch'));
|
|
45
|
+
return { workspaceId, directoryName: parsed.data };
|
|
46
|
+
}
|
|
47
|
+
export function assetContentRoute(input) {
|
|
48
|
+
const query = new URLSearchParams(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
49
|
+
return `/workspace-assets/content?${query}`;
|
|
50
|
+
}
|
|
51
|
+
export function scopedAssetTask(scope, value) {
|
|
52
|
+
const task = assetGenerationTaskSchema.parse(value);
|
|
53
|
+
if (task.workspaceId !== scope.workspaceId || task.directoryName !== scope.directoryName)
|
|
54
|
+
throw new Error(t('scopeMismatch'));
|
|
55
|
+
return task;
|
|
56
|
+
}
|
|
57
|
+
export async function readAssetTasks(client, scope) {
|
|
58
|
+
const value = assetGenerationTaskListSchema.parse(await client.get('/workspace-assets', { ...scope, action: 'list' }));
|
|
59
|
+
return { tasks: value.tasks.map(task => scopedAssetTask(scope, task)) };
|
|
60
|
+
}
|
|
61
|
+
export async function readAssetTask(client, scope, taskId) {
|
|
62
|
+
const task = scopedAssetTask(scope, await client.get('/workspace-assets', { ...scope, action: 'get', taskId }));
|
|
63
|
+
if (task.taskId !== taskId)
|
|
64
|
+
throw new Error(t('scopeMismatch'));
|
|
65
|
+
return task;
|
|
66
|
+
}
|
|
67
|
+
export async function waitForAssetTask(client, scope, taskId, timeoutSeconds = 1800, intervalSeconds = 3) {
|
|
68
|
+
validateAssetWait(timeoutSeconds, intervalSeconds);
|
|
69
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const task = await readAssetTask(client, scope, taskId);
|
|
72
|
+
if (!['queued', 'running', 'cancel_requested'].includes(task.status))
|
|
73
|
+
return task;
|
|
74
|
+
if (Date.now() >= deadline)
|
|
75
|
+
throw new Error(t('timeoutReached'));
|
|
76
|
+
await delay(Math.min(intervalSeconds * 1000, deadline - Date.now()));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function validateAssetWait(timeoutSeconds, intervalSeconds) {
|
|
80
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 86400 || !Number.isFinite(intervalSeconds)
|
|
81
|
+
|| intervalSeconds < 0.1 || intervalSeconds > 60)
|
|
82
|
+
throw new Error(t('invalidRequest'));
|
|
83
|
+
}
|
|
84
|
+
async function verifyFile(path, file) {
|
|
85
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
86
|
+
try {
|
|
87
|
+
if ((await handle.stat()).size !== file.size)
|
|
88
|
+
throw new Error(t('invalidContent'));
|
|
89
|
+
const hash = createHash('sha256');
|
|
90
|
+
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
91
|
+
hash.update(chunk);
|
|
92
|
+
if (hash.digest('hex') !== file.contentHash)
|
|
93
|
+
throw new Error(t('invalidContent'));
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await handle.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Stage and verify every member before publishing the new local output. */
|
|
100
|
+
export async function downloadAssetTask(client, scope, taskId, output, versionId) {
|
|
101
|
+
const task = await readAssetTask(client, scope, taskId);
|
|
102
|
+
if (!['awaiting_adoption', 'saved'].includes(task.status) || !task.output)
|
|
103
|
+
throw new Error(t('outputUnavailable'));
|
|
104
|
+
let bundle = task.bundle, entry = task.output;
|
|
105
|
+
if (versionId) {
|
|
106
|
+
const editor = animationEditorStateSchema.parse(await client.get('/workspace-assets', { ...scope, action: 'animation', taskId }));
|
|
107
|
+
if (editor.taskId !== taskId)
|
|
108
|
+
throw new Error(t('scopeMismatch'));
|
|
109
|
+
const version = editor.versions.find(version => version.versionId === versionId);
|
|
110
|
+
if (!version)
|
|
111
|
+
throw new Error(t('outputUnavailable'));
|
|
112
|
+
bundle = version.bundle;
|
|
113
|
+
entry = version.output;
|
|
114
|
+
}
|
|
115
|
+
const destination = resolve(resolveSandboxPath(output));
|
|
116
|
+
if (await lstat(destination).then(() => true, error => { if (error.code === 'ENOENT')
|
|
117
|
+
return false; throw error; }))
|
|
118
|
+
throw new Error(t('outputExists'));
|
|
119
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
120
|
+
const staging = await mkdtemp(join(dirname(destination), '.playcraft-asset-'));
|
|
121
|
+
let createdDestination = false;
|
|
122
|
+
try {
|
|
123
|
+
const files = bundle?.files ?? [{ ...entry, path: 'output', fileId: undefined }];
|
|
124
|
+
const paths = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const temporary = resolve(staging, file.path);
|
|
127
|
+
const inside = relative(staging, temporary);
|
|
128
|
+
if (!inside || inside === '..' || inside.startsWith(`..${sep}`))
|
|
129
|
+
throw new Error(t('invalidContent'));
|
|
130
|
+
await mkdir(dirname(temporary), { recursive: true });
|
|
131
|
+
await client.download(assetContentRoute({ ...scope, taskId, ...(bundle ? { fileId: file.fileId } : {}), ...(versionId ? { versionId } : {}) }), temporary);
|
|
132
|
+
await verifyFile(temporary, file);
|
|
133
|
+
paths.push(bundle ? join(destination, file.path) : destination);
|
|
134
|
+
}
|
|
135
|
+
if (bundle) {
|
|
136
|
+
// mkdir is exclusive; an existing directory or symbolic link cannot be replaced.
|
|
137
|
+
await mkdir(destination);
|
|
138
|
+
createdDestination = true;
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
const path = join(destination, file.path);
|
|
141
|
+
await mkdir(dirname(path), { recursive: true });
|
|
142
|
+
await link(join(staging, file.path), path);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
await link(join(staging, 'output'), destination);
|
|
147
|
+
return { task, paths };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (createdDestination)
|
|
151
|
+
await rm(destination, { recursive: true, force: true });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await rm(staging, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const messages = {
|
|
2
|
+
en: {
|
|
3
|
+
topic: 'Generate workspace assets and manage the same tasks shown in the workspace',
|
|
4
|
+
estimate: 'Estimate generation cost without creating a task', generate: 'Create a durable generation task',
|
|
5
|
+
tasks: 'List generation tasks', get: 'Read a generation task', cancel: 'Request task cancellation',
|
|
6
|
+
wait: 'Wait for an existing task; never submit another generation', download: 'Download a verified result, preserving all bundle files',
|
|
7
|
+
saved: 'Confirm workspace adoption using an existing platform write receipt',
|
|
8
|
+
libraryTargets: 'List authorized destination teams', librarySave: 'Copy a generated result into the asset library',
|
|
9
|
+
librarySaves: 'List library copy jobs', libraryRetry: 'Resume a library copy job using its existing identity',
|
|
10
|
+
animation: 'Inspect and edit generated frame animations', animationGet: 'Read the animation draft and versions',
|
|
11
|
+
animationDraft: 'Update a draft using its expected revision', animationFrame: 'Upload a replacement frame',
|
|
12
|
+
animationVersion: 'Export an immutable animation version', animationSaved: 'Confirm adoption of an animation version',
|
|
13
|
+
workspace: 'Workspace identifier; inferred from the current project when omitted', directory: 'Engineering directory; inferred inside the cloud workspace, otherwise basic',
|
|
14
|
+
request: 'Path to the request document', receipt: 'Path to a platform write receipt with operationId and path',
|
|
15
|
+
clientRequest: 'Stable request identity; reuse it after an uncertain submission',
|
|
16
|
+
output: 'Destination file, or new directory for a complete sprite or animation bundle',
|
|
17
|
+
waitOption: 'Wait for generation to finish', timeout: 'Maximum wait in seconds', interval: 'Polling interval in seconds',
|
|
18
|
+
version: 'Animation version identifier', saveId: 'Existing library copy job identifier', release: 'Start this library copy without waiting for workspace adoption',
|
|
19
|
+
json: 'Print structured results', invalidRequest: 'The request document or command arguments are invalid.',
|
|
20
|
+
scopeMismatch: 'The returned result belongs to another workspace or engineering directory.',
|
|
21
|
+
outputUnavailable: 'The task has no completed output to download.', outputExists: 'The output already exists. Choose a new destination.',
|
|
22
|
+
invalidContent: 'Downloaded content does not match the task size or content digest.', failed: 'Asset operation failed',
|
|
23
|
+
timeoutReached: 'Waiting timed out. Use assets get or assets wait with the same task identifier; generation was not cancelled.',
|
|
24
|
+
requestTooLarge: 'The request document exceeds the permitted size.',
|
|
25
|
+
},
|
|
26
|
+
zh: {
|
|
27
|
+
topic: '生成工作区素材,管理工作区面板中同一批任务', estimate: '估算生成费用,不创建任务', generate: '创建可恢复的素材生成任务',
|
|
28
|
+
tasks: '列出素材生成任务', get: '读取生成任务', cancel: '请求取消任务', wait: '等待已有任务,不会重新生成',
|
|
29
|
+
download: '校验并下载结果,保留素材包全部文件', saved: '用已有平台写入回执确认素材已采纳',
|
|
30
|
+
libraryTargets: '列出允许写入的团队素材库', librarySave: '将生成结果保存到素材库', librarySaves: '列出素材库保存任务',
|
|
31
|
+
libraryRetry: '沿用已有标识恢复素材库保存任务', animation: '查看、编辑生成的帧动画', animationGet: '读取动画草稿和版本',
|
|
32
|
+
animationDraft: '按预期修订号更新动画草稿', animationFrame: '上传替换帧', animationVersion: '导出不可变的动画版本',
|
|
33
|
+
animationSaved: '确认动画版本已采纳', workspace: '工作区标识;省略时从当前工程推断', directory: '工程目录;云工作区内自动推断,其余情况默认 basic',
|
|
34
|
+
request: '请求文档路径', receipt: '平台写入回执路径,需包含 operationId 和 path',
|
|
35
|
+
clientRequest: '稳定请求标识;提交结果不确定时复用同一标识', output: '目标文件路径;精灵图或动画素材包使用新目录',
|
|
36
|
+
waitOption: '等待生成完成', timeout: '最长等待秒数', interval: '查询间隔秒数', version: '动画版本标识',
|
|
37
|
+
saveId: '已有素材库保存任务标识', release: '立即执行此素材库保存任务,不再等待工作区采纳', json: '输出结构化结果',
|
|
38
|
+
invalidRequest: '请求文档或命令参数无效。', scopeMismatch: '返回结果不属于所选工作区或工程目录。',
|
|
39
|
+
outputUnavailable: '任务尚无可供下载的完整结果。', outputExists: '目标已存在,请选择新的保存位置。',
|
|
40
|
+
invalidContent: '下载内容与任务记录的大小或内容摘要不一致。', failed: '素材操作失败',
|
|
41
|
+
timeoutReached: '等待已超时。请使用同一任务标识运行 assets get 或 assets wait,生成任务未取消。',
|
|
42
|
+
requestTooLarge: '请求文档超过允许大小。',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export function assetMessage(key) {
|
|
46
|
+
const language = process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en';
|
|
47
|
+
return messages[language.startsWith('zh') ? 'zh' : 'en'][key];
|
|
48
|
+
}
|
package/dist/cli-root-help.js
CHANGED
|
@@ -5,6 +5,8 @@ export function getCliTopicsHelpText() {
|
|
|
5
5
|
'',
|
|
6
6
|
' 本地开发 create, init, start, stop, status, logs, config, sync, fix-ids',
|
|
7
7
|
' 素材 tools generate-* | image <子命令> | audio <子命令> | prefab <子命令>',
|
|
8
|
+
' 素材库 library list/search/facets/get/files/download',
|
|
9
|
+
' 生成任务 assets estimate/generate/tasks/get/wait/cancel/download | assets animation',
|
|
8
10
|
' 视频理解 video models | video understand | video jobs get/cancel',
|
|
9
11
|
' 平台 build, build-base, build-playable, build-all, analyze, inspect, upgrade',
|
|
10
12
|
' tools 除 generate-* 外(Git / 沙箱构建 / 发布 / 列表 / Prefab 等,需后端 API)',
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { assetGenerationRequestSchema, assetGenerationEstimateInput, assetGenerationEstimateInputSchema, assetGenerationEstimateSchema, assetGenerationTaskSavedSchema, assetLibrarySaveRequestSchema, assetLibrarySaveJobSchema, assetLibrarySaveListSchema, assetLibrarySaveTargetsSchema, animationEditorStateSchema, animationEditorDraftUpdateSchema, animationEditorFrameUploadSchema, animationEditorFrameUploadResultSchema, animationEditorVersionCreateSchema, animationEditorVersionResultSchema, workspaceAssetToolWriteSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
3
|
+
import { resolveAssetScope, readAssetRequest, readAssetTask, readAssetTasks, scopedAssetTask, waitForAssetTask, downloadAssetTask } from '../assets/client.js';
|
|
4
|
+
import { validateAssetWait } from '../assets/client.js';
|
|
5
|
+
import { assetMessage as t } from '../assets/messages.js';
|
|
6
|
+
function shared(command) {
|
|
7
|
+
return command.option('--workspace <id>', t('workspace')).option('--directory <name>', t('directory')).option('--json', t('json'));
|
|
8
|
+
}
|
|
9
|
+
function polling(command) {
|
|
10
|
+
return command.option('--timeout <seconds>', t('timeout'), '1800').option('--interval <seconds>', t('interval'), '3');
|
|
11
|
+
}
|
|
12
|
+
function identifier(value) {
|
|
13
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value))
|
|
14
|
+
throw new Error(t('invalidRequest'));
|
|
15
|
+
return value.toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
async function document(path) { if (!path)
|
|
18
|
+
throw new Error(t('invalidRequest')); return readAssetRequest(path); }
|
|
19
|
+
async function run(command, options, action) {
|
|
20
|
+
try {
|
|
21
|
+
const scope = await resolveAssetScope(options);
|
|
22
|
+
const result = await action({ scope, client: new AgentApiClient() });
|
|
23
|
+
const record = result && typeof result === 'object' ? result : {};
|
|
24
|
+
const failed = ['failed', 'interrupted', ...(command === 'assets cancel' ? [] : ['cancelled'])];
|
|
25
|
+
const ok = !failed.includes(record.status ?? '');
|
|
26
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 'playcraft.assets-cli.v1', command, ok, result })}\n`);
|
|
27
|
+
if (!ok)
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
const timeout = message === t('timeoutReached');
|
|
33
|
+
process.stderr.write(`${t('failed')}: ${message}\n`);
|
|
34
|
+
process.stdout.write(`${JSON.stringify({ schemaVersion: 'playcraft.assets-cli.v1', command, ok: false,
|
|
35
|
+
error: { code: timeout ? 'asset_generation_wait_timeout' : 'asset_operation_failed', message } })}\n`);
|
|
36
|
+
process.exitCode = timeout ? 75 : 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function write(client, body) {
|
|
40
|
+
return client.post(body.action === 'animation-draft' ? '/workspace-assets/animation-draft' : '/workspace-assets', workspaceAssetToolWriteSchema.parse(body));
|
|
41
|
+
}
|
|
42
|
+
export function registerWorkspaceAssetCommands(program) {
|
|
43
|
+
const assets = program.command('assets').description(t('topic'));
|
|
44
|
+
shared(assets.command('estimate').description(t('estimate')))
|
|
45
|
+
.requiredOption('--request <path>', t('request'))
|
|
46
|
+
.action((options) => run('assets estimate', options, async ({ client, scope }) => {
|
|
47
|
+
const input = await document(options.request), full = assetGenerationRequestSchema.safeParse(input);
|
|
48
|
+
const request = full.success ? assetGenerationEstimateInput(full.data) : assetGenerationEstimateInputSchema.parse(input);
|
|
49
|
+
return assetGenerationEstimateSchema.parse(await write(client, { ...scope, action: 'estimate', request }));
|
|
50
|
+
}));
|
|
51
|
+
polling(shared(assets.command('generate').description(t('generate'))))
|
|
52
|
+
.requiredOption('--request <path>', t('request')).requiredOption('--client-request-id <id>', t('clientRequest'))
|
|
53
|
+
.option('--wait', t('waitOption')).option('--output <path>', t('output'))
|
|
54
|
+
.action((options) => run('assets generate', options, async ({ client, scope }) => {
|
|
55
|
+
if (options.output && !options.wait)
|
|
56
|
+
throw new Error(t('invalidRequest'));
|
|
57
|
+
if (options.wait)
|
|
58
|
+
validateAssetWait(Number(options.timeout), Number(options.interval));
|
|
59
|
+
const clientRequestId = options.clientRequestId.trim();
|
|
60
|
+
if (!clientRequestId)
|
|
61
|
+
throw new Error(t('invalidRequest'));
|
|
62
|
+
const request = assetGenerationRequestSchema.parse(await document(options.request));
|
|
63
|
+
let task = scopedAssetTask(scope, await write(client, { ...scope, action: 'create', clientRequestId, request }));
|
|
64
|
+
if (task.clientRequestId !== clientRequestId)
|
|
65
|
+
throw new Error(t('scopeMismatch'));
|
|
66
|
+
// The identity is printed before polling so a disconnected caller can recover without creating a task.
|
|
67
|
+
if (options.wait) {
|
|
68
|
+
process.stderr.write(`${JSON.stringify({ taskId: task.taskId, clientRequestId: task.clientRequestId, status: task.status })}\n`);
|
|
69
|
+
task = await waitForAssetTask(client, scope, task.taskId, Number(options.timeout), Number(options.interval));
|
|
70
|
+
}
|
|
71
|
+
if (options.output && ['awaiting_adoption', 'saved'].includes(task.status))
|
|
72
|
+
return downloadAssetTask(client, scope, task.taskId, options.output);
|
|
73
|
+
return task;
|
|
74
|
+
}));
|
|
75
|
+
shared(assets.command('tasks').description(t('tasks')))
|
|
76
|
+
.action((options) => run('assets tasks', options, ({ client, scope }) => readAssetTasks(client, scope)));
|
|
77
|
+
shared(assets.command('get <task-id>').description(t('get')))
|
|
78
|
+
.action((taskId, options) => run('assets get', options, ({ client, scope }) => readAssetTask(client, scope, identifier(taskId))));
|
|
79
|
+
shared(assets.command('cancel <task-id>').description(t('cancel')))
|
|
80
|
+
.action((taskId, options) => run('assets cancel', options, async ({ client, scope }) => scopedAssetTask(scope, await write(client, { ...scope, action: 'cancel', taskId: identifier(taskId) }))));
|
|
81
|
+
polling(shared(assets.command('wait <task-id>').description(t('wait'))))
|
|
82
|
+
.action((taskId, options) => run('assets wait', options, ({ client, scope }) => waitForAssetTask(client, scope, identifier(taskId), Number(options.timeout), Number(options.interval))));
|
|
83
|
+
shared(assets.command('download <task-id>').description(t('download')))
|
|
84
|
+
.requiredOption('--output <path>', t('output')).option('--version <id>', t('version'))
|
|
85
|
+
.action((taskId, options) => run('assets download', options, ({ client, scope }) => downloadAssetTask(client, scope, identifier(taskId), options.output, options.version ? identifier(options.version) : undefined)));
|
|
86
|
+
shared(assets.command('saved <task-id>').description(t('saved')))
|
|
87
|
+
.requiredOption('--receipt <path>', t('receipt'))
|
|
88
|
+
.action((taskId, options) => run('assets saved', options, async ({ client, scope }) => scopedAssetTask(scope, await write(client, { ...scope, action: 'saved', taskId: identifier(taskId), proof: assetGenerationTaskSavedSchema.parse(await document(options.receipt)) }))));
|
|
89
|
+
shared(assets.command('library-targets').description(t('libraryTargets')))
|
|
90
|
+
.action((options) => run('assets library-targets', options, async ({ client, scope }) => assetLibrarySaveTargetsSchema.parse(await client.get('/workspace-assets', { ...scope, action: 'library-targets' }))));
|
|
91
|
+
shared(assets.command('library-saves <task-id>').description(t('librarySaves')))
|
|
92
|
+
.action((taskId, options) => run('assets library-saves', options, async ({ client, scope }) => assetLibrarySaveListSchema.parse(await client.get('/workspace-assets', { ...scope, action: 'library-saves', taskId: identifier(taskId) }))));
|
|
93
|
+
shared(assets.command('library-save <task-id>').description(t('librarySave')))
|
|
94
|
+
.requiredOption('--request <path>', t('request'))
|
|
95
|
+
.action((taskId, options) => run('assets library-save', options, async ({ client, scope }) => assetLibrarySaveJobSchema.parse(await write(client, { ...scope, action: 'library-save', taskId: identifier(taskId), request: assetLibrarySaveRequestSchema.parse(await document(options.request)) }))));
|
|
96
|
+
shared(assets.command('library-retry <task-id>').description(t('libraryRetry')))
|
|
97
|
+
.requiredOption('--save-id <id>', t('saveId')).option('--start-without-workspace', t('release'))
|
|
98
|
+
.action((taskId, options) => run('assets library-retry', options, async ({ client, scope }) => assetLibrarySaveJobSchema.parse(await write(client, { ...scope, action: 'library-retry', taskId: identifier(taskId), saveId: identifier(options.saveId),
|
|
99
|
+
...(options.startWithoutWorkspace ? { startWithoutWorkspace: true } : {}) }))));
|
|
100
|
+
const animation = assets.command('animation').description(t('animation'));
|
|
101
|
+
shared(animation.command('get <task-id>').description(t('animationGet')))
|
|
102
|
+
.action((taskId, options) => run('assets animation get', options, async ({ client, scope }) => {
|
|
103
|
+
const result = animationEditorStateSchema.parse(await client.get('/workspace-assets', { ...scope, action: 'animation', taskId: identifier(taskId) }));
|
|
104
|
+
if (result.taskId !== identifier(taskId))
|
|
105
|
+
throw new Error(t('scopeMismatch'));
|
|
106
|
+
return result;
|
|
107
|
+
}));
|
|
108
|
+
for (const [name, action, description, schema, response] of [
|
|
109
|
+
['draft', 'animation-draft', 'animationDraft', animationEditorDraftUpdateSchema, animationEditorStateSchema],
|
|
110
|
+
['upload-frame', 'animation-frame', 'animationFrame', animationEditorFrameUploadSchema, animationEditorFrameUploadResultSchema],
|
|
111
|
+
['version', 'animation-version', 'animationVersion', animationEditorVersionCreateSchema, animationEditorVersionResultSchema],
|
|
112
|
+
]) {
|
|
113
|
+
shared(animation.command(`${name} <task-id>`).description(t(description))).requiredOption('--request <path>', t('request'))
|
|
114
|
+
.action((taskId, options) => run(`assets animation ${name}`, options, async ({ client, scope }) => {
|
|
115
|
+
const body = workspaceAssetToolWriteSchema.parse({ ...scope, action, taskId: identifier(taskId), request: schema.parse(await document(options.request)) });
|
|
116
|
+
return response.parse(await write(client, body));
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
shared(animation.command('saved <task-id>').description(t('animationSaved')))
|
|
120
|
+
.requiredOption('--version <id>', t('version')).requiredOption('--receipt <path>', t('receipt'))
|
|
121
|
+
.action((taskId, options) => run('assets animation saved', options, async ({ client, scope }) => animationEditorVersionResultSchema.parse(await write(client, { ...scope, action: 'animation-saved', taskId: identifier(taskId), versionId: identifier(options.version),
|
|
122
|
+
proof: assetGenerationTaskSavedSchema.parse(await document(options.receipt)) }))));
|
|
123
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const messages = {
|
|
2
|
+
en: {
|
|
3
|
+
decompose: 'Separate an image into a base and editable layers using the cloud workspace pipeline',
|
|
4
|
+
input: 'Input image path or image address', outputDir: 'Output directory',
|
|
5
|
+
engine: 'Layer engine: seedream (workspace) or qwen (legacy)', imageModel: 'Image model reference for workspace layer separation',
|
|
6
|
+
size: 'Workspace layer size: auto, 1K, 1.5K, 2K', prompt: 'Optional layer guidance', team: 'Team used for model configuration',
|
|
7
|
+
numLayers: 'Legacy Qwen layer count, 2–10', safety: 'Disable the legacy Qwen safety checker', format: 'Legacy Qwen output format',
|
|
8
|
+
ninePatch: 'Analyze stretchable nine-patch borders with the cloud workspace vision model',
|
|
9
|
+
output: 'Save the analysis as a file', visionModel: 'Vision model reference',
|
|
10
|
+
invalidFile: 'Input must be a regular image file of at most {maxBytes} bytes', downloadFailed: 'Image download failed ({status})',
|
|
11
|
+
tooLarge: 'Input image exceeds {maxBytes} bytes', unsupportedInput: 'Input must be a single image in a supported format',
|
|
12
|
+
unsupportedOutput: 'Unsupported output image type: {mimeType}', invalidEngine: '--engine must be seedream or qwen',
|
|
13
|
+
legacyOnly: '--num-layers, --no-safety-checker and --output-format require --engine qwen',
|
|
14
|
+
workspaceOnly: '--image-model, --size and --team-id require --engine seedream',
|
|
15
|
+
invalidSize: '--size must be auto, 1K, 1.5K or 2K', invalidCount: '--num-layers must be an integer between 2 and 10',
|
|
16
|
+
saved: 'Saved separated layers and decompose-layers-meta.json to {outputDir}', failed: 'Image command failed: {message}',
|
|
17
|
+
},
|
|
18
|
+
zh: {
|
|
19
|
+
decompose: '使用云工作区同款管线,将图片分解为底图和可编辑图层',
|
|
20
|
+
input: '输入图片路径或图片地址', outputDir: '输出目录',
|
|
21
|
+
engine: '分层引擎:seedream(云工作区)或 qwen(旧版)', imageModel: '云工作区图层分离的图像模型引用',
|
|
22
|
+
size: '云工作区分层尺寸:auto、1K、1.5K、2K', prompt: '可选图层分离提示词', team: '模型配置所属团队',
|
|
23
|
+
numLayers: '旧版 Qwen 图层数量,2–10', safety: '关闭旧版 Qwen 安全检测', format: '旧版 Qwen 输出格式',
|
|
24
|
+
ninePatch: '使用云工作区视觉模型分析点九图可拉伸边界', output: '将分析结果保存到文件', visionModel: '视觉模型引用',
|
|
25
|
+
invalidFile: '输入必须是大小不超过 {maxBytes} 字节的普通图片文件', downloadFailed: '图片下载失败({status})',
|
|
26
|
+
tooLarge: '输入图片超过 {maxBytes} 字节', unsupportedInput: '输入必须是支持格式的单张图片',
|
|
27
|
+
unsupportedOutput: '不支持的输出图片类型:{mimeType}', invalidEngine: '--engine 必须为 seedream 或 qwen',
|
|
28
|
+
legacyOnly: '--num-layers、--no-safety-checker 和 --output-format 需要使用 --engine qwen',
|
|
29
|
+
workspaceOnly: '--image-model、--size 和 --team-id 需要使用 --engine seedream',
|
|
30
|
+
invalidSize: '--size 必须为 auto、1K、1.5K 或 2K', invalidCount: '--num-layers 必须为 2 到 10 之间的整数',
|
|
31
|
+
saved: '已将分离图层与 decompose-layers-meta.json 保存到 {outputDir}', failed: '图片命令执行失败:{message}',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export function imageLayerMessage(key, values = {}) {
|
|
35
|
+
const language = (process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en').startsWith('zh') ? 'zh' : 'en';
|
|
36
|
+
return messages[language][key].replace(/\{(\w+)\}/g, (match, name) => String(values[name] ?? match));
|
|
37
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
5
|
+
import { resolveSandboxPath } from '../utils/sandbox-path.js';
|
|
6
|
+
import { imageLayerMessage as t } from './image-layer-messages.js';
|
|
7
|
+
import { captureGeneratedArtifact } from '../workspace-runtime/workspaces/artifact-receipts.js';
|
|
8
|
+
const MAX_IMAGE_BYTES = 30_000_000;
|
|
9
|
+
const isRemote = (input) => /^https?:\/\//i.test(input);
|
|
10
|
+
function localImageBytes(input, maxBytes) {
|
|
11
|
+
const filename = resolveSandboxPath(input);
|
|
12
|
+
const info = statSync(filename);
|
|
13
|
+
if (!info.isFile() || info.size > maxBytes) {
|
|
14
|
+
throw new Error(t('invalidFile', { maxBytes }));
|
|
15
|
+
}
|
|
16
|
+
return readFileSync(filename);
|
|
17
|
+
}
|
|
18
|
+
async function inputImage(input) {
|
|
19
|
+
let bytes;
|
|
20
|
+
if (isRemote(input)) {
|
|
21
|
+
const response = await fetch(input, { signal: AbortSignal.timeout(60_000) });
|
|
22
|
+
if (!response.ok || !response.body)
|
|
23
|
+
throw new Error(t('downloadFailed', { status: response.status }));
|
|
24
|
+
if (Number(response.headers.get('content-length')) > MAX_IMAGE_BYTES) {
|
|
25
|
+
await response.body.cancel();
|
|
26
|
+
throw new Error(t('tooLarge', { maxBytes: MAX_IMAGE_BYTES }));
|
|
27
|
+
}
|
|
28
|
+
const reader = response.body.getReader();
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let total = 0;
|
|
31
|
+
try {
|
|
32
|
+
while (true) {
|
|
33
|
+
const chunk = await reader.read();
|
|
34
|
+
if (chunk.done)
|
|
35
|
+
break;
|
|
36
|
+
total += chunk.value.byteLength;
|
|
37
|
+
if (total > MAX_IMAGE_BYTES)
|
|
38
|
+
throw new Error(t('tooLarge', { maxBytes: MAX_IMAGE_BYTES }));
|
|
39
|
+
chunks.push(chunk.value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await reader.cancel();
|
|
44
|
+
}
|
|
45
|
+
bytes = Buffer.concat(chunks);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
bytes = localImageBytes(input, MAX_IMAGE_BYTES);
|
|
49
|
+
}
|
|
50
|
+
const metadata = await sharp(bytes).metadata();
|
|
51
|
+
const inputMimeTypes = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' };
|
|
52
|
+
const mimeType = inputMimeTypes[metadata.format ?? ''];
|
|
53
|
+
if (!mimeType || (metadata.pages ?? 1) > 1)
|
|
54
|
+
throw new Error(t('unsupportedInput'));
|
|
55
|
+
return { imageBase64: bytes.toString('base64'), mimeType };
|
|
56
|
+
}
|
|
57
|
+
function imageExtension(mimeType) {
|
|
58
|
+
const formats = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
|
|
59
|
+
const extension = formats[mimeType];
|
|
60
|
+
if (!extension)
|
|
61
|
+
throw new Error(t('unsupportedOutput', { mimeType }));
|
|
62
|
+
return extension;
|
|
63
|
+
}
|
|
64
|
+
function saveLayer(outputDir, stem, layer) {
|
|
65
|
+
const file = `${stem}.${imageExtension(layer.mimeType)}`;
|
|
66
|
+
writeFileSync(join(outputDir, file), Buffer.from(layer.imageBase64, 'base64'));
|
|
67
|
+
const { imageBase64: _bytes, ...metadata } = layer;
|
|
68
|
+
return { ...metadata, file };
|
|
69
|
+
}
|
|
70
|
+
function fail(error) {
|
|
71
|
+
console.error(t('failed', { message: error instanceof Error ? error.message : String(error) }));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
export function registerImageLayerTools(image) {
|
|
75
|
+
image.command('decompose-layers')
|
|
76
|
+
.description(t('decompose'))
|
|
77
|
+
.requiredOption('--input <path>', t('input'))
|
|
78
|
+
.option('--output-dir <dir>', t('outputDir'), './decompose-layers-output')
|
|
79
|
+
.option('--engine <name>', t('engine'), 'seedream')
|
|
80
|
+
.option('--image-model <ref>', t('imageModel'))
|
|
81
|
+
.option('--size <size>', t('size'))
|
|
82
|
+
.option('--prompt <text>', t('prompt'), '')
|
|
83
|
+
.option('--team-id <id>', t('team'))
|
|
84
|
+
.option('--num-layers <n>', t('numLayers'), (value) => Number(value), 4)
|
|
85
|
+
.option('--no-safety-checker', t('safety'))
|
|
86
|
+
.option('--output-format <format>', t('format'), 'png')
|
|
87
|
+
.action(async (opts, command) => {
|
|
88
|
+
try {
|
|
89
|
+
const legacyFlags = ['numLayers', 'safetyChecker', 'outputFormat'].some(name => command.getOptionValueSource(name) === 'cli');
|
|
90
|
+
const explicitEngine = command.getOptionValueSource('engine') === 'cli';
|
|
91
|
+
const engine = legacyFlags && !explicitEngine ? 'qwen' : opts.engine;
|
|
92
|
+
if (!['seedream', 'qwen'].includes(engine))
|
|
93
|
+
throw new Error(t('invalidEngine'));
|
|
94
|
+
if (engine === 'seedream' && legacyFlags) {
|
|
95
|
+
throw new Error(t('legacyOnly'));
|
|
96
|
+
}
|
|
97
|
+
if (engine === 'qwen' && (opts.imageModel || opts.size || opts.teamId)) {
|
|
98
|
+
throw new Error(t('workspaceOnly'));
|
|
99
|
+
}
|
|
100
|
+
if (opts.size && !['auto', '1K', '1.5K', '2K'].includes(opts.size)) {
|
|
101
|
+
throw new Error(t('invalidSize'));
|
|
102
|
+
}
|
|
103
|
+
if (engine === 'qwen' && (!Number.isInteger(opts.numLayers) || opts.numLayers < 2 || opts.numLayers > 10)) {
|
|
104
|
+
throw new Error(t('invalidCount'));
|
|
105
|
+
}
|
|
106
|
+
const outputDir = resolveSandboxPath(opts.outputDir);
|
|
107
|
+
const input = opts.input;
|
|
108
|
+
const startedAt = Date.now();
|
|
109
|
+
const client = new AgentApiClient();
|
|
110
|
+
let metadata;
|
|
111
|
+
const savedImages = [];
|
|
112
|
+
if (engine === 'seedream') {
|
|
113
|
+
const result = await client.post('/image/layer-decomposition', {
|
|
114
|
+
...await inputImage(input),
|
|
115
|
+
prompt: opts.prompt || undefined,
|
|
116
|
+
size: opts.size || 'auto',
|
|
117
|
+
imageModelRef: opts.imageModel,
|
|
118
|
+
teamId: opts.teamId,
|
|
119
|
+
});
|
|
120
|
+
mkdirSync(outputDir, { recursive: true });
|
|
121
|
+
const base = saveLayer(outputDir, 'base', result.base);
|
|
122
|
+
const layers = result.layers.map((layer, index) => saveLayer(outputDir, `layer_${String(index).padStart(2, '0')}`, layer));
|
|
123
|
+
savedImages.push(...[base, ...layers].map(layer => join(outputDir, layer.file)));
|
|
124
|
+
metadata = { source: input, engine, base, layers, usage: result.usage };
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const maxBytes = Number.parseInt(process.env.PLAYCRAFT_IMAGE_MAX_INPUT_BYTES || '', 10);
|
|
128
|
+
const payload = {
|
|
129
|
+
...(isRemote(input) ? { imageUrl: input } : {
|
|
130
|
+
imageBase64: localImageBytes(input, maxBytes > 0 ? maxBytes : 512 * 1024 * 1024).toString('base64'),
|
|
131
|
+
}),
|
|
132
|
+
numLayers: opts.numLayers,
|
|
133
|
+
prompt: opts.prompt,
|
|
134
|
+
enableSafetyChecker: opts.safetyChecker !== false,
|
|
135
|
+
outputFormat: opts.outputFormat,
|
|
136
|
+
};
|
|
137
|
+
const result = await client.post('/image-layered/decompose', payload);
|
|
138
|
+
mkdirSync(outputDir, { recursive: true });
|
|
139
|
+
const layers = result.layers.map((layer) => {
|
|
140
|
+
const file = `layer_${String(layer.index).padStart(2, '0')}.${imageExtension(layer.mimeType)}`;
|
|
141
|
+
writeFileSync(join(outputDir, file), Buffer.from(layer.imageBase64, 'base64'));
|
|
142
|
+
savedImages.push(join(outputDir, file));
|
|
143
|
+
const { imageBase64: _bytes, ...details } = layer;
|
|
144
|
+
return { ...details, file };
|
|
145
|
+
});
|
|
146
|
+
metadata = { source: input, engine, requestId: result.requestId, numLayers: result.numLayers,
|
|
147
|
+
hasNsfwConcepts: result.hasNsfwConcepts, layers };
|
|
148
|
+
}
|
|
149
|
+
const elapsed = `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
150
|
+
writeFileSync(join(outputDir, 'decompose-layers-meta.json'), JSON.stringify({ ...metadata, elapsed }, null, 2));
|
|
151
|
+
for (const imagePath of savedImages)
|
|
152
|
+
await captureGeneratedArtifact(imagePath, 'decompose-layers');
|
|
153
|
+
console.log(t('saved', { outputDir }));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
fail(error);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
image.command('analyze-nine-patch')
|
|
160
|
+
.description(t('ninePatch'))
|
|
161
|
+
.requiredOption('--input <path>', t('input'))
|
|
162
|
+
.option('--output <path>', t('output'))
|
|
163
|
+
.option('--vision-model <ref>', t('visionModel'))
|
|
164
|
+
.option('--team-id <id>', t('team'))
|
|
165
|
+
.action(async (opts) => {
|
|
166
|
+
try {
|
|
167
|
+
const input = await inputImage(opts.input);
|
|
168
|
+
const result = await new AgentApiClient().post('/image/analyze-nine-patch', {
|
|
169
|
+
imageBase64: input.imageBase64,
|
|
170
|
+
visionModelRef: opts.visionModel,
|
|
171
|
+
teamId: opts.teamId,
|
|
172
|
+
});
|
|
173
|
+
const json = JSON.stringify(result, null, 2);
|
|
174
|
+
if (opts.output) {
|
|
175
|
+
const output = resolveSandboxPath(opts.output);
|
|
176
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
177
|
+
writeFileSync(output, json);
|
|
178
|
+
}
|
|
179
|
+
console.log(json);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
fail(error);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|