diff --git a/.agents/skills/xyq-marketing-skill b/.agents/skills/xyq-marketing-skill new file mode 120000 index 0000000..834f2b5 --- /dev/null +++ b/.agents/skills/xyq-marketing-skill @@ -0,0 +1 @@ +../../skills/xyq-marketing-skill \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb8033a..972adc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: - name: Test CLI-only installation and Skill bootstrap run: node scripts/install-cli.test.js + - name: Test bundled Skills and marketing API workflow + run: node scripts/skills.test.js && npm run test:marketing-skill + go: name: Go tests runs-on: ubuntu-22.04 diff --git a/README.md b/README.md index 76b04dc..fb439fe 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,44 @@ ## 技能列表 -本仓库在 `skills/` 目录下包含两个智能体技能: +本仓库在 `skills/` 目录下包含三个智能体技能: | 技能 | 说明 | 路径 | |-------|-------------|------| | `xyq-short-drama-skill` | 短剧工作流技能,支持提交创作任务、上传参考文件、查询进度、列出会话文件和下载产物。 | `skills/short-drama/` | | `xyq-skill` | 图片生成与参考图编辑、视频生成、视频超分与擦字幕、异步结果交付、个人 Canvas 编辑、积分查询及登录授权。 | `skills/xyq-nest-skill/` | +| `xyq-marketing-skill` | 商品图文营销一键成片:剧情广告、品牌大片、达人带货,含素材上传、进度查询与媒体下载。 | `skills/xyq-marketing-skill/` | ### 技能路由 - 图片生成与参考图编辑、视频生成(含首尾帧和参考素材)、视频超分、擦字幕、结果查询、个人 Canvas 编辑、积分和授权由 `xyq-skill` 处理。 - 短剧生成、续写、改写、人物设定、分集创作和短剧会话文件处理使用 `xyq-short-drama-skill`。 +- 商品图文营销成片、剧情广告、品牌大片和达人带货视频使用 `xyq-marketing-skill`;由公开营销 API 完成创作编排。 需要补充、选择或确认时,使用宿主实际暴露且当前模式允许的工具:Codex 的 `request_user_input` / `request_user_input_async`、WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。 +## 小云雀营销成片技能 + +入口:[skills/xyq-marketing-skill/SKILL.md](skills/xyq-marketing-skill/SKILL.md)。使用 [官网](https://xyq.jianying.com/cli?tab=api) 已开放的营销 API,支持素材上传、营销视频提交、结果查询与下载、积分查询。安装器会从 `skills/` 自动安装该 Skill,也可以单独安装: + +```bash +npx skills add Pippit-dev/cli --skill xyq-marketing-skill +``` + +需要 Node.js 16+,使用 Skill 内自包含脚本,无额外 npm 依赖。脚本从当前进程读取 `XYQ_ACCESS_KEY`;API 与 CLI 可使用同一 Access Key,但 CLI 浏览器登录不会设置此环境变量。密钥在本机安全配置,不写入请求 JSON 或命令参数。 + +```bash +# 从仓库根目录执行;营销请求字段见接口契约,默认只预览 +node skills/xyq-marketing-skill/scripts/marketing.js generate --request request.json --dry-run +# 用户已要求真实生成时提交,然后用返回的真实 ID 取回媒体 +node skills/xyq-marketing-skill/scripts/marketing.js generate --request request.json --execute +node skills/xyq-marketing-skill/scripts/marketing.js query --thread-id THREAD_ID --run-id RUN_ID --wait --output-dir ./results +``` + +参数与错误处理见 [接口契约](skills/xyq-marketing-skill/references/api.md),完整示例见 [商品图到营销视频](skills/xyq-marketing-skill/examples/product-video.md)。这是 Skill 脚本入口,不是新增的 Go CLI 子命令。营销 API 未公开团队切换字段,不宣称支持团队空间切换。 + +维护后运行 `node scripts/marketing-skill.test.js` 和 `node scripts/skills.test.js`,验证请求、上传、任务状态、下载与 Skill 引用。测试使用本地模拟服务,不会创建真实付费任务。 + ## 小云雀图片、视频与媒体处理技能 入口:[skills/xyq-nest-skill/SKILL.md](skills/xyq-nest-skill/SKILL.md)。普通生成请求也直接使用对应 CLI;素材路径交给命令内部上传,异步查询自动下载,最后通过宿主交付真实媒体附件。 diff --git a/package-lock.json b/package-lock.json index 3e2e321..37d0c12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pippit-dev/cli", - "version": "1.0.29", + "version": "1.0.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pippit-dev/cli", - "version": "1.0.29", + "version": "1.0.30", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index b2fdf58..67b1c15 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "@pippit-dev/cli", - "version": "1.0.29", + "version": "1.0.30", "description": "Pippit CLI", "bin": { "pippit-tool-cli": "scripts/run.js" }, "scripts": { "postinstall": "node scripts/install.js", + "test:marketing-skill": "node scripts/marketing-skill.test.js", "prepack": "node scripts/prepare-canvas-runtime.js", "prepare:canvas-runtime": "node scripts/prepare-canvas-runtime.js", - "test": "node scripts/version-check.test.js && node scripts/skills.test.js && node scripts/install-wizard.test.js && node scripts/install-cli.test.js && node scripts/run-help.test.js && node scripts/canvas-command.test.js && node scripts/canvas-command-schema.test.js && node scripts/canvas-command-guides.test.js && node scripts/prepare-canvas-runtime.test.js && node scripts/canvas-command-runtime.test.js && npm run test:canvas-operations && go test ./... && go vet ./...", + "test": "node scripts/version-check.test.js && node scripts/skills.test.js && node scripts/marketing-skill.test.js && node scripts/install-wizard.test.js && node scripts/install-cli.test.js && node scripts/run-help.test.js && node scripts/canvas-command.test.js && node scripts/canvas-command-schema.test.js && node scripts/canvas-command-guides.test.js && node scripts/prepare-canvas-runtime.test.js && node scripts/canvas-command-runtime.test.js && npm run test:canvas-operations && go test ./... && go vet ./...", "verify:canvas-runtime": "node scripts/prepare-canvas-runtime.js --check-only", "test:canvas-runtime": "node scripts/canvas-command-runtime.test.js", "test:canvas-operations": "node scripts/canvas-command-canvas-operations.test.js && node scripts/canvas-command-scene3d-operations.test.js && node scripts/canvas-command-timeline-operations.test.js" diff --git a/scripts/marketing-skill.test.js b/scripts/marketing-skill.test.js new file mode 100644 index 0000000..038fe52 --- /dev/null +++ b/scripts/marketing-skill.test.js @@ -0,0 +1,203 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const { spawnSync } = require('child_process'); +const { BASE, PATHS, validate, createClient, main } = require('../skills/xyq-marketing-skill/scripts/marketing'); + +async function test() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xyq-marketing-')); + const received = []; + let respond; + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + received.push({ url: req.url, method: req.method, headers: req.headers, body: Buffer.concat(chunks) }); + respond(req, res); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + // Real HTTP streaming on loopback; public HTTPS destination is checked before mapping. + const targets = []; + const request = (url, opts, callback) => { + targets.push(url.href); + return http.request({ hostname: '127.0.0.1', port: server.address().port, path: url.pathname + url.search, ...opts }, callback); + }; + const client = createClient({ key: 'test-secret', request, timeout: 1000 }); + const json = value => { respond = (_, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; }; + const ids = { thread_id: 'marketing-thread', run_id: 'marketing-run' }; + const response = (state, extra = {}) => ({ ret: '0', data: { ...ids, run_state: state, ...extra } }); + try { + const body = { message: '按原文生成 🛍️', general_agent_settings: { video_model: 'seedance2.0_vision', ratio: 3, show_subtitle: false, duration_start: 15, duration_end: 15 } }; + assert.strictEqual(validate(body), body); + for (const invalid of [ + { message: '', general_agent_settings: {} }, + { message: 'x' }, + { message: 'x', general_agent_settings: {} }, + { message: 'x', general_agent_settings: { video_model: ' ' } }, + { message: 'x', general_agent_settings: { video_model: 'future-model', ratio: '9:16' } }, + { message: 'x', general_agent_settings: { video_model: 'future-model', show_subtitle: 'false' } }, + { message: 'x', general_agent_settings: { video_model: 'future-model', duration_start: 20, duration_end: 15 } }, + { message: 'x', general_agent_settings: { video_model: 'future-model', duration_start: 1.5 } }, + { message: 'x', general_agent_settings: { video_model: 'future-model', duration_start: 2147483648 } }, + { message: 'x', general_agent_settings: {}, asset_ids: [123] }, + { message: 'x', general_agent_settings: {}, TeamID: 'invented' }, + ]) assert.throws(() => validate(invalid)); + // New server model values remain usable without a Skill update. + validate({ message: 'x', general_agent_settings: { video_model: 'future-model' } }); + + const file = path.join(dir, 'request.json'); + fs.writeFileSync(file, JSON.stringify(body)); + const output = []; + assert.strictEqual(await main(['generate', '--request', file], { + env: {}, out: line => output.push(JSON.parse(line)), clientFactory: () => { throw new Error('dry-run contacted API'); }, + }), 0); + assert.deepStrictEqual(output[0], { dry_run: true, url: BASE + PATHS.generate, body }); + await assert.rejects(main(['generate', '--request', file, '--execute', '--dry-run']), /不能同时/); + await assert.rejects(main(['query', '--thread-id', ids.thread_id]), /run-id/); + await assert.rejects(main(['query', '--thread-id', ids.thread_id, '--run-id', ids.run_id, '--timeout', '0']), /timeout/); + await assert.rejects(main(['generate', '--request', file, '--source', 'codex']), /无效/); + await assert.rejects(createClient({ key: '', request }).api('balance', {}), /XYQ_ACCESS_KEY/); + assert.strictEqual(received.length, 0); + + json({ ret: 0, log_id: 'log-submit', data: { run: { ...ids, state: 1 }, web_thread_link: 'https://xyq.jianying.com/task' } }); + assert.strictEqual(await main(['generate', '--request', file, '--execute'], { out: () => {}, clientFactory: () => client }), 0); + assert.strictEqual(received.length, 1); + assert.deepStrictEqual(JSON.parse(received[0].body), body); + assert.strictEqual(received[0].url, PATHS.generate); + assert.strictEqual(received[0].headers.authorization, 'Bearer test-secret'); + assert.strictEqual(targets[0], BASE + PATHS.generate); + + const uploadFile = path.join(dir, '商品.png'); + const imageBytes = Buffer.from([0, 1, 2, 255, 13, 10]); + fs.writeFileSync(uploadFile, imageBytes); + json({ ret: '0', data: { pippit_asset_id: 'asset-real' } }); + assert.strictEqual((await client.upload(uploadFile)).data.pippit_asset_id, 'asset-real'); + const upload = received[received.length - 1]; + assert.strictEqual(upload.url, PATHS.upload); + assert(upload.body.includes(imageBytes)); + assert(upload.body.includes(Buffer.from('name="file"'))); + assert(upload.headers['content-type'].startsWith('multipart/form-data; boundary=')); + assert.strictEqual(Number(upload.headers['content-length']), upload.body.length); + assert(!upload.body.includes(Buffer.from('test-secret'))); + await assert.rejects(client.upload(dir), /非空文件/); + + json({ ret: '0', data: { total_remain_amount: '0' } }); + assert.strictEqual((await client.api('balance', {})).data.total_remain_amount, '0'); + json({ ret: '12004', errmsg: 'permission denied', log_id: 'log-failure' }); + await assert.rejects(client.api('generate', body), /12004.*permission denied.*log-failure/); + json({ ret: '0', data: { run: { thread_id: ids.thread_id } } }); + await assert.rejects(client.api('generate', body), /禁止自动重提/); + json({ ret: false, data: {} }); + await assert.rejects(client.api('balance', {}), /API 失败/); + json(response('3', { run_id: 'different' })); + await assert.rejects(client.api('query', ids), /不一致/); + json(response('unknown')); + await assert.rejects(client.api('query', ids), /run_state 缺失或格式异常/); + respond = (_, res) => { res.writeHead(302, { Location: 'https://other.example/secret' }); res.end(); }; + let before = received.length; + await assert.rejects(client.api('generate', body), /HTTP 302/); + assert.strictEqual(received.length - before, 1, 'API redirect must not be followed'); + respond = (_, res) => { res.writeHead(504); res.end(); }; + before = received.length; + await assert.rejects(client.api('generate', body), /HTTP 504/); + assert.strictEqual(received.length - before, 1, 'submission must not retry'); + respond = (_, res) => res.end('not json'); + await assert.rejects(client.api('generate', body), /有效 JSON/); + respond = () => {}; + await assert.rejects(createClient({ key: 'test-secret', request, timeout: 20 }).api('generate', body), /超时/); + + const queryArgs = ['query', '--thread-id', ids.thread_id, '--run-id', ids.run_id]; + const seenActions = []; + let states = [1, '2', 7, '8', 3]; + let clock = 0; + const outputs = []; + const runtime = { + out: line => outputs.push(JSON.parse(line)), now: () => clock, pause: async ms => { clock += ms; }, + clientFactory: () => ({ api: async (action, input) => { + seenActions.push(action); assert.deepStrictEqual(input, ids); + return response(states.shift(), { video_urls: ['https://cdn.example/final.mp4'] }); + } }), + }; + assert.strictEqual(await main([...queryArgs, '--wait'], runtime), 0); + assert.deepStrictEqual(seenActions, ['query', 'query', 'query', 'query', 'query']); + assert.strictEqual(outputs[0].data.run_state, 3); + assert.strictEqual(clock, 40000); + for (const state of [6, '6', 9, '9']) { + states = [state, 3]; + const beforeClock = clock; + assert.strictEqual(await main([...queryArgs, '--wait'], runtime), 4); + assert.strictEqual(states.length, 1, 'input-required run must not be polled again'); + assert.strictEqual(clock, beforeClock, 'input-required run must not sleep'); + const result = outputs[outputs.length - 1]; + assert.strictEqual(result.action_required, true); + assert.strictEqual(result.run_state_name, state == 9 ? 'HITL_Interrupt' : 'InputRequired'); + assert.deepStrictEqual(result.data, response(state, { video_urls: ['https://cdn.example/final.mp4'] }).data); + } + states = [2, 7, 9, 3]; + assert.strictEqual(await main([...queryArgs, '--wait'], runtime), 4); + assert.strictEqual(states.length, 1); + for (const state of [0, 99, '99']) { + states = [state, 3]; + assert.strictEqual(await main([...queryArgs, '--wait'], runtime), 5); + assert.strictEqual(states.length, 1); + assert.strictEqual(outputs[outputs.length - 1].unknown_state, true); + assert.strictEqual(outputs[outputs.length - 1].data.run_state, state); + } + for (const state of [4, '5']) { + states = [state]; + assert.strictEqual(await main([...queryArgs, '--wait'], runtime), 2); + assert.strictEqual(states.length, 0); + } + states = [8, 2]; + assert.strictEqual(await main([...queryArgs, '--wait', '--max-wait', '1'], runtime), 3); + assert.strictEqual(states.length, 1, 'stop at deadline without a further query'); + assert.strictEqual(outputs[outputs.length - 1].wait_timed_out, true); + states = [2]; + assert.strictEqual(await main(queryArgs, runtime), 0, 'single pending query is not a completed video'); + const emptyRuntime = { out: () => {}, clientFactory: () => ({ api: async () => response(3, { video_urls: [] }) }) }; + assert.strictEqual(await main([...queryArgs, '--wait'], emptyRuntime), 2); + + const media = Buffer.from('test-media-bytes'); + respond = (req, res) => { + if (req.url === '/redirect') { res.writeHead(302, { Location: 'https://cdn.example/final.mp4' }); res.end(); } + else { res.setHeader('Content-Type', 'video/mp4'); res.end(media); } + }; + before = received.length; + const dest = path.join(dir, 'video.mp4'); + await client.download('https://cdn.example/redirect', dest); + assert.deepStrictEqual(fs.readFileSync(dest), media); + for (const call of received.slice(before)) assert.strictEqual(call.headers.authorization, undefined); + await assert.rejects(client.download('https://cdn.example/final.mp4', dest), /EEXIST/); + assert.deepStrictEqual(fs.readFileSync(dest), media); + await assert.rejects(client.download('http://cdn.example/final.mp4', path.join(dir, 'bad')), /HTTPS/); + respond = (_, res) => { res.setHeader('Content-Type', 'text/html'); res.end('login'); }; + await assert.rejects(client.download('https://cdn.example/login', path.join(dir, 'login.mp4')), /未取得媒体/); + assert(!fs.existsSync(path.join(dir, 'login.mp4'))); + respond = (_, res) => res.end(); + const emptyFile = path.join(dir, 'empty.mp4'); + await assert.rejects(client.download('https://cdn.example/empty', emptyFile), /为空/); + assert(!fs.existsSync(emptyFile)); + + // Exercise query + download orchestration and preserve raw IDs before delivery. + respond = (req, res) => { + if (req.url === PATHS.query) res.end(JSON.stringify(response('3', { video_urls: ['https://cdn.example/v.mp4'], image_urls: ['https://cdn.example/i.png'] }))); + else { res.setHeader('Content-Type', 'application/octet-stream'); res.end(media); } + }; + const delivery = []; + assert.strictEqual(await main([...queryArgs, '--output-dir', path.join(dir, 'results')], { out: line => delivery.push(JSON.parse(line)), clientFactory: () => client }), 0); + assert.strictEqual(delivery[0].data.run_id, ids.run_id); + assert.strictEqual(delivery[delivery.length - 1].downloaded_files.length, 2); + for (const file of delivery[delivery.length - 1].downloaded_files) assert.deepStrictEqual(fs.readFileSync(file.path), media); + + const executable = path.resolve(__dirname, '../skills/xyq-marketing-skill/scripts/marketing.js'); + const help = spawnSync(process.execPath, [executable, '--help'], { encoding: 'utf8', env: { ...process.env, XYQ_ACCESS_KEY: '' } }); + assert.strictEqual(help.status, 0); + assert(help.stdout.includes('generate --request')); + console.log('Marketing Skill: validation, multipart, API errors, no replay, polling and media delivery passed'); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + } +} +test().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/skills.test.js b/scripts/skills.test.js index 18e0ef9..80148ab 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -7,6 +7,7 @@ const { cleanupLegacyGlobalSkills } = require("./skills"); const repoRoot = path.resolve(__dirname, ".."); const generalSkillPath = path.join(repoRoot, "skills", "xyq-nest-skill", "SKILL.md"); const shortDramaSkillPath = path.join(repoRoot, "skills", "short-drama", "SKILL.md"); +const marketingSkillPath = path.join(repoRoot, "skills", "xyq-marketing-skill", "SKILL.md"); const readmePath = path.join(repoRoot, "README.md"); function readRequiredFile(filePath) { @@ -15,17 +16,24 @@ function readRequiredFile(filePath) { } function assertFrontmatterName(content, expectedName) { - const match = content.match(/^---\n[\s\S]*?^name:\s*([^\n]+)$/m); + const match = content.match(/^---\r?\n[\s\S]*?^name:\s*([^\n]+)$/m); assert.ok(match, `missing frontmatter name for ${expectedName}`); assert.strictEqual(match[1].trim(), expectedName); } +for (const newline of ["\n", "\r\n"]) { + assertFrontmatterName(["---", "name: xyq-skill", "---", ""].join(newline), "xyq-skill"); + assert.throws(() => assertFrontmatterName(["---", "description: test", "---", ""].join(newline), "xyq-skill"), /missing frontmatter name/); +} + const generalSkill = readRequiredFile(generalSkillPath); const shortDramaSkill = readRequiredFile(shortDramaSkillPath); +const marketingSkill = readRequiredFile(marketingSkillPath); const readme = readRequiredFile(readmePath); assertFrontmatterName(generalSkill, "xyq-skill"); assertFrontmatterName(shortDramaSkill, "xyq-short-drama-skill"); +assertFrontmatterName(marketingSkill, "xyq-marketing-skill"); assert.ok(generalSkill.includes("user-invocable: true"), "xyq-skill must remain user-invocable"); assert.ok( shortDramaSkill.includes("user-invocable: true"), @@ -117,6 +125,22 @@ for (const folder of ["commands", "workflows", "examples", "scripts"]) { } } } + +// The marketing Skill must also work when installed by itself. +const marketingRoot = path.dirname(marketingSkillPath); +const marketingDocuments = collectSkillDocuments(marketingSkillPath); +for (const folder of ["references", "examples"]) { + for (const file of fs.readdirSync(path.join(marketingRoot, folder))) { + if (file.endsWith(".md")) assert(marketingDocuments.has(path.join(marketingRoot, folder, file)), `Unreachable marketing document: ${file}`); + } +} +readRequiredFile(path.join(marketingRoot, "scripts", "marketing.js")); +const marketingDiscovery = path.join(repoRoot, ".agents", "skills", "xyq-marketing-skill"); +// Git on Windows may check out a symlink as a file containing its target. +const discoveryTarget = fs.lstatSync(marketingDiscovery).isSymbolicLink() + ? fs.readlinkSync(marketingDiscovery) : readRequiredFile(marketingDiscovery).trim(); +assert.strictEqual(path.resolve(path.dirname(marketingDiscovery), discoveryTarget), marketingRoot); + for (const requiredText of ["request_user_input", "ask_user_question", "credits"]) { assert.ok( shortDramaDocuments.includes(requiredText), @@ -127,6 +151,7 @@ for (const requiredText of ["request_user_input", "ask_user_question", "credits" for (const requiredText of [ "skills/xyq-nest-skill/", "skills/short-drama/", + "skills/xyq-marketing-skill/", "pippit-tool-cli generate-video", "request_user_input", "ask_user_question", @@ -151,6 +176,7 @@ for (const skillName of [ "xyq-nest-skill", "xyq-short-drama-skill", "xyq-skill", + "xyq-marketing-skill", ]) { fs.mkdirSync(path.join(globalSkillsDir, skillName)); } @@ -161,5 +187,6 @@ assert.strictEqual(fs.existsSync(path.join(globalSkillsDir, "pippit-short-drama- assert.strictEqual(fs.existsSync(path.join(globalSkillsDir, "xyq-nest-skill")), false); assert.strictEqual(fs.existsSync(path.join(globalSkillsDir, "xyq-short-drama-skill")), true); assert.strictEqual(fs.existsSync(path.join(globalSkillsDir, "xyq-skill")), true); +assert.strictEqual(fs.existsSync(path.join(globalSkillsDir, "xyq-marketing-skill")), true); fs.rmSync(globalSkillsDir, { force: true, recursive: true }); diff --git a/skills/xyq-marketing-skill/SKILL.md b/skills/xyq-marketing-skill/SKILL.md new file mode 100644 index 0000000..2c65e7e --- /dev/null +++ b/skills/xyq-marketing-skill/SKILL.md @@ -0,0 +1,51 @@ +--- +name: xyq-marketing-skill +description: 使用小云雀公开营销 API,根据商品图文生成剧情广告、品牌大片或达人带货营销视频;上传素材、提交营销成片、查询进度、下载交付结果及查询积分。用户要求小云雀营销一键成片或接入营销 API 时使用。 +user-invocable: true +metadata: + {"openclaw": {"emoji": "🛍️", "requires": {"bins": ["node"], "env": ["XYQ_ACCESS_KEY"]}}} +--- + +# 小云雀营销成片 + +营销一键成片使用独立的公开 API,由服务端编排创作。剧情广告、品牌大片和达人带货由用户的自然语言指令表达,不虚构 `mode`、`template` 等参数。普通模型直出、画布编辑和短剧创作应使用各自已有 Skill;不要把营销成片替换成一次普通生视频。 + +## Setup + +本 Skill 自包含,只需要 Node.js 16+,不依赖其它 Skill 或 npm 安装。脚本位置为 `"{baseDir}/scripts/marketing.js"`;下面示例中的相对路径从本 Skill 目录执行。 + +API 和 CLI 可以使用同一个 Access Key,但脚本只读取当前进程的 `XYQ_ACCESS_KEY`。CLI 浏览器登录保存的凭据不会自动变成环境变量,不读取系统钥匙串或 CLI 凭据文件。缺少变量时,引导用户在 [官网 API 页](https://xyq.jianying.com/cli?tab=api) 管理 Access Key 并在本机安全配置,不能让用户在聊天中发送密钥,也不要将密钥写入请求文件、命令参数或日志。 + +先按 [接口契约](references/api.md) 准备请求;需要完整执行示例时读 [商品图到营销视频](examples/product-video.md)。 + +## Workflow + +1. 确认用户要生成的营销内容与商品素材。保留用户原始指令,不主动扩写或加入未经提供的卖点。仅查询、接入咨询或估算不提交生成;明确要求生成即可执行,不重复询问已给出的授权。必要的补充信息用宿主当前允许的 `request_user_input_async`、`request_user_input` 或 `ask_user_question` 询问,没有工具时用普通聊天。 +2. 有本地商品素材时逐个上传,保留返回的 `data.pippit_asset_id`。远程素材先取得用户授权使用的本地文件;不能把 URL 或路径放进 `asset_ids`。用户已提供有效资产 ID 时直接复用。无素材的纯文字请求无需上传。 +3. 写入 UTF-8 JSON 请求文件。`message` 保留用户指令;`general_agent_settings.video_model` 必填,不能传 `{}`。用户指定模型时原样使用;未指定时询问,或在用户已明确授权“你决定”等选择范围内选定并说明。其它选项按用户给定或已授权的偏好设置,不静默换模型。`thread_id` 仅在继续已有营销会话时传入真实 ID。 +4. 先预览校验,已获生成授权后使用 `--execute` 提交。立即保存响应并展示真实 `data.web_thread_link`,保留 `data.run.thread_id`、`data.run.run_id`。缺少网页链接时只报告实际返回信息,不自行拼接链接。 +5. 用当前 thread/run 查询到结束并下载媒体。遇到确认或问卷时沿用已有授权;缺少必要选择再问用户。确认后继续同一 thread,并取得最新 run_id 再查询:旧 Run 可永久保持等待交互状态。可通过宿主浏览器查看已返回的网页链接;安装了 `pippit-tool-cli` 时,也可用 `get-thread --thread-id THREAD_ID` 读取各 Run 的真实 ID。不要用旧 Run 重复确认或重新生成。提交成功、网页链接或进度链接都不等于交付完成。 + +```bash +node scripts/marketing.js upload --file /path/to/product.png +node scripts/marketing.js generate --request /path/to/marketing-request.json --dry-run +node scripts/marketing.js generate --request /path/to/marketing-request.json --execute +node scripts/marketing.js query --thread-id THREAD_ID --run-id RUN_ID --wait --max-wait 900 --output-dir /path/to/results +node scripts/marketing.js balance +``` + +生成默认是离线预览;预览不需要密钥。上传、查询、余额是实际 API 请求。`--timeout` 设置单请求总时限(秒,默认 60)。`--wait` 由脚本每 10 秒查询,默认最多 900 秒;不再叠加其它轮询器。没有 `--wait` 时只查询一次。脚本输出逐行 JSON,查询下载前先输出服务端响应,再逐个输出已下载文件,最后输出含 `downloaded_files` 的响应。 + +## Completion and Recovery + +- `ret` 为字符串或数字 `0` 才是 API 业务成功。查询 `data.run_state`:1/2/7 为已提交或进行中;8 为程序中断(可能等待工具回调),有时限地继续查询;3 为本轮完成;4/5 为失败/取消;6/9 为等待用户交互。不能把退出码 0 的单次进行中查询当作生成完成。 +- 退出码 4:6=`InputRequired`,9=`HITL_Interrupt`(人工交互中断,当前 Run 的终态)。输出原始响应、`run_state_name`、`action_required=true` 和下一步说明,立即停止轮询。9 不表示视频失败或完成;在同一会话处理确认后查询最新 Run。 +- 退出码 5:未知或未指定状态。保留原始响应和任务 ID,停止自动轮询并诊断,不重提生成。 +- 退出码 2:生成失败、取消或成功却没有视频。展示 `fail_reason` 或“任务成功但未返回视频”,不编造产物、不自动重提。成功但仅有图片时,原始响应仍包含图片链接,可如实交付为部分产物。 +- 退出码 3:等待预算耗尽。保留任务 ID 和最后状态;可继续用相同查询命令等待。网络错误、状态格式异常、业务错误或参数错误退出码 1,先诊断,不自动重发生成。提交超时可能已经创建任务;没有 ID 时报告结果不明确,先查官网任务或联系支持核对 `log_id`。 +- 下载失败时,原始响应和已下载文件记录已输出,可用相同 ID 重新查询。脚本只新建文件,不覆盖已有结果;下载不带 API 鉴权头。生产 API 固定使用 `https://xyq.jianying.com`,不跟随鉴权请求重定向。 +- 检查本地媒体可打开、内容类型和时长等与交付相符;成功下载仅证明取得了字节,不能宣称创意质量已验收。将每个最终视频/图片通过宿主的附件或媒体预览能力展示,URL/路径作为补充。若当前宿主无法展示附件,明确说明并提供可用本地文件或下载链接。 + +## Scope + +本 Skill 仅使用正式公开营销接口;不宣称支持团队空间切换。鉴权范围由用户配置的 Access Key 和服务端决定,公开请求没有 `TeamID` 字段,不自行加入团队字段或跨账号复用资产/任务 ID。沉浸式短片、火山引擎服务和现有 CLI 的来源统计参数不在此脚本范围;不要把 `--source` 等未公开字段传给营销接口。 diff --git a/skills/xyq-marketing-skill/examples/product-video.md b/skills/xyq-marketing-skill/examples/product-video.md new file mode 100644 index 0000000..62f23c8 --- /dev/null +++ b/skills/xyq-marketing-skill/examples/product-video.md @@ -0,0 +1,45 @@ +# 商品图到营销视频 + +用户示例:“用这张保温杯商品图做一个 15 秒竖屏达人带货视频,不要字幕,用 Seedance 2.0 VIP,1080p。” + +1. 确认当前进程已配置 `XYQ_ACCESS_KEY`,然后上传用户给出的真实文件: + + ```bash + node scripts/marketing.js upload --file /path/to/cup.png + ``` + +2. 将返回的 `data.pippit_asset_id` 填入 UTF-8 请求文件。下面的 `ACTUAL_UPLOADED_ASSET_ID` 是占位说明,不能原样提交;不另补用户没选的模型或分辨率。 + + ```json + { + "message": "用这张保温杯商品图做一个 15 秒竖屏达人带货视频,不要字幕,用 Seedance 2.0 VIP,1080p。", + "asset_ids": ["ACTUAL_UPLOADED_ASSET_ID"], + "general_agent_settings": { + "ratio": 3, + "duration_start": 15, + "duration_end": 15, + "show_subtitle": false, + "video_model": "seedance2.0_vision", + "video_resolution": "720p" + } + } + ``` + +3. 预览后提交;用户已经要求生成,执行时不再索要相同授权。 + + ```bash + node scripts/marketing.js generate --request /path/to/request.json --dry-run + node scripts/marketing.js generate --request /path/to/request.json --execute + ``` + +4. 保存真实响应中的 thread/run ID,展示返回的网页链接,然后等待和下载: + + ```bash + node scripts/marketing.js query --thread-id ACTUAL_THREAD_ID --run-id ACTUAL_RUN_ID --wait --output-dir /path/to/results + ``` + +5. 若查询退出码为 4、`run_state=6/9`,打开提交时返回的网页查看待确认项,沿用用户已有授权;缺少必要选择时再问用户。确认后取同一 thread 的最新 run_id,重新执行查询命令,不重发生成请求,也不再查已中断的旧 Run。 + +6. 读取最终 `run_state` 和 `downloaded_files`,确认视频可打开并将实际本地文件作为媒体交付。用户只要“看之前那条生成好了没”时,从第 4 步开始,不能重新上传或提交。 + +错误、空结果和超时按 [完成与恢复规则](../SKILL.md#completion-and-recovery) 处理。 diff --git a/skills/xyq-marketing-skill/references/api.md b/skills/xyq-marketing-skill/references/api.md new file mode 100644 index 0000000..b7ec37e --- /dev/null +++ b/skills/xyq-marketing-skill/references/api.md @@ -0,0 +1,63 @@ +# Marketing API Contract + +来源:[官网 API 入口](https://xyq.jianying.com/cli?tab=api) 链接的 [正式接口文档](https://bytedance.larkoffice.com/docx/CQOYdJNLioLz6fxRzKXcCsKLnJh)。2026-09-22 核对 revision 597。以下仅整理本 Skill 使用的字段;模型可用性、积分和权限以服务端实际响应为准。 + +## Endpoints + +Base URL:`https://xyq.jianying.com`。全部使用 POST,认证为 `Authorization: Bearer `,`Accept: application/json`。 + +| 操作 | 路径 | 请求体 | +| --- | --- | --- | +| 上传单文件 | `/api/biz/v1/skill/upload_file` | multipart/form-data,字段 `file`,boundary 由脚本生成 | +| 营销成片 | `/api/biz/v1/agent/submit_marketing_run` | JSON,字段见下表 | +| 查询结果 | `/api/biz/v1/agent/query_generate_video_result` | JSON:真实 `thread_id`、`run_id` | +| 查询积分 | `/api/biz/v1/skill/get_credit_balance` | JSON:`{}` | + +## Generate Request + +| 字段 | 类型 | 使用规则 | +| --- | --- | --- | +| `message` | string | 必填,非空的原始创作指令 | +| `asset_ids` | string[] | 可选,上传返回的真实 `data.pippit_asset_id` | +| `general_agent_settings` | object | 必传,且必须包含非空 `video_model` | +| `thread_id` | string | 可选,只在继续已有营销会话时使用 | + +设置块必须传对象,且 `video_model` 必填。2026-09-22 实际调用及服务端 `validateMarketingGeneralAgentSettings` 均确认:`{}` 或仅有比例/时长会返回 `ret=2`、缺少 `video_model`。不再按文档中的默认策略说明发送空对象;预览和提交都先本地校验。用户未指定模型时先询问,已有明确选择授权时按授权选择,不隐式降级。 + +| 设置字段 | 类型 | 含义 | +| --- | --- | --- | +| `ratio` | int32 | `2=16:9`、`3=9:16`、`4=4:3`、`5=3:4`、`6=1:1`;不是比例字符串 | +| `duration_start` / `duration_end` | int32 | 正整数秒;精确时长两者相等;下限不能大于上限 | +| `show_subtitle` | bool | 是否展示字幕;显式 `false` 必须保留 | +| `video_model` | string | 必填;模型原始标识,保留大小写 | +| `video_resolution` | string | 分辨率偏好,如 `480p`、`720p`、`1080p` | + +核对时文档列出 VIP 模型 `seedance2.0_fast_vision`、`seedance2.0_vision`、`Seedance_2.0_mini`,非 VIP 模型 `Seedance_2.0_mini_lite`;注明 `1080p` 目前仅支持 `seedance2.0_vision`。脚本不固化模型白名单,不自动降级或代换用户选择;若服务端拒绝,保留错误并说明参数或权限差异。 + +## Responses + +HTTP 成功还需检查 `ret` 为 `"0"` 或 `0`;失败保留 `errmsg` 和 `log_id`。 + +- 上传:`data.pippit_asset_id`。 +- 提交:`data.run.thread_id`、`data.run.run_id`、`data.run.state`;可有 `data.web_thread_link`。 +- 查询:`data.thread_id`、`data.run_id`、`data.run_state`(兼容数字/字符串);可有 `video_urls`、`image_urls`、`fail_reason: {code, message}`。 +- 积分:`data.total_remain_amount` 为数字字符串,零余额为 `"0"`,不可丢失或转为浮点数。 + +服务端 `RunState` 状态枚举(2026-09-22 按生成协议代码核实): + +| 值 | 名称 | 查询处理 | +| --- | --- | --- | +| 0 | Unspecified | 未指定;保留响应并停止诊断 | +| 1 | Submitted | 已提交;继续查询 | +| 2 | Working | 处理中;继续查询 | +| 3 | Completed | 当前 Run 完成;检查真实媒体列表 | +| 4 | Failed | 失败;返回原因 | +| 5 | Canceled | 已取消 | +| 6 | InputRequired | 等待补充输入;停止轮询并处理交互 | +| 7 | Generating | 生成中;继续查询 | +| 8 | Interrupt | 程序中断,例如等待工具回调;有时限地继续查询 | +| 9 | HITL_Interrupt | 当前 Run 因人工交互中断结束;处理确认后查询同一 thread 的最新 Run | + +查询接口透传 Run 状态;仅在 3 时提取媒体,4/5 时提取失败原因,其它状态返回空媒体数组。因此 9 的空 `video_urls` 不能解释为生成失败。状态 3 也不保证有视频;以真实媒体列表判断交付。未知数值保留原始响应并停止,不自动重提或无期限轮询。 + +一条 thread 可以有多个 Run。问卷、达人图或工具执行确认会产生后续 Run,原 `run_id` 不会自动变成最新运行。确认后从网页或可用的 `pippit-tool-cli get-thread --thread-id THREAD_ID` 读取新的真实 run_id,再传给营销查询命令;不要猜 ID 或为查询创建新 Run。 diff --git a/skills/xyq-marketing-skill/scripts/marketing.js b/skills/xyq-marketing-skill/scripts/marketing.js new file mode 100644 index 0000000..16405e4 --- /dev/null +++ b/skills/xyq-marketing-skill/scripts/marketing.js @@ -0,0 +1,268 @@ +#!/usr/bin/env node +// Documented Xiaoyunque marketing API; Node.js >= 16, no dependencies. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const crypto = require('crypto'); +const { Readable } = require('stream'); +const { pipeline } = require('stream/promises'); + +const BASE = 'https://xyq.jianying.com'; +const PATHS = { + generate: '/api/biz/v1/agent/submit_marketing_run', + upload: '/api/biz/v1/skill/upload_file', + query: '/api/biz/v1/agent/query_generate_video_result', + balance: '/api/biz/v1/skill/get_credit_balance', +}; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const nonempty = value => typeof value === 'string' && value.trim().length > 0; +const RUN_STATES = Object.freeze({ 0: 'Unspecified', 1: 'Submitted', 2: 'Working', 3: 'Completed', 4: 'Failed', 5: 'Canceled', 6: 'InputRequired', 7: 'Generating', 8: 'Interrupt', 9: 'HITL_Interrupt' }); +const WAIT_STATES = ['1', '2', '7', '8']; +const INPUT_STATES = ['6', '9']; +function requireValue(condition, message) { if (!condition) throw new Error(message); } +function onlyKeys(value, keys) { + requireValue(object(value), '请求字段必须是 JSON 对象'); + requireValue(Object.keys(value).every(key => keys.includes(key)), '存在未支持的字段,请核对接口文档'); +} + +function validate(body) { + onlyKeys(body, ['message', 'asset_ids', 'thread_id', 'general_agent_settings']); + requireValue(nonempty(body.message), 'message 必须为用户的非空创作指令'); + if ('thread_id' in body) requireValue(nonempty(body.thread_id), 'thread_id 必须为非空字符串'); + if ('asset_ids' in body) requireValue(Array.isArray(body.asset_ids) && body.asset_ids.every(nonempty), 'asset_ids 必须为素材 ID 字符串数组'); + const settings = body.general_agent_settings; + onlyKeys(settings, ['ratio', 'duration_start', 'duration_end', 'show_subtitle', 'video_model', 'video_resolution']); + requireValue(nonempty(settings.video_model), 'general_agent_settings.video_model 必填;请先取得用户模型选择或按已授权的选择范围配置模型'); + if ('ratio' in settings) requireValue([2, 3, 4, 5, 6].includes(settings.ratio), 'ratio 必须为 2/3/4/5/6 的整数枚举'); + for (const key of ['duration_start', 'duration_end']) { + if (key in settings) requireValue(Number.isInteger(settings[key]) && settings[key] > 0 && settings[key] <= 2147483647, `${key} 必须为正整数秒数(int32)`); + } + if ('duration_start' in settings && 'duration_end' in settings) requireValue(settings.duration_start <= settings.duration_end, '时长下限不能大于上限'); + if ('show_subtitle' in settings) requireValue(typeof settings.show_subtitle === 'boolean', 'show_subtitle 必须为布尔值'); + for (const key of ['video_model', 'video_resolution']) { + if (key in settings) requireValue(nonempty(settings[key]), `${key} 必须为非空字符串`); + } + return body; +} + +function checkResponse(action, result, body) { + requireValue(object(result) && (result.ret === '0' || result.ret === 0), + `API 失败:ret=${result && result.ret} errmsg=${result && result.errmsg || ''} log_id=${result && result.log_id || ''}`); + const data = result.data; + requireValue(object(data), 'API 响应缺少 data'); + if (action === 'generate') requireValue(object(data.run) && nonempty(data.run.thread_id) && nonempty(data.run.run_id), '提交响应缺少任务 ID;结果不明确,禁止自动重提'); + if (action === 'upload') requireValue(nonempty(data.pippit_asset_id), '上传响应缺少 pippit_asset_id'); + if (action === 'balance') requireValue(typeof data.total_remain_amount === 'string' && /^\d+$/.test(data.total_remain_amount), '余额响应缺少字符串 total_remain_amount'); + if (action === 'query') { + requireValue(data.thread_id === body.thread_id && data.run_id === body.run_id, '返回任务 ID 与请求不一致'); + requireValue((typeof data.run_state === 'number' || typeof data.run_state === 'string') && /^\d+$/.test(String(data.run_state)), 'run_state 缺失或格式异常'); + for (const key of ['video_urls', 'image_urls']) { + if (key in data) requireValue(Array.isArray(data[key]) && data[key].every(nonempty), `${key} 格式异常`); + } + } + return result; +} + +function createClient({ key = process.env.XYQ_ACCESS_KEY, request = https.request, timeout = 60000 } = {}) { + function open(url, method, headers, body) { + return new Promise((resolve, reject) => { + const target = new URL(url); + requireValue(target.protocol === 'https:' && !target.username && !target.password, '仅支持无内嵌凭据的 HTTPS URL'); + const req = request(target, { method, headers }, res => { + // Keep the total deadline active through response consumption. + res.once('end', () => clearTimeout(timer)); + res.once('close', () => clearTimeout(timer)); + resolve(res); + }); + const timer = setTimeout(() => req.destroy(new Error('请求超时;提交结果可能不明确,请勿自动重提')), timeout); + req.once('error', error => { clearTimeout(timer); reject(error); }); + if (body && typeof body.pipe === 'function') { + body.once('error', error => req.destroy(error)); + req.once('close', () => body.destroy()); + body.pipe(req); + } else req.end(body); + }); + } + + async function api(action, body, headers = { 'Content-Type': 'application/json' }) { + requireValue(nonempty(key) && !/[\r\n]/.test(key), '请在本机环境设置 XYQ_ACCESS_KEY,不要在聊天中发送密钥;CLI 登录不会设置此变量'); + const wire = headers['Content-Type'] === 'application/json' ? JSON.stringify(body) : body; + const res = await open(BASE + PATHS[action], 'POST', { ...headers, Accept: 'application/json', Authorization: `Bearer ${key}` }, wire); + // Never follow API redirects or automatically retry a POST. + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + throw new Error(`HTTP ${res.statusCode};请求未重试,生成结果可能不明确`); + } + const chunks = []; + let size = 0; + for await (const chunk of res) { + size += chunk.length; + requireValue(size <= 8 * 1024 * 1024, 'API 响应超出 8 MiB 限制'); + chunks.push(chunk); + } + let result; + try { result = JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch (_) { throw new Error('API 未返回有效 JSON;提交结果可能不明确,请勿自动重提'); } + return checkResponse(action, result, body); + } + + async function upload(file) { + const stat = await fs.promises.stat(file); + requireValue(stat.isFile() && stat.size > 0 && stat.size < 500000000, '上传需要非空文件且小于 500 MB'); + const boundary = 'xyq-' + crypto.randomBytes(16).toString('hex'); + const name = path.basename(file).replace(/["\r\n\\]/g, '_'); + const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }[path.extname(file).toLowerCase()] || 'application/octet-stream'; + const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${mime}\r\n\r\n`); + const tail = Buffer.from(`\r\n--${boundary}--\r\n`); + const body = Readable.from((async function* () { + yield head; + for await (const chunk of fs.createReadStream(file)) yield chunk; + yield tail; + })()); + return api('upload', body, { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': head.length + stat.size + tail.length }); + } + + async function download(url, destination) { + // Media requests never receive the API key, including across redirects. + let res; + for (let redirects = 0; ; redirects++) { + res = await open(url, 'GET', {}, undefined); + if (![301, 302, 303, 307, 308].includes(res.statusCode)) break; + res.resume(); + requireValue(redirects < 5 && res.headers.location, '媒体重定向次数过多或缺少 Location'); + url = new URL(res.headers.location, url).href; + } + if (res.statusCode !== 200) { res.resume(); throw new Error(`媒体下载 HTTP ${res.statusCode}`); } + if (/text\/html|application\/json/i.test(res.headers['content-type'] || '')) { + res.resume(); throw new Error('下载返回网页或 JSON,未取得媒体'); + } + // Exclusive creation preserves existing user files and permits safe recovery. + let output; + try { output = await fs.promises.open(destination, 'wx'); } + catch (error) { res.destroy(); throw error; } + try { + await pipeline(res, fs.createWriteStream(destination, { fd: output.fd, autoClose: false })); + await output.close(); + requireValue((await fs.promises.stat(destination)).size > 0, '下载文件为空'); + } catch (error) { + await output.close().catch(() => {}); + await fs.promises.unlink(destination).catch(() => {}); + throw error; + } + return path.resolve(destination); + } + return { api, upload, download }; +} + +const HELP = `小云雀营销 Skill(Node.js >= 16) + node marketing.js generate --request request.json [--dry-run | --execute] + node marketing.js upload --file product.png + node marketing.js query --thread-id ID --run-id ID [--wait] [--max-wait 900] [--output-dir DIR] + node marketing.js balance +所有 API 调用从环境读取 XYQ_ACCESS_KEY;generate 默认仅预览。 +--timeout 秒数:单请求总时限,默认 60;--max-wait:轮询总时限,默认 900。 +query 输出 API 原始响应;有 --output-dir 时成功结果附带 downloaded_files。 +退出码:0 成功/单次查询进行中;1 输入或接口错误;2 生成失败/取消/无视频;3 等待超时;4 等待用户交互;5 未知或未指定状态。 +`; + +function parseArgs(argv) { + const [action, ...rest] = argv; + requireValue(Object.hasOwnProperty.call(PATHS, action), '操作必须是 generate/upload/query/balance'); + const options = {}; + const flags = ['execute', 'dry-run', 'wait']; + const allowed = { generate: ['request', 'execute', 'dry-run'], upload: ['file'], query: ['thread-id', 'run-id', 'wait', 'max-wait', 'output-dir'], balance: [] }[action].concat('timeout'); + for (let i = 0; i < rest.length; i++) { + const name = rest[i].replace(/^--/, ''); + requireValue(rest[i].startsWith('--') && allowed.includes(name) && !(name in options), `无效或重复参数:${rest[i]}`); + if (flags.includes(name)) options[name] = true; + else { + requireValue(nonempty(rest[i + 1]) && !rest[i + 1].startsWith('--'), `${name} 缺少值`); + options[name] = rest[++i]; + } + } + requireValue(!(options.execute && options['dry-run']), '--execute 和 --dry-run 不能同时使用'); + for (const [name, fallback, max] of [['timeout', 60, 1800], ['max-wait', 900, 86400]]) { + const value = options[name] === undefined ? fallback : Number(options[name]); + requireValue(Number.isInteger(value) && value > 0 && value <= max, `${name} 必须为 1 至 ${max} 的整数秒数`); + options[name] = value; + } + return { action, options }; +} + +async function main(argv, { env = process.env, out = console.log, clientFactory = createClient, now = Date.now, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { + if (!argv.length || argv.includes('--help') || argv.includes('-h')) { out(HELP); return 0; } + const { action, options } = parseArgs(argv); + let body = {}; + if (action === 'generate') { + requireValue(options.request, 'generate 需要 --request JSON 文件'); + body = validate(JSON.parse(await fs.promises.readFile(options.request, 'utf8'))); + if (!options.execute) { out(JSON.stringify({ dry_run: true, url: BASE + PATHS.generate, body })); return 0; } + } + if (action === 'query') { + requireValue(nonempty(options['thread-id']) && nonempty(options['run-id']), 'query 需要 thread-id 和 run-id'); + body = { thread_id: options['thread-id'], run_id: options['run-id'] }; + } + if (action === 'upload') requireValue(nonempty(options.file), 'upload 需要 --file'); + const client = clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: options.timeout * 1000 }); + let result; + const deadline = now() + options['max-wait'] * 1000; + do { + // A poll request cannot overrun the remaining wait budget. + const pollClient = action === 'query' && options.wait + ? clientFactory({ key: env.XYQ_ACCESS_KEY, timeout: Math.min(options.timeout * 1000, Math.max(1, deadline - now())) }) : client; + result = action === 'upload' ? await client.upload(options.file) : await pollClient.api(action, body); + if (action !== 'query' || !options.wait || !WAIT_STATES.includes(String(result.data.run_state))) break; + if (now() >= deadline) { + out(JSON.stringify({ ...result, wait_timed_out: true })); return 3; + } + await pause(Math.min(10000, deadline - now())); + if (now() >= deadline) { out(JSON.stringify({ ...result, wait_timed_out: true })); return 3; } + } while (true); + if (action === 'query') { + const state = String(result.data.run_state); + if (INPUT_STATES.includes(state)) { + out(JSON.stringify({ ...result, run_state_name: RUN_STATES[state], action_required: true, + next_step: '查看同一会话的确认或问卷;沿用已有授权,缺少必要选择时再询问用户。确认后取得同一 thread 的最新 run_id 再查询,旧 Run 可保持此状态。不要重复生成。' })); + return 4; + } + if (!(state in RUN_STATES) || state === '0') { + out(JSON.stringify({ ...result, run_state_name: RUN_STATES[state] || 'Unknown', unknown_state: true, + next_step: '停止自动轮询,保留任务 ID 和原始响应,核实服务端状态含义;不要重新提交生成。' })); + return 5; + } + } + // Print IDs and URLs before downloads so a partial download failure remains resumable. + out(JSON.stringify(result)); + if (action === 'query') { + const state = String(result.data.run_state); + if (['4', '5'].includes(state)) return 2; + if (state === '3') { + if (!(result.data.video_urls || []).length) return 2; + if (options['output-dir']) { + await fs.promises.mkdir(options['output-dir'], { recursive: true }); + const downloaded = []; + for (const [field, ext] of [['video_urls', '.mp4'], ['image_urls', '.jpg']]) { + for (const [index, url] of (result.data[field] || []).entries()) { + const suffix = path.extname(new URL(url).pathname); + const mediaExt = /^\.(mp4|mov|webm|mkv|jpg|jpeg|png|webp|gif)$/i.test(suffix) ? suffix : ext; + const file = path.join(options['output-dir'], `${field}-${index + 1}-${crypto.randomBytes(4).toString('hex')}${mediaExt}`); + downloaded.push({ url, path: await client.download(url, file) }); + out(JSON.stringify({ downloaded_file: downloaded[downloaded.length - 1] })); + } + } + out(JSON.stringify({ ...result, downloaded_files: downloaded })); + } + } + } + return 0; +} + +if (require.main === module) { + main(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(error => { + const key = process.env.XYQ_ACCESS_KEY; + const message = key ? error.message.split(key).join('[REDACTED]') : error.message; + console.error(JSON.stringify({ error: message, note: '请求未自动重试;已有任务请按原 thread_id/run_id 继续查询。' })); + process.exitCode = 1; + }); +} +module.exports = { BASE, PATHS, validate, checkResponse, createClient, parseArgs, main }; diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index 17c8ba2..a9bc7f3 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -10,6 +10,8 @@ metadata: 通过 CLI 完成生成、处理、结果下载与媒体交付。支持下表中的操作;不提供多轮会话续写或自动拆分剧本、分镜并编排成片的能力。复杂需求先确认能由所列命令完成的具体操作,不承诺未覆盖的流程。 +商品图文营销一键成片(剧情广告、品牌大片、达人带货)由独立的 `xyq-marketing-skill` 提供,不用普通生视频代替营销编排;未安装该 Skill 时先说明当前能力边界。 + ## 开始执行 1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`,其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。