修复画布素材导出分片读取
补齐 OSS 响应体失败与局部 206 的同源字节兜底 保留角色动作逐帧 objectKey 与 assetObjectId 补充导出、Abort、契约回归与文档
This commit is contained in:
@@ -403,10 +403,18 @@
|
||||
|
||||
- 现象:产品要求画板 `生成角色动作` 返回后按透明序列帧播放和下载,但旧实现或旧测试可能继续把结果当作预览视频处理。
|
||||
- 原因:后端仍需要先生成 `previewVideoPath` 再抽帧、绿幕去背和落 OSS;如果前端把预览视频当主媒体,就会绕过已经扣绿幕的 PNG 帧,也无法按序列帧打包下载。
|
||||
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc`,`mediaType` 固定为 `image-sequence`,`assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames`,`previewVideoPath` 只作为来源信息保留。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
|
||||
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录。
|
||||
- 处理:角色动作结果图层主 `src` 使用 `frames[0].imageSrc`,`mediaType` 固定为 `image-sequence`,`assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames`,`previewVideoPath` 只作为来源信息保留。生成端确认每帧对象后必须把该帧 `objectKey` 与 `assetObjectId` 一起写入正式 payload 和 `generation_inputs_json.characterAnimation.frames`。单图层下载必须生成序列帧 ZIP;画布素材 ZIP 中角色动作写入 `sequences/<编号-标题>/frames/`。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
|
||||
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录;生成测试还应断言首帧与非首帧的稳定引用都被保留。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## OSS 导出兜底必须覆盖响应体读取阶段
|
||||
|
||||
- 现象:浏览器控制台显示 OSS `206 Partial Content` 后紧跟 `net::ERR_FAILED`,画布预览或素材导出失败,但没有出现预期的 `/api/assets/read-bytes` 兜底请求。
|
||||
- 原因:`fetch(signedUrl)` 可能先返回一个 `ok` 的 `Response`,网络、浏览器 Range 缓存或传输错误随后才在 `arrayBuffer()` / `blob()` 消费响应体时暴露;如果直连保护边界只包住 `fetch` 和状态码,响应体失败会绕过 fallback。
|
||||
- 处理:私有素材字节读取必须在直连 OSS 分支内完整消费响应体并重新构造可重复读取的 `Response`;换签、请求、非成功状态或响应体读取任一阶段失败时统一回退同源 `/api/assets/read-bytes`。Abort 仍应直接上抛,不能转化为额外服务器读取。
|
||||
- 验证:前端服务测试模拟 OSS 返回 `206/ok`、但 `blob()` reject,断言随后请求 `/api/assets/read-bytes` 并返回 fallback 完整字节;同时保留直连成功、局部分片、直连非成功、请求 reject 和 Abort 边界。
|
||||
- 关联:`src/services/assetReadUrlService.ts`、`src/services/assetReadUrlService.test.ts`、`src/components/image-editor/ImageCanvasExportModel.ts`。
|
||||
|
||||
## 图片画布序列帧播放不要复用普通图片淡入样式
|
||||
|
||||
- 现象:角色动作序列帧播放时看起来像每帧之间在渐变或闪烁。
|
||||
|
||||
@@ -166,7 +166,7 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
4. 对每个素材源读取 Blob:
|
||||
- `data:image/...` 直接转换为 Blob。
|
||||
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
|
||||
- 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;只有换签或 OSS 字节读取失败时,才 fallback 到同源 `/api/assets/read-bytes`。
|
||||
- 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;必须在同一保护边界内完整消费响应体,换签、请求、状态码或响应体读取任一阶段失败时,才 fallback 到同源 `/api/assets/read-bytes`,不得在只拿到 `2xx/206` 响应头后提前视为读取成功。
|
||||
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。
|
||||
- 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
|
||||
- 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。
|
||||
@@ -194,6 +194,7 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
- 图层的锁定、翻转、分组状态写入元数据。
|
||||
- `data:image` 图片不经过网络请求即可导出。
|
||||
- URL 图片 fetch 失败时不中断其他素材导出。
|
||||
- OSS 返回 `2xx/206`、但响应体读取失败时,自动改走 `/api/assets/read-bytes`,并使用 fallback 的完整字节生成 Blob。
|
||||
- 动作图层右键菜单把 `导出为` 作为一级入口,二级菜单提供 `序列帧导出(zip)` 和 `Spine 导出(zip)`。
|
||||
- 单图层普通序列帧 ZIP 包含 `frames/`、`preview.gif`、`metadata.json` 和 `manifest.txt`,且不包含 `skeleton.json`。
|
||||
- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。
|
||||
|
||||
@@ -643,7 +643,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`。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `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`。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,且每个已确认帧必须同时保留 `objectKey` 与 `assetObjectId`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。
|
||||
|
||||
### `editor_asset_group_source_provenance`
|
||||
|
||||
@@ -2534,17 +2534,37 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
.await?;
|
||||
|
||||
Ok(ProcessedEditorCharacterAnimationFrame {
|
||||
payload: EditorCharacterAnimationFramePayload {
|
||||
frame_index: frame_index as u32 + 1,
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: frame_width,
|
||||
height: frame_height,
|
||||
},
|
||||
payload: build_editor_character_animation_frame_payload(
|
||||
frame_index as u32 + 1,
|
||||
put_result.legacy_public_path,
|
||||
put_result.object_key.clone(),
|
||||
confirmed.record.asset_object_id.clone(),
|
||||
frame_width,
|
||||
frame_height,
|
||||
),
|
||||
object_key: put_result.object_key,
|
||||
asset_object_id: confirmed.record.asset_object_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_editor_character_animation_frame_payload(
|
||||
frame_index: u32,
|
||||
image_src: String,
|
||||
object_key: String,
|
||||
asset_object_id: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> EditorCharacterAnimationFramePayload {
|
||||
EditorCharacterAnimationFramePayload {
|
||||
frame_index,
|
||||
image_src,
|
||||
object_key: Some(object_key),
|
||||
asset_object_id: Some(asset_object_id),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_animation_set(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -6689,12 +6709,16 @@ mod tests {
|
||||
EditorCharacterAnimationFramePayload {
|
||||
frame_index: 1,
|
||||
image_src: "/generated-animations/editor/layer/task/frame01.png".to_string(),
|
||||
object_key: Some("generated-animations/editor/layer/task/frame01.png".to_string()),
|
||||
asset_object_id: Some("asset-object-frame01".to_string()),
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
EditorCharacterAnimationFramePayload {
|
||||
frame_index: 2,
|
||||
image_src: "/generated-animations/editor/layer/task/frame02.png".to_string(),
|
||||
object_key: Some("generated-animations/editor/layer/task/frame02.png".to_string()),
|
||||
asset_object_id: Some("asset-object-frame02".to_string()),
|
||||
width: 192,
|
||||
height: 256,
|
||||
},
|
||||
@@ -6724,6 +6748,58 @@ mod tests {
|
||||
metadata["characterAnimation"]["frames"][1]["imageSrc"],
|
||||
"/generated-animations/editor/layer/task/frame02.png"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["characterAnimation"]["frames"][0]["objectKey"],
|
||||
"generated-animations/editor/layer/task/frame01.png"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["characterAnimation"]["frames"][0]["assetObjectId"],
|
||||
"asset-object-frame01"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["characterAnimation"]["frames"][1]["objectKey"],
|
||||
"generated-animations/editor/layer/task/frame02.png"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["characterAnimation"]["frames"][1]["assetObjectId"],
|
||||
"asset-object-frame02"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_character_animation_frame_payload_retains_stable_asset_references() {
|
||||
let frames = [
|
||||
build_editor_character_animation_frame_payload(
|
||||
1,
|
||||
"/generated-animations/editor/layer/task/frame01.png".to_string(),
|
||||
"generated-animations/editor/layer/task/frame01.png".to_string(),
|
||||
"asset-object-frame01".to_string(),
|
||||
192,
|
||||
256,
|
||||
),
|
||||
build_editor_character_animation_frame_payload(
|
||||
2,
|
||||
"/generated-animations/editor/layer/task/frame02.png".to_string(),
|
||||
"generated-animations/editor/layer/task/frame02.png".to_string(),
|
||||
"asset-object-frame02".to_string(),
|
||||
192,
|
||||
256,
|
||||
),
|
||||
];
|
||||
let frames = serde_json::to_value(frames).expect("frame payloads should serialize");
|
||||
|
||||
assert_eq!(frames[0]["frameIndex"], 1);
|
||||
assert_eq!(
|
||||
frames[0]["objectKey"],
|
||||
"generated-animations/editor/layer/task/frame01.png"
|
||||
);
|
||||
assert_eq!(frames[0]["assetObjectId"], "asset-object-frame01");
|
||||
assert_eq!(frames[1]["frameIndex"], 2);
|
||||
assert_eq!(
|
||||
frames[1]["objectKey"],
|
||||
"generated-animations/editor/layer/task/frame02.png"
|
||||
);
|
||||
assert_eq!(frames[1]["assetObjectId"], "asset-object-frame02");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -343,6 +343,10 @@ pub struct EditorCharacterAnimationGenerateRequest {
|
||||
pub struct EditorCharacterAnimationFramePayload {
|
||||
pub frame_index: u32,
|
||||
pub image_src: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub object_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_object_id: Option<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
@@ -1141,6 +1145,8 @@ mod tests {
|
||||
frames: vec![EditorCharacterAnimationFramePayload {
|
||||
frame_index: 1,
|
||||
image_src: "/generated-animations/editor/layer/frame01.png".to_string(),
|
||||
object_key: Some("generated-animations/editor/layer/frame01.png".to_string()),
|
||||
asset_object_id: Some("asset-object-frame01".to_string()),
|
||||
width: 768,
|
||||
height: 1024,
|
||||
}],
|
||||
@@ -1163,6 +1169,14 @@ mod tests {
|
||||
payload["frames"][0]["imageSrc"],
|
||||
json!("/generated-animations/editor/layer/frame01.png")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["frames"][0]["objectKey"],
|
||||
json!("generated-animations/editor/layer/frame01.png")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["frames"][0]["assetObjectId"],
|
||||
json!("asset-object-frame01")
|
||||
);
|
||||
assert_eq!(payload["fps"], json!(8));
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +298,78 @@ describe('ImageCanvasExportModel', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back when OSS returns 206 but its response body fails during export', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const signedResponseBlob = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.startsWith('/api/assets/read-url?')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated/export-body-failure.png',
|
||||
signedUrl: 'https://signed.example.com/export-body-failure.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url === 'https://signed.example.com/export-body-failure.png') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 206,
|
||||
statusText: 'Partial Content',
|
||||
headers: new Headers({
|
||||
'Content-Range': 'bytes 0-2/3',
|
||||
'Content-Type': 'image/png',
|
||||
}),
|
||||
blob: signedResponseBlob,
|
||||
} as unknown as Response;
|
||||
}
|
||||
if (url.startsWith('/api/assets/read-bytes?')) {
|
||||
return new Response(new Blob(['fallback'], { type: 'image/png' }));
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
const blob = await readLayerAssetBlob(
|
||||
buildLayer({
|
||||
src: '/generated-editor-images/export-body-failure.png',
|
||||
objectKey: 'generated/export-body-failure.png',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
|
||||
await expect(blob.text()).resolves.toBe('fallback');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/api/assets/read-bytes?objectKey=generated%2Fexport-body-failure.png',
|
||||
),
|
||||
expect.any(Object),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('reads private image-sequence frame object keys through same-origin bytes', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
|
||||
@@ -725,4 +725,201 @@ describe('assetReadUrlService', () => {
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes falls back when a successful OSS response body fails to read', async () => {
|
||||
const signedResponseBlob = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const signedResponse = {
|
||||
ok: true,
|
||||
status: 206,
|
||||
statusText: 'Partial Content',
|
||||
headers: new Headers({
|
||||
'Content-Range': 'bytes 0-2/3',
|
||||
'Content-Type': 'image/png',
|
||||
}),
|
||||
blob: signedResponseBlob,
|
||||
} as unknown as Response;
|
||||
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(signedResponse)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([9, 8, 7]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'/generated-editor-images/image.png',
|
||||
{
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
expireSeconds: 300,
|
||||
},
|
||||
);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
|
||||
expect(Array.from(bytes)).toEqual([9, 8, 7]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/editor-image.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes rejects a partial 206 fragment and uses the full-byte fallback', async () => {
|
||||
const signedResponseBlob = vi.fn();
|
||||
const signedResponse = {
|
||||
ok: true,
|
||||
status: 206,
|
||||
statusText: 'Partial Content',
|
||||
headers: new Headers({
|
||||
'Content-Range': 'bytes 0-1/3',
|
||||
'Content-Type': 'image/png',
|
||||
}),
|
||||
blob: signedResponseBlob,
|
||||
} as unknown as Response;
|
||||
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(signedResponse)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([9, 8, 7]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'/generated-editor-images/image.png',
|
||||
{
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
expireSeconds: 300,
|
||||
},
|
||||
);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
expect(signedResponseBlob).not.toHaveBeenCalled();
|
||||
expect(Array.from(bytes)).toEqual([9, 8, 7]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes preserves aborts during OSS body reads without fallback', async () => {
|
||||
const abortController = new AbortController();
|
||||
const signedResponseBlob = vi.fn().mockImplementation(async () => {
|
||||
abortController.abort();
|
||||
throw new DOMException('The operation was aborted.', 'AbortError');
|
||||
});
|
||||
const signedResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'image/png',
|
||||
}),
|
||||
blob: signedResponseBlob,
|
||||
} as unknown as Response;
|
||||
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(signedResponse);
|
||||
|
||||
await expect(
|
||||
readAssetBytes('/generated-editor-images/image.png', {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
expireSeconds: 300,
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,8 +556,15 @@ export async function readAssetBytes(
|
||||
try {
|
||||
const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal);
|
||||
const response = await fetch(signedUrl, { signal: options.signal });
|
||||
if (response.ok) {
|
||||
return response;
|
||||
if (isCompleteAssetReadResponse(response)) {
|
||||
// OSS 可能先返回 200/206 响应头、再在读取响应体时失败。必须在这里完整消费响应体,
|
||||
// 才能让读取失败进入同源字节代理兜底;返回新 Response,避免调用方拿到已消费的响应体。
|
||||
const body = await response.blob();
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (options.signal?.aborted) {
|
||||
@@ -569,6 +576,31 @@ export async function readAssetBytes(
|
||||
return readAssetBytesViaFallbackApi(readRequest, options.signal);
|
||||
}
|
||||
|
||||
function isCompleteAssetReadResponse(response: Response) {
|
||||
if (response.status === 200) {
|
||||
return true;
|
||||
}
|
||||
if (response.status !== 206) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 全量字节读取不能把媒体预览或浏览器缓存留下的局部分片当成完整素材。
|
||||
// 只有 Content-Range 明确覆盖 0..total-1 时才接受 206;跨域未暴露该头时安全回退同源代理。
|
||||
const contentRange = response.headers.get('content-range')?.trim() ?? '';
|
||||
const match = /^bytes\s+0-(\d+)\/(\d+)$/iu.exec(contentRange);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const end = Number(match[1]);
|
||||
const total = Number(match[2]);
|
||||
return (
|
||||
Number.isSafeInteger(end) &&
|
||||
Number.isSafeInteger(total) &&
|
||||
total > 0 &&
|
||||
end + 1 === total
|
||||
);
|
||||
}
|
||||
|
||||
async function readAssetBytesViaFallbackApi(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
'src/routing/activeAppPageRoutes.test.ts',
|
||||
'src/routing/activeAppRoutes.test.ts',
|
||||
'src/services/activeAppTitle.test.ts',
|
||||
'src/services/assetReadUrlService.test.ts',
|
||||
'src/services/authService.test.ts',
|
||||
'src/services/apiClient.test.ts',
|
||||
'src/services/clipboard.test.ts',
|
||||
|
||||
Reference in New Issue
Block a user