diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 92732c25a..cbfc902e3 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -4047,6 +4047,8 @@ "type": "object", "required": [ "imageSrc", + "objectKey", + "assetObjectId", "width", "height" ], @@ -4055,16 +4057,14 @@ "type": "string" }, "objectKey": { - "type": [ - "string", - "null" - ] + "type": "string", + "minLength": 1, + "description": "帧对应的稳定 OSS Object Key;服务端据此重建持久 imageSrc,不能只提交临时签名 URL。" }, "assetObjectId": { - "type": [ - "string", - "null" - ] + "type": "string", + "minLength": 1, + "description": "帧对应的稳定资产对象 ID。" }, "width": { "type": "integer", @@ -4076,7 +4076,7 @@ } }, "additionalProperties": false, - "description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段。" + "description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段;每帧必须同时携带 objectKey 与 assetObjectId,imageSrc 按 objectKey 规范化为持久站内路径。" }, "EditorCharacterAnimationGenerationResponse": { "type": "object", diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ada852eb6..594852369 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -676,6 +676,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、媒体读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id`、`generation_cost_mud_points` 和序列媒体结果字段。`asset_kind` 是唯一权威媒体类别;`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,数组长度派生帧数、数组顺序决定播放顺序、FPS 按需推导,`generation_inputs_json` 只保存用户可见生成 / 重放输入。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成把原始绿幕预览视频作为独立 `asset_kind = video` 素材保存,再把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧数组与图片序列毫秒时长写入两个正式媒体结果字段;生成端确认每个最终帧对象后,必须把该帧的 `objectKey` 与 `assetObjectId` 同时写入正式帧 payload,不能只在内部处理中暂存或仅保留首帧引用;存在项目上下文时,最终动作的 `source_resource_id` 指向预览视频资源。不把每帧拆成独立素材,也不重复保存预览路径。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别、媒体结果和用户可见生成输入快照。 +- 正式序列帧写入边界:写入端必须逐帧取得非空 `objectKey` 与 `assetObjectId`,并始终按 `objectKey` 重建 `imageSrc` 的持久站内路径。只有临时签名 URL 而没有这两项稳定引用的 External v1 请求返回 `400`,不能进入正式素材或项目资源。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_asset_group_source_provenance` diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index e7b22d91f..49751fd92 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -4360,12 +4360,14 @@ mod tests { { "imageSrc": "/generated/action/formal-01.png", "objectKey": "generated/action/formal-01.png", + "assetObjectId": "asset-object-formal-01", "width": 192, "height": 256 }, { "imageSrc": "/generated/action/formal-02.png", "objectKey": "generated/action/formal-02.png", + "assetObjectId": "asset-object-formal-02", "width": 192, "height": 256 } @@ -4397,8 +4399,8 @@ mod tests { fn admin_editor_asset_payload_preserves_formal_character_action_metadata() { let state = AppState::new(AppConfig::default()).expect("state should build"); let frames = json!([ - { "imageSrc": "/generated/action/frame-01.png", "width": 192, "height": 256 }, - { "imageSrc": "/generated/action/frame-02.png", "width": 192, "height": 256 } + { "imageSrc": "/generated/action/frame-01.png", "objectKey": "generated/action/frame-01.png", "assetObjectId": "asset-object-frame-01", "width": 192, "height": 256 }, + { "imageSrc": "/generated/action/frame-02.png", "objectKey": "generated/action/frame-02.png", "assetObjectId": "asset-object-frame-02", "width": 192, "height": 256 } ]); let mut record = test_admin_editor_asset_record( "action-asset", diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a87430860..a785ea1ab 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -7140,32 +7140,39 @@ fn normalize_editor_image_sequence_frame(frame: &Value) -> Option { if frame.contains_key("frameIndex") { return None; } - let image_src = frame + frame .get("imageSrc") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; + let object_key = normalize_editor_object_key( + frame + .get("objectKey") + .and_then(Value::as_str) + .map(str::to_string), + )?; + let asset_object_id = normalize_optional_string( + frame + .get("assetObjectId") + .and_then(Value::as_str) + .map(str::to_string), + )?; let width = frame.get("width").and_then(Value::as_u64)?; let height = frame.get("height").and_then(Value::as_u64)?; if width == 0 || height == 0 || u32::try_from(width).is_err() || u32::try_from(height).is_err() { return None; } - let mut normalized = serde_json::Map::from_iter([ - ("imageSrc".to_string(), Value::String(image_src.to_string())), + let normalized = serde_json::Map::from_iter([ + ( + "imageSrc".to_string(), + Value::String(editor_media_src_from_object_key(&object_key)), + ), + ("objectKey".to_string(), Value::String(object_key)), + ("assetObjectId".to_string(), Value::String(asset_object_id)), ("width".to_string(), Value::from(width)), ("height".to_string(), Value::from(height)), ]); - for field in ["objectKey", "assetObjectId"] { - if let Some(value) = frame - .get(field) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - normalized.insert(field.to_string(), Value::String(value.to_string())); - } - } Some(Value::Object(normalized)) } @@ -8594,6 +8601,35 @@ pub(crate) fn serialize_editor_image_sequence_frames( })), ); } + if frames.as_array().is_some_and(|frames| { + frames.iter().any(|frame| { + let Some(frame) = frame.as_object() else { + return false; + }; + normalize_editor_object_key( + frame + .get("objectKey") + .and_then(Value::as_str) + .map(str::to_string), + ) + .is_none() + || normalize_optional_string( + frame + .get("assetObjectId") + .and_then(Value::as_str) + .map(str::to_string), + ) + .is_none() + }) + }) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图片序列帧每帧必须同时提供 objectKey 和 assetObjectId;临时签名 URL 不能作为正式帧引用。", + "fields": ["objectKey", "assetObjectId"], + })), + ); + } let Some(frames) = normalize_editor_image_sequence_frames_value(&frames) else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -9812,12 +9848,12 @@ mod tests { #[test] fn editor_asset_payload_uses_formal_sequence_fields_only() { let stored_formal_frames = json!([ - { "imageSrc": "/generated/formal/frame01.png", "width": 192, "height": 256 }, - { "imageSrc": "/generated/formal/frame02.png", "width": 192, "height": 256 } + { "imageSrc": "https://example.invalid/frame01.png?Signature=temporary", "objectKey": "generated/formal/frame01.png", "assetObjectId": "asset-object-frame01", "width": 192, "height": 256 }, + { "imageSrc": "https://example.invalid/frame02.png?Signature=temporary", "objectKey": "generated/formal/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]); let formal_frames = json!([ - { "imageSrc": "/generated/formal/frame01.png", "width": 192, "height": 256 }, - { "imageSrc": "/generated/formal/frame02.png", "width": 192, "height": 256 } + { "imageSrc": "/generated/formal/frame01.png", "objectKey": "generated/formal/frame01.png", "assetObjectId": "asset-object-frame01", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame02.png", "objectKey": "generated/formal/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]); let record = EditorAssetRecord { asset_id: "editor-asset-action".to_string(), @@ -9920,8 +9956,8 @@ mod tests { #[test] fn editor_project_resource_payload_uses_formal_sequence_fields_only() { let formal_frames = json!([ - { "imageSrc": "/generated/formal/frame01.png", "width": 192, "height": 256 }, - { "imageSrc": "/generated/formal/frame02.png", "width": 192, "height": 256 } + { "imageSrc": "/generated/formal/frame01.png", "objectKey": "generated/formal/frame01.png", "assetObjectId": "asset-object-frame01", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame02.png", "objectKey": "generated/formal/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]); let record = EditorProjectResourceRecord { resource_id: "editor-resource-action".to_string(), @@ -10889,11 +10925,15 @@ mod tests { let formal_frames = json!([ { "imageSrc": "/generated/formal/frame-0001.png", + "objectKey": "generated/formal/frame-0001.png", + "assetObjectId": "asset-object-frame-0001", "width": 192, "height": 256 }, { "imageSrc": "/generated/formal/frame-0002.png", + "objectKey": "generated/formal/frame-0002.png", + "assetObjectId": "asset-object-frame-0002", "width": 192, "height": 256 } @@ -10940,8 +10980,8 @@ mod tests { #[test] fn editor_showcase_payload_does_not_backfill_missing_formal_sequence_fields() { let formal_frames = json!([ - { "imageSrc": "/generated/formal/frame-0001.png", "width": 192, "height": 256 }, - { "imageSrc": "/generated/formal/frame-0002.png", "width": 192, "height": 256 } + { "imageSrc": "/generated/formal/frame-0001.png", "objectKey": "generated/formal/frame-0001.png", "assetObjectId": "asset-object-frame-0001", "width": 192, "height": 256 }, + { "imageSrc": "/generated/formal/frame-0002.png", "objectKey": "generated/formal/frame-0002.png", "assetObjectId": "asset-object-frame-0002", "width": 192, "height": 256 } ]); let legacy_generation_inputs = json!({ "fields": [{ "title": "动作", "value": "待机" }], diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 10baecb1f..e70f33908 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -488,9 +488,8 @@ pub async fn create_external_editor_asset( payload.asset_kind.as_deref(), payload.generation_inputs.clone(), )?; - let image_sequence_frames_json = crate::editor_project::serialize_editor_image_sequence_frames( - payload.image_sequence_frames, - )?; + let image_sequence_frames_json = + serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_optional_string(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let asset = state @@ -597,9 +596,8 @@ pub async fn create_external_editor_project_resource( payload.asset_kind.as_deref(), payload.generation_inputs.clone(), )?; - let image_sequence_frames_json = crate::editor_project::serialize_editor_image_sequence_frames( - payload.image_sequence_frames, - )?; + let image_sequence_frames_json = + serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_optional_string(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let resource = state @@ -1017,6 +1015,12 @@ fn serialize_external_editor_generation_inputs( ) } +fn serialize_external_editor_image_sequence_frames( + frames: Option, +) -> Result, AppError> { + crate::editor_project::serialize_editor_image_sequence_frames(frames) +} + #[cfg(test)] mod tests { use super::*; @@ -1140,6 +1144,63 @@ mod tests { } } + #[test] + fn external_editor_sequence_frames_reject_temporary_signed_urls_without_stable_references() { + let error = serialize_external_editor_image_sequence_frames(Some(json!([ + { + "imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1", + "width": 192, + "height": 256 + }, + { + "imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2", + "width": 192, + "height": 256 + } + ]))) + .expect_err("External v1 不能把仅含临时签名 URL 的帧持久化为正式动作"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("fields")), + Some(&json!(["objectKey", "assetObjectId"])) + ); + assert!(error.body_text().contains("临时签名 URL")); + } + + #[test] + fn external_editor_sequence_frames_derive_persistent_paths_from_object_keys() { + let serialized = serialize_external_editor_image_sequence_frames(Some(json!([ + { + "imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1", + "objectKey": "/generated/action/frame-01.png", + "assetObjectId": "asset-object-frame-01", + "width": 192, + "height": 256 + }, + { + "imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2", + "objectKey": "generated/action/frame-02.png", + "assetObjectId": "asset-object-frame-02", + "width": 192, + "height": 256 + } + ]))) + .expect("带稳定对象引用的 External v1 正式帧应可持久化") + .expect("正式帧序列应产生 JSON"); + let frames: Value = serde_json::from_str(&serialized).expect("正式帧序列应为合法 JSON"); + + assert_eq!(frames[0]["imageSrc"], "/generated/action/frame-01.png"); + assert_eq!(frames[1]["imageSrc"], "/generated/action/frame-02.png"); + assert!(frames.as_array().is_some_and(|frames| { + frames.iter().all(|frame| { + !frame["imageSrc"] + .as_str() + .is_some_and(|image_src| image_src.contains("Signature=")) + }) + })); + } + #[test] fn external_generation_requires_bounded_printable_idempotency_key() { let missing = HeaderMap::new(); @@ -1268,6 +1329,24 @@ mod tests { json!(2), "{request_schema_name}" ); + let frame_schema = &parsed["components"]["schemas"]["EditorImageSequenceFrame"]; + assert_eq!( + frame_schema["required"], + json!(["imageSrc", "objectKey", "assetObjectId", "width", "height"]), + "{request_schema_name}" + ); + for stable_field in ["objectKey", "assetObjectId"] { + assert_eq!( + frame_schema["properties"][stable_field]["type"], + json!("string"), + "{request_schema_name}.{stable_field}" + ); + assert_eq!( + frame_schema["properties"][stable_field]["minLength"], + json!(1), + "{request_schema_name}.{stable_field}" + ); + } assert_eq!( request_schema["properties"]["imageSequenceDurationMs"]["minimum"], json!(1), diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 73097118e..04ef53ec6 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1392,7 +1392,7 @@ fn compact_external_api_generation_result(result: Value) -> Value { frame.retain(|key, _| { matches!( key.as_str(), - "frameIndex" | "imageSrc" | "objectKey" | "width" | "height" + "frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height" ) }); remove_unstable_external_generation_media_fields(frame); @@ -2291,6 +2291,23 @@ mod tests { "height": 1024, "generationInputs": {"private": true} }, + "frames": [ + { + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "assetObjectId": "asset-object-frame-01", + "width": 192, + "height": 256, + "provider": "internal-provider-must-not-persist" + }, + { + "imageSrc": "/generated/action/frame-02.png", + "objectKey": "generated/action/frame-02.png", + "assetObjectId": "asset-object-frame-02", + "width": 192, + "height": 256 + } + ], "project": { "projectId": "project-1", "canvas": {"layers": ["large-layout-must-not-persist"]} @@ -2347,6 +2364,16 @@ mod tests { ); assert!(result["asset"].get("thumbnailSrc").is_none()); assert!(result["asset"].get("generationInputs").is_none()); + assert_eq!( + result["frames"][0], + json!({ + "imageSrc": "/generated/action/frame-01.png", + "objectKey": "generated/action/frame-01.png", + "assetObjectId": "asset-object-frame-01", + "width": 192, + "height": 256 + }) + ); assert_eq!( result["warning"], json!({