修复正式序列帧稳定引用持久化
正式序列帧强制携带 objectKey 与 assetObjectId,并按 objectKey 重建持久路径 同步收紧 External v1 OpenAPI 并补充签名 URL 负向测试 保留异步生成紧凑结果中的逐帧 assetObjectId 并更新架构文档
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user