From 6df0bfc8f35e12a98e0f67411780f6b50dd32f2c Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 7 Oct 2026 20:58:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E9=87=8D=E6=94=BE=E8=AF=BB=E5=8F=96=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E8=AF=B7=E6=B1=82=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按主键和账号读取原始生成任务,保留幂等键与请求指纹 保留同键不同参数冲突校验和跨账号隔离 补充隔离数据库中三个任务状态的读取与重放回归 同步外部生成读取契约和项目排障记忆 关联 #495 第一项 --- docs/project-memory/shared-memory/pitfalls.md | 1 + ...�Ž端架构】外部生成Worker化方案-2026-06-03.md | 3 + .../spacetime-editor-idempotency-smoke.mjs | 199 +++++++++++++++++- .../src/external_generation.rs | 19 +- 4 files changed, 216 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3fc57c933..d9ef19fba 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1336,6 +1336,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。 - 原因:同步接口 worker 化时把原请求整体序列化到 `external_generation_job.request_payload_json`,而前端又把已有 `objectKey` 下载成 Data URL 提交。任务表也是正式持久化边界;列表 procedure 若先收集完整任务行再截断,还会把 request/result 大字段在 SpacetimeDB、SDK mapper 和 BFF 多次持有。 +- 内部重放例外:`get_external_generation_job_and_return` 按主键读取单条原始任务并校验 owner,供 External 去背景核对真实幂等键与原始请求指纹;不能拿丢弃这些字段的摘要兼容快照做校验,否则同请求重试也会返回 `409`。这不改变用户列表、状态和确认只读摘要的边界;回归验证必须覆盖真实 procedure 读取,不能仅手工构造完整任务记录。 - 处理:先在事故涉及的编辑器持久任务 JSON 上由 api-server 与 SpacetimeDB 两层递归拒绝 `data:` / `blob:` 并限制字节数;已有媒体传 `objectKey` / `resourceId` / `assetId`,本地派生图先用强唯一 key 上传。其它玩法若仍以 Data URL 作为正式请求契约,必须先资源化,不能直接扩大门禁造成玩法回归。列表、详情和 acknowledge 只走无 payload 的摘要投影,ack 不能为了同步旧字段重写大任务行;摘要错误文本也必须清除内联媒体并设硬上限,列表只能维护有界 top-N,不能先收集 owner 全量历史再截断。历史只通过迁移操作员的 dry-run + B-tree cursor 分批 procedure 压缩 `editor-canvas` 终态任务,cursor 选择读取量必须受 limit 约束,绝不全表扫描、绝不处理 pending / running;dry-run 后 apply 同一批时保持输入 cursor 不变,最后一批即使 `has_more=false` 只要仍有命中也必须 apply,只有 apply 成功后才推进到返回 cursor。SpacetimeDB CLI 2.5 的 `Option` 非空参数必须使用 SATS sum 编码;维护脚本要统一编码 `cursor_job_id`、`owner_user_id` 和 `completed_before_micros`,否则首批空 cursor 可运行,但第二批或带截止时间的调用会在写入前被拒绝。 - 发布门禁:生产发布入口必须固定 `--delete-data=never` 与 scoped `--yes=migrate,break-clients`,普通 Jenkins 参数不得暴露清库开关;需要删数据的 schema 冲突必须直接阻断并重新检查 artifact/schema,不能靠裸 `--yes` 放行。 - 验证:构造嵌套 Data URL、Blob URL 和超限 JSON 确认入队失败;检查正式 UI procedure / client record 不含 request/result payload;用 dry-run 和 apply 测试确认活动任务不变、终态普通提示词保留且内联媒体被替换;至少带一次非空 `--cursor-job-id` 与 `--completed-before-micros` 验证 CLI Option 编码,而不是只测首批空 cursor。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 42fc410fd..89956eb4e 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -36,12 +36,15 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部 - `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。 - `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。 - `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 +- `get_external_generation_job_and_return`:仅供受信后端执行幂等重放核对,按 `job_id` 读取原始任务行并校验 `owner_user_id`,保留真实 `dedupe_key` 和含原始请求指纹的 `request_payload_json`。不得用摘要兼容快照替代,否则相同请求会被误判为幂等冲突;同键同原始参数返回原任务,同键不同参数仍返回 `409`。该读取不触发入队、生成或扣费,也不替代 BFF 的摘要与结果查询。 - `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。 External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 +幂等读取回归使用 `npm run check:editor-idempotency-procedures`:隔离 standalone 验证 pending、running、completed 的原始请求身份、同键重放唯一任务及跨 owner / 不存在任务拒绝,不调用生成 Provider。脚本仅在临时构建目录注入公开测试 bootstrap hash;若设置 `GENARRATIVE_EDITOR_IDEMPOTENCY_SMOKE_WASM` 复用预编译模块,该模块也必须使用脚本中的测试 hash 构建,且不得用于部署。 + 这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿和编辑器业务结果回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 ## BFF 状态接口 diff --git a/scripts/spacetime-editor-idempotency-smoke.mjs b/scripts/spacetime-editor-idempotency-smoke.mjs index 0e6d9b94c..9a43eb00c 100644 --- a/scripts/spacetime-editor-idempotency-smoke.mjs +++ b/scripts/spacetime-editor-idempotency-smoke.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { once } from 'node:events'; -import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm } from 'node:fs/promises'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; @@ -19,6 +20,11 @@ const database = 'editor-idempotency-smoke'; const expectedSpacetimeVersion = '2.8.3'; const expectedSpacetimeCommit = '8e410d2842147bd8e5a32a9589cc00c19f7478e2'; const commandTimeoutMs = 5 * 60 * 1000; +// 仅用于隔离 smoke;预编译 WASM 也必须使用这个公开测试值的 SHA-256 编译。 +const smokeBootstrapSecret = 'a'.repeat(64); +const smokeBootstrapSecretHash = createHash('sha256') + .update(smokeBootstrapSecret) + .digest('hex'); function assert(condition, message) { if (!condition) { @@ -123,6 +129,20 @@ export function parseProcedureOutput(stdout, procedureName = 'procedure') { }; } +function parseExternalGenerationJobResult(stdout, procedureName) { + const value = JSON.parse(String(stdout).trim()); + assert( + Array.isArray(value) && value.length === 8, + `${procedureName} returned an invalid result shape.`, + ); + return { + ok: value[0], + job: decodeOption(value[1]), + jobs: value[2], + errorMessage: decodeOption(value[7]), + }; +} + export function parseSqlRows(stdout, queryLabel = 'SQL') { let payload; try { @@ -278,7 +298,13 @@ function cliPrefix(configPath) { return ['--config-path', configPath]; } -async function callProcedure(configPath, serverUrl, procedureName, input) { +async function callProcedure( + configPath, + serverUrl, + procedureName, + input, + parseOutput = parseProcedureOutput, +) { const result = await runCommand('spacetime', [ ...cliPrefix(configPath), 'call', @@ -290,7 +316,168 @@ async function callProcedure(configPath, serverUrl, procedureName, input) { procedureName, JSON.stringify(input), ]); - return parseProcedureOutput(result.stdout, procedureName); + return parseOutput(result.stdout, procedureName); +} + +async function initializeExternalGenerationRuntime(configPath, serverUrl) { + const pricingPath = path.join( + repoRoot, + 'server-rs/crates/api-server/config/editor-generation-pricing.default.json', + ); + const pricing = JSON.parse(await readFile(pricingPath, 'utf8')); + const models = Object.entries(pricing.models).map(([model, entry]) => ({ + model, + unit: entry.unit, + price: entry.price === undefined ? [1, []] : [0, entry.price], + prices: Object.entries(entry.prices ?? {}).map(([key, price]) => ({ + key, + price, + })), + })); + const initialized = await callProcedure( + configPath, + serverUrl, + 'initialize_editor_generation_pricing_config_if_missing_and_return', + { + admin_user_id: 'external-generation-smoke', + models, + updated_at_micros: 1, + bootstrap_secret: smokeBootstrapSecret, + }, + ); + assertOk(initialized, 'external generation runtime identity bootstrap'); +} + +async function runExternalGenerationJobSmoke(configPath, serverUrl) { + const procedure = (name, input) => + callProcedure( + configPath, + serverUrl, + name, + input, + parseExternalGenerationJobResult, + ); + const ownerUserId = 'external-generation-smoke-owner'; + const jobId = 'external-generation-smoke-job'; + const dedupeKey = 'external-generation-smoke-dedupe'; + const fingerprint = 'external-generation-smoke-fingerprint'; + const requestPayloadJson = JSON.stringify({ + sourceImageSrc: 'smoke-source-resource', + _externalApiRequestFingerprint: fingerprint, + }); + const enqueueInput = { + job_id: jobId, + dedupe_key: dedupeKey, + job_kind: 'editor_background_removal', + owner_user_id: ownerUserId, + source_module: 'editor-canvas', + source_entity_id: 'external-generation-smoke-entity', + request_label: 'external generation smoke', + request_payload_json: requestPayloadJson, + max_attempts: 1, + available_at_micros: 1_000, + created_at_micros: 1_000, + price_mud_points: 0, + }; + const getJob = (owner = ownerUserId, id = jobId) => + procedure('get_external_generation_job_and_return', { + job_id: id, + owner_user_id: owner, + }); + const assertOriginalJob = (result, status) => { + assert( + result.ok === true, + `${status} job read failed: ${result.errorMessage}`, + ); + assert(Array.isArray(result.job), `${status} job read returned no job.`); + assert( + result.job[0] === jobId && + result.job[1] === dedupeKey && + result.job[7] === requestPayloadJson && + result.job[8] === status, + `${status} job read lost its original ID, dedupe key, payload, or status.`, + ); + assert( + JSON.parse(result.job[7])._externalApiRequestFingerprint === fingerprint, + `${status} job read lost the request fingerprint.`, + ); + }; + + const queued = await procedure( + 'enqueue_external_generation_job_and_return', + enqueueInput, + ); + assert( + queued.ok === true && queued.job?.[0] === jobId, + 'Job enqueue failed.', + ); + assertOriginalJob(await getJob(), 'pending'); + const replay = await procedure('enqueue_external_generation_job_and_return', { + ...enqueueInput, + job_id: 'external-generation-smoke-replay-job', + }); + assert( + replay.ok === true && replay.job?.[0] === jobId, + 'Dedupe replay created a new job.', + ); + const rows = await sqlRows( + configPath, + serverUrl, + `SELECT job_id FROM external_generation_job WHERE dedupe_key = '${dedupeKey}'`, + 'external generation dedupe rows', + ); + assert( + rows.length === 1 && rows[0][0] === jobId, + 'Dedupe replay changed job count.', + ); + + for (const [label, owner, id] of [ + ['foreign owner', 'external-generation-smoke-other-owner', jobId], + ['missing job', ownerUserId, 'external-generation-smoke-missing-job'], + ]) { + const result = await getJob(owner, id); + assert( + result.ok === false && + result.job === null && + result.errorMessage === 'external_generation_job 不存在', + `${label} read did not fail closed.`, + ); + } + + const workerId = 'external-generation-smoke-worker'; + const claimed = await procedure('claim_external_generation_jobs_and_return', { + worker_id: workerId, + limit: 1, + claimed_at_micros: 1_000, + lease_expires_at_micros: 3_600_001_000, + }); + assert( + claimed.ok === true && + claimed.jobs.length === 1 && + claimed.jobs[0][0] === jobId, + `Job claim failed: ${claimed.errorMessage}`, + ); + assertOriginalJob(await getJob(), 'running'); + const leaseToken = decodeOption(claimed.jobs[0][20]); + assert( + typeof leaseToken === 'string' && leaseToken.length > 0, + 'Claim returned no lease token.', + ); + const completed = await procedure( + 'complete_external_generation_job_and_return', + { + job_id: jobId, + worker_id: workerId, + lease_token: leaseToken, + result_payload_json: [1, []], + completed_at_micros: 2_000, + }, + ); + assert( + completed.ok === true, + `Job completion failed: ${completed.errorMessage}`, + ); + assertOriginalJob(await getJob(), 'completed'); } async function sqlRows(configPath, serverUrl, query, queryLabel) { @@ -846,13 +1033,17 @@ export async function main() { env: { ...process.env, CARGO_TARGET_DIR: path.join(tempDir, 'cargo-target'), + GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256: + smokeBootstrapSecretHash, }, sensitiveValues: [loginToken], }); await runSmoke(configPath, serverUrl); + await initializeExternalGenerationRuntime(configPath, serverUrl); + await runExternalGenerationJobSmoke(configPath, serverUrl); console.log( - '[editor-idempotency-smoke] Passed folder reads (empty, missing, foreign owner, no writes), exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.', + '[editor-idempotency-smoke] Passed folder reads, idempotent creates, and external generation job original-payload reads across pending/running/completed with owner isolation and dedupe replay.', ); } catch (error) { const standaloneOutput = sanitizeDiagnostic( diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 0c7f63901..d8dffdf7d 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -1125,8 +1125,23 @@ fn get_external_generation_job_tx( ctx: &ReducerContext, input: ExternalGenerationJobGetInput, ) -> Result { - get_external_generation_job_summary_tx(ctx, input) - .map(map_external_generation_job_summary_to_compat_snapshot) + validate_required("external_generation_job.job_id", &input.job_id)?; + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + let job_id = input.job_id.trim().to_string(); + let row = ctx + .db + .external_generation_job() + .job_id() + .find(&job_id) + .ok_or_else(|| "external_generation_job 不存在".to_string())?; + if row.owner_user_id.trim() != input.owner_user_id.trim() { + return Err("external_generation_job 不存在".to_string()); + } + // 内部幂等重放需要真实 dedupe_key 与原始请求指纹,摘要兼容快照不包含这些字段。 + Ok(map_external_generation_job_row(row)) } fn get_external_generation_job_result_tx( -- 2.52.0 From a948722d1a3d66d8310cd15f3efcc08f5fd869aa Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 7 Oct 2026 21:36:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E7=B2=BE=E7=AE=80=E7=BB=93=E6=9E=9C=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除未被接口引用的旧动画生成响应定义 明确顶层帧与秒级时长及正式序列记录读取路径 修正 Python 自测并补充动画精简结果回归 同步外部生成方案与共享排障记忆 关联 #495 第二项 --- .../references/requests-and-outputs.md | 2 +- .../scripts/genarrative_external_api.py | 26 ++--- .../genarrative-external-v1.openapi.json | 109 +----------------- docs/project-memory/shared-memory/pitfalls.md | 5 + ...�Ž端架构】外部生成Worker化方案-2026-06-03.md | 2 + .../crates/api-server/src/editor_project.rs | 45 ++++++++ 6 files changed, 67 insertions(+), 122 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index 37647c6f6..be8e1dcf6 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -134,7 +134,7 @@ A minimal `canvasCompletion` is: Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order. -Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed. +Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. In a completed polling response, use `result.frames` for the ordered frame artifacts and `result.durationSeconds` for the duration in seconds. The nested `result.resource` and `result.asset` are compact references, not complete records; they do not include `imageSequenceFrames` or `imageSequenceDurationMs`. Read the complete project (`GET /api/external/v1/editor/projects/{projectId}`) or asset library (`GET /api/external/v1/editor/assets/library`) and match `resourceId` or `assetId` to obtain those formal sequence fields, whose duration is in milliseconds. MCP equivalents are `find_assets/get_project_resources` and `find_assets/list_library`. Do not synthesize a duplicate animation asset from the first frame. For the lower-level asset/resource creation endpoints, `generationInputs` follows the metadata and media-runtime boundaries described in [Generation Inputs Metadata](#generation-inputs-metadata). diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index bc270fe38..4d4422275 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -795,25 +795,20 @@ def _self_test() -> None: "model": "seedance2.0-fast", "prompt": "角色呼吸", "previewVideoPath": "/generated/preview.mp4", - "frames": [{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}], + "frames": [ + {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, + {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, + ], + "frameCount": 2, + "durationSeconds": 4, "resource": { "resourceId": "editor-resource-demo", "assetKind": "character-animation", "sourceResourceId": "editor-resource-preview-demo", - "imageSequenceFrames": [ - {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, - {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, - ], - "imageSequenceDurationMs": 4000, }, "asset": { "assetId": "editor-asset-demo", "assetKind": "character-animation", - "imageSequenceFrames": [ - {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, - {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, - ], - "imageSequenceDurationMs": 4000, }, } if method == "POST": @@ -841,8 +836,13 @@ def _self_test() -> None: assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" assert result["asset"]["assetId"] == "editor-asset-demo" assert result["asset"]["assetKind"] == "character-animation" - assert len(result["asset"]["imageSequenceFrames"]) == 2 - assert result["asset"]["imageSequenceDurationMs"] == 4000 + assert result["resource"]["resourceId"] == "editor-resource-demo" + assert result["frames"] == [ + {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, + {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, + ] + assert result["frameCount"] == 2 + assert result["durationSeconds"] == 4 calls.clear() background_result = client.remove_background( "uploads/source.png", diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index b85e6c217..0d3f2dee1 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -4114,113 +4114,6 @@ "additionalProperties": false, "description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段;每帧必须同时携带 objectKey 与 assetObjectId,imageSrc 按 objectKey 规范化为持久站内路径。" }, - "EditorCharacterAnimationGenerationResponse": { - "type": "object", - "required": [ - "ok", - "taskId", - "model", - "prompt", - "previewVideoPath", - "frames", - "frameCount", - "durationSeconds", - "frameWidth", - "frameHeight", - "fps", - "priceMudPoints" - ], - "properties": { - "ok": { - "type": "boolean" - }, - "taskId": { - "type": "string" - }, - "model": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "previewVideoPath": { - "type": "string" - }, - "frames": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EditorImageSequenceFrame" - } - }, - "frameCount": { - "type": "integer", - "minimum": 1 - }, - "durationSeconds": { - "type": "integer", - "minimum": 1 - }, - "frameWidth": { - "type": "integer", - "minimum": 1 - }, - "frameHeight": { - "type": "integer", - "minimum": 1 - }, - "fps": { - "type": "integer", - "minimum": 1 - }, - "priceMudPoints": { - "type": "integer", - "minimum": 0 - }, - "project": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorProject" - }, - { - "type": "null" - } - ], - "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" - }, - "resource": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorProjectResource" - }, - { - "type": "null" - } - ], - "description": "最终透明角色动作对应的项目资源。携带 projectId 时,画布图层必须直接引用其 resourceId,不得再次创建重复资源。" - }, - "asset": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorAsset" - }, - { - "type": "null" - } - ], - "description": "最终透明角色动作素材。assetKind 为 character-animation,并直接包含序列帧字段。" - }, - "queueState": { - "anyOf": [ - { - "$ref": "#/components/schemas/ExternalGenerationJobStatusRecord" - }, - { - "type": "null" - } - ] - } - } - }, "EditorVideoGenerationRequest": { "type": "object", "required": [ @@ -4776,7 +4669,7 @@ } }, "additionalProperties": true, - "description": "其它生成类型或历史结果的兼容 compact result。背景音乐(audioKind=background-music)与不带 audioKind 的图片、视频、图标序列帧、角色动作和 UI 拆解结果都落在这里。pixelArt 图片的 width/height、图标结果的 spritesheetWidth/spritesheetHeight 及关联 resource/asset 尺寸均表示最终逻辑分辨率 PNG 的实际值,不保证等于请求档位、provider 回图或画布 placeholder。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。" + "description": "其它生成类型或历史结果的兼容 compact result。背景音乐(audioKind=background-music)与不带 audioKind 的图片、视频、图标序列帧、角色动作和 UI 拆解结果都落在这里。角色动作使用顶层 frames(有序帧产物)与 durationSeconds(秒级时长);内嵌 resource/asset 是精简引用,不包含 imageSequenceFrames/imageSequenceDurationMs。需要正式序列记录时,按返回的 resourceId/assetId 查询 GET /api/external/v1/editor/projects/{projectId} 或 GET /api/external/v1/editor/assets/library,正式 imageSequenceDurationMs 单位为毫秒。pixelArt 图片的 width/height、图标结果的 spritesheetWidth/spritesheetHeight 及关联 resource/asset 尺寸均表示最终逻辑分辨率 PNG 的实际值,不保证等于请求档位、provider 回图或画布 placeholder。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。" }, "ExternalEditorGenerationCompletedResult": { "oneOf": [ diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d9ef19fba..b9481e7fd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,11 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## External 动画轮询的精简引用不等于完整序列记录 + +- 动画 completed 结果通过顶层 `frames` 和 `durationSeconds` 提供有序帧与秒级时长;内嵌 `resource/asset` 只提供精简引用。正式 `imageSequenceFrames/imageSequenceDurationMs` 从完整项目或素材库按返回的 ID 读取,后者时长单位为毫秒。内嵌引用没有这两个字段不能据此判定持久化丢帧,也不能用首帧重复创建动画素材。 +- 排查时分别核对真实精简函数、正式记录和 [External v1 OpenAPI](../../openapi/genarrative-external-v1.openapi.json);Python helper 的模拟响应必须遵循同一结构,不能用虚构的嵌套完整记录证明轮询契约成立。 + ## 2026-10-05 generationInputs 的保存与复用不等于自动生效或完整透传 - **现象 / 根因**:调用方把构图等要求只写入 `generationInputs.artSpec`,但实际生图输入没有这些要求;把 `JsonValue` 和“可复用规范”误读为服务端会自动组装提示词、完整保留全部元数据或自动用于下一次生成。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 89956eb4e..d469e511e 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -41,6 +41,8 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部 External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。 +角色动画的 completed compact result 通过顶层 `frames` 保留有序帧产物,通过 `durationSeconds` 保留秒级时长;内嵌 `resource/asset` 仍是精简引用,不包含 `imageSequenceFrames/imageSequenceDurationMs`。调用方需要正式序列记录时,按返回的 `resourceId/assetId` 从完整项目或素材库读取,正式时长单位为毫秒;不得把精简引用当完整记录,或根据首帧重复创建动画素材。OpenAPI、Skill 与 helper 自测共同遵循此读路径。 + 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 幂等读取回归使用 `npm run check:editor-idempotency-procedures`:隔离 standalone 验证 pending、running、completed 的原始请求身份、同键重放唯一任务及跨 owner / 不存在任务拒绝,不调用生成 Provider。脚本仅在临时构建目录注入公开测试 bootstrap hash;若设置 `GENARRATIVE_EDITOR_IDEMPOTENCY_SMOKE_WASM` 复用预编译模块,该模块也必须使用脚本中的测试 hash 构建,且不得用于部署。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 602771e28..71f740c73 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -19846,6 +19846,51 @@ mod tests { } } + #[test] + fn compact_external_character_animation_result_keeps_frames_duration_and_references() { + let frames = json!([ + { + "imageSrc": "/generated/animation/frame01.png", + "objectKey": "generated/animation/frame01.png", + "assetObjectId": "asset-object-frame01", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/animation/frame02.png", + "objectKey": "generated/animation/frame02.png", + "assetObjectId": "asset-object-frame02", + "width": 256, + "height": 192 + } + ]); + let result = compact_external_api_generation_result(json!({ + "ok": true, + "durationSeconds": 4, + "frameCount": 2, + "frames": frames, + "resource": { + "resourceId": "resource-animation", + "objectKey": "generated/animation/frame01.png", + "assetObjectId": "asset-object-frame01" + }, + "asset": { + "assetId": "asset-animation", + "objectKey": "generated/animation/frame01.png", + "assetObjectId": "asset-object-frame01" + }, + "provider": "internal-provider" + })); + + assert_eq!(result["frames"], frames); + assert_eq!(result["durationSeconds"], 4); + assert_eq!(result["frameCount"], 2); + assert_eq!(result["resource"]["resourceId"], "resource-animation"); + assert_eq!(result["asset"]["assetId"], "asset-animation"); + assert_eq!(result["resource"]["assetObjectId"], "asset-object-frame01"); + assert_eq!(result["asset"]["assetObjectId"], "asset-object-frame01"); + } + #[test] fn game_creator_completed_jobs_keep_stable_results_for_all_current_media_kinds() { let fixtures = [ -- 2.52.0