修复正式序列帧稳定引用持久化

正式序列帧强制携带 objectKey 与 assetObjectId,并按 objectKey 重建持久路径

同步收紧 External v1 OpenAPI 并补充签名 URL 负向测试

保留异步生成紧凑结果中的逐帧 assetObjectId 并更新架构文档
This commit is contained in:
2026-08-05 20:08:23 +08:00
parent eac2c19a72
commit ab5f2cbba1
6 changed files with 188 additions and 39 deletions
@@ -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 与 assetObjectIdimageSrc 按 objectKey 规范化为持久站内路径。"
},
"EditorCharacterAnimationGenerationResponse": {
"type": "object",
@@ -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`
+4 -2
View File
@@ -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",
@@ -7140,32 +7140,39 @@ fn normalize_editor_image_sequence_frame(frame: &Value) -> Option<Value> {
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": "待机" }],
@@ -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<Value>,
) -> Result<Option<String>, 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),
@@ -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!({