合并 web master 角色交付尺寸统一
接受 master 的角色带背景原图与透明图统一交付尺寸方案,像素模式不再 豁免提前归一:逻辑像素规整改在按业务像素矩阵 resize_to_fill(Lanczos 重采样 + 居中裁切)后的交付尺寸图上进行,snapper 自行还原回输入尺寸, 删除原先后置的 nearest 二次恢复及其 pixel_art_applied 开关。 后处理降级告警合并两侧机制:postprocess 失败路径改用 master 的 _with_dimension 重载并继续经 merge_editor_generation_warnings 汇总; 成功路径补回 master 的 dimension_warning,避免自动合并将其丢弃。 结构断言改为只固定 editor_postprocess_fallback_warning 共同前缀。 decision-log 保留双方新决策条目,并修订像素艺术条目中已过时的 "provider 原尺寸分析网格"表述。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-29 角色带背景原图与透明图统一交付尺寸
|
||||
|
||||
- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。
|
||||
- 决策:角色分支必须在持久化带纯色背景原图和调用 BgFilter 之前,先按统一业务像素矩阵执行一次尺寸归一;该原图和透明派生图始终使用同一实际像素尺寸,1K 的长边为 `1024`。若 provider 回图任意一边小于业务目标或比例偏差过大,仍禁止放大或大幅裁切;此时两张图一同保留 provider 实际尺寸并返回通用 `warning`,不允许只改透明图。BgFilter 回图尺寸漂移时只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并回贴到该原图;蒙版比例超限、回贴失败或尺寸验证失败时必须改用原图单产物降级,不持久化尺寸或比例不一致的透明图。若尺寸降级和后处理降级同时发生,同一条 `warning.reason` 必须同时保留两个原因。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色生成、原图持久化、BgFilter 输入、项目资源尺寸与画布图层 Resolution;不改变前端请求 DTO、扣费、素材类型或多产物布局。
|
||||
- 验证方式:后端定向测试覆盖角色全尺寸矩阵:`nanobanana2` 的 `0.5K / 1K / 2K` 和 `gpt-image-2` 的 `1K / 2K`,每档均覆盖 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`,30 个组合全部构造大于目标尺寸的真实 PNG provider 回图并执行像素恢复,不只校验字符串映射;另覆盖欠尺寸禁止放大、比例超限、BgFilter 错比例 alpha 蒙版拒绝和组合告警。同时从函数调用顺序上固定“尺寸归一 → 持久化带背景原图 → BgFilter”。运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-29 图标图集 BgFilter 开启 cross-check
|
||||
|
||||
- 背景:图标 spritesheet 的透明化需要提高主体内部孔洞、轮廓和相邻小图标边缘的交叉校验质量。
|
||||
@@ -566,7 +576,9 @@
|
||||
## 2026-06-24 图片画布项目封面使用静态快照资源
|
||||
|
||||
- 背景:项目页和创作主页最近项目曾在卡片中根据项目 `layers + viewport + resources` 临时重建一份迷你画布,视觉上像封面,但它不是持久快照,也会把列表页变成画布布局解释器。
|
||||
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
|
||||
- 决策:项目封面图改为画布当前视口栅格化后的静态资源。前端在项目加载后和防抖保存 layout 时生成 320x240 WebP,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`;项目列表和创作主页最近项目只读取最新封面快照资源渲染,没有快照时显示项目占位,不再回退为实时画布组合。
|
||||
- 2026-07-24 补充:封面取景以当前画布工作区的实际尺寸和渲染态 viewport 为准,先绘制工作区背景色,再从视口中心等比放大并裁成 4:3;持久化显示倍率不得直接用于封面渲染。
|
||||
- 2026-07-29 补充:常规编辑仍沿用防抖保存;用户从画布返回项目页时必须取消待执行 timer,以最新权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存和正式项目资源后再导航。当前视口存在图层但全部位于取景外时仍生成纯背景封面,不沿用旧缩略图。
|
||||
- 影响范围:`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/project/ProjectGalleryView.tsx`、`src/components/creation-home/CreationLandingView.tsx` 和图片画布数据契约文档。
|
||||
- 验证方式:运行项目页、封面快照模型、图片画布项目持久化和媒体上传相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
@@ -4525,7 +4537,7 @@
|
||||
|
||||
- 契约:普通图片 / 角色共用的图片生成请求和图标图集生成请求增加可选字符串 `style`,当前公开合法值为 `none / pixelArt`。省略、`null`、空字符串和 `none` 归一为内部 `None` 且不告警;未知字符串、或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `pixelArt` 时,按 `None` 继续原管线并返回 `unsupported-image-style` 通用告警;非字符串 JSON 返回 `400`。旧队列 payload 缺少字段时兼容为 `None`。
|
||||
- UI 边界:只有普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 显示 `像素艺术` 勾选项;当前选择可进入已有生成器快照和请求 / 队列 payload,但不写入 `generationInputs`、素材元数据或新表。画布 Agent 和其它生成 / 编辑入口不开放该选项。
|
||||
- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、相邻边缘峰间距使用线性插值 P30 估算步长、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。
|
||||
- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、相邻边缘峰间距使用线性插值 P30 估算步长、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。2026-07-29 合并「角色带背景原图与透明图统一交付尺寸」后本条修订:像素模式不再豁免提前归一,网格分析源是已按业务像素矩阵 `resize_to_fill`(Lanczos 重采样 + 居中裁切)后的交付尺寸平底图,不再是 provider 原生分辨率图;像素规整在交付尺寸上完成、由 snapper 自行还原回输入尺寸,因此不再执行后置的 nearest 二次恢复。
|
||||
- 执行边界:像素规整 CPU 工作使用进程级最大并发 2;取得并发许可的排队时间与实际处理时间共享最多 30 秒预算,同时不得晚于当前请求 deadline,最终取更早者。输入图片任一边上限为 10000 像素、总像素上限为 8294400;超限、排队超时或处理超时均按 best-effort 非致命降级,不持久化部分结果。
|
||||
- 去背边界:不修改 BgFilter `flat` 参数、`cross_check`、fallback、Alpha 回贴和默认关闭 despill 的现有行为。BgFilter 最终失败时不运行像素规整;像素规整失败按 best-effort 非致命降级,保留进入该步骤前的图片并通过既有通用 `warning` 完成任务,不退款。
|
||||
- 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。
|
||||
|
||||
@@ -366,7 +366,9 @@
|
||||
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
|
||||
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
|
||||
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
|
||||
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
|
||||
- 封面是展示派生物,不是 layout 真相。常规编辑只在项目加载和原有 layout 保存触发点采样当前 `canvasSize`,不监听 ResizeObserver 尺寸变化单独增加保存频率;但用户主动返回项目页时必须先 flush 最新权威 layout,并等待同一视口封面写入 IndexedDB 和正式项目资源后再导航。为避免移动端、窄窗口或首次尺寸尚未稳定时取景过小,以当前视口中心为锚点把取景宽高至少扩大到 `1280x960`;实际值更大时保留更大值。画布存在 drawable 图层但当前取景全部离屏时,要保存纯背景封面,不能因相交列表为空而保留旧缩略图。
|
||||
- 封面生成不要为同一 OSS 对象发起另一套换签缓存维度:图片、序列帧和 poster 分别复用主画布预览的 refresh key,保证封面取得相同 signed URL,由浏览器合并 in-flight 请求或命中 HTTP 缓存。通用素材上传里的 `bypassCache: true` 只用于上传后立即预览;项目封面不消费该 `src`,应在 confirm 后直接使用 object-only 结果创建项目资源。
|
||||
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存、小视口居中扩大到 `1280x960`以及大视口不缩小;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
|
||||
- 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。
|
||||
|
||||
## 图片画布框选预览要复用源图换签缓存
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -125,6 +125,7 @@ const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-sou
|
||||
const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback";
|
||||
const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style";
|
||||
const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings";
|
||||
const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05;
|
||||
const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2;
|
||||
const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30);
|
||||
static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock<Arc<tokio::sync::Semaphore>> = LazyLock::new(|| {
|
||||
@@ -655,6 +656,19 @@ fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse {
|
||||
editor_postprocess_fallback_warning("像素规整未完成,已保留原始生成结果。")
|
||||
}
|
||||
|
||||
fn editor_postprocess_fallback_warning_with_dimension(
|
||||
reason: &'static str,
|
||||
dimension_warning: Option<&EditorGenerationWarningResponse>,
|
||||
) -> EditorGenerationWarningResponse {
|
||||
let reason = dimension_warning
|
||||
.map(|warning| format!("{reason} {}", warning.reason))
|
||||
.unwrap_or_else(|| reason.to_string());
|
||||
EditorGenerationWarningResponse {
|
||||
code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse {
|
||||
let reason = error
|
||||
.details()
|
||||
@@ -1864,25 +1878,19 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
// 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回
|
||||
// 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一,
|
||||
// 保证不同模型的完成图与生成前占位标注一致。
|
||||
// 角色带背景原图和透明结果是同一个业务产物的两个版本,必须在
|
||||
// provider 原图持久化前就收口到同一交付尺寸。否则 1K 任务会把模型原生
|
||||
// 2752x1536 原图和长边 1024 的透明图同时放入画布。像素模式不再豁免,
|
||||
// 逻辑像素规整直接在归一并居中裁切后的交付尺寸图上进行。
|
||||
let (restored_image, dimension_restore_error) =
|
||||
if is_character_generation || image_style == EditorImageGenerationStyle::PixelArt {
|
||||
// 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存;
|
||||
// 透明主结果会在后处理完成后再归一到画布业务规格。像素模式同样必须
|
||||
// 先完成逻辑像素规整,再用 nearest 恢复交付尺寸,不能提前走 Lanczos。
|
||||
(image, None)
|
||||
} else {
|
||||
restore_editor_generated_image_output_dimensions_or_original(
|
||||
image,
|
||||
generation_options.model,
|
||||
image_size.as_ref(),
|
||||
)
|
||||
};
|
||||
generation_warning = merge_editor_generation_warnings(
|
||||
generation_warning,
|
||||
dimension_restore_error
|
||||
.as_ref()
|
||||
.map(editor_dimension_restore_warning),
|
||||
);
|
||||
restore_editor_generated_image_output_dimensions_or_original(
|
||||
image,
|
||||
generation_options.model,
|
||||
image_size.as_ref(),
|
||||
);
|
||||
let dimension_warning = dimension_restore_error
|
||||
.as_ref()
|
||||
.map(editor_dimension_restore_warning);
|
||||
if let Some(error) = dimension_restore_error {
|
||||
tracing::warn!(
|
||||
task_id = %generated.task_id,
|
||||
@@ -1903,7 +1911,6 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
let mut output_model = generation_options.model.to_string();
|
||||
let mut output_provider = "VectorEngine".to_string();
|
||||
let mut output_generation_inputs = generation_inputs.clone();
|
||||
let mut pixel_art_applied = false;
|
||||
let source_record = if is_character_generation {
|
||||
let source_persisted = persist_editor_provider_source_image(
|
||||
state,
|
||||
@@ -2005,8 +2012,9 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
project: completed_project,
|
||||
warning: merge_editor_generation_warnings(
|
||||
generation_warning,
|
||||
Some(editor_postprocess_fallback_warning(
|
||||
Some(editor_postprocess_fallback_warning_with_dimension(
|
||||
"生成任务成功,后处理失败。",
|
||||
dimension_warning.as_ref(),
|
||||
)),
|
||||
),
|
||||
},
|
||||
@@ -2036,8 +2044,6 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
.await;
|
||||
(image, error, None)
|
||||
};
|
||||
pixel_art_applied =
|
||||
image_style == EditorImageGenerationStyle::PixelArt && pixel_art_error.is_none();
|
||||
if let Some(error) = postprocess_dimension_error {
|
||||
tracing::warn!(
|
||||
task_id = %generated.task_id,
|
||||
@@ -2047,8 +2053,39 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
delivery_width,
|
||||
delivery_height,
|
||||
error = ?error,
|
||||
"角色透明图尺寸恢复失败,保留去背景服务原始输出"
|
||||
"角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布"
|
||||
);
|
||||
let completed_project = complete_editor_canvas_generation(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.as_deref(),
|
||||
payload.canvas_completion.as_ref(),
|
||||
source_record.resource.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorImageGenerationResponse {
|
||||
image_src: source_image_src,
|
||||
object_key: Some(source_object_key),
|
||||
asset_object_id: Some(source_asset_object_id),
|
||||
width: delivery_width,
|
||||
height: delivery_height,
|
||||
source_type: "generated",
|
||||
prompt: role_setting,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
resource: source_record.resource,
|
||||
asset: source_record.asset,
|
||||
project: completed_project,
|
||||
warning: Some(editor_postprocess_fallback_warning_with_dimension(
|
||||
"生成任务成功,后处理失败。",
|
||||
dimension_warning.as_ref(),
|
||||
)),
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(error) = pixel_art_error {
|
||||
tracing::warn!(
|
||||
@@ -2074,12 +2111,13 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
None
|
||||
};
|
||||
|
||||
// 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行,snapper 会把结果
|
||||
// 还原回输入尺寸,因此这里不再需要二次恢复交付尺寸。
|
||||
if !is_character_generation && image_style == EditorImageGenerationStyle::PixelArt {
|
||||
let (pixel_art_image, pixel_art_error) =
|
||||
snap_editor_pixel_art_or_original(image, request_context.external_call_deadline())
|
||||
.await;
|
||||
image = pixel_art_image;
|
||||
pixel_art_applied = pixel_art_error.is_none();
|
||||
if let Some(error) = pixel_art_error {
|
||||
tracing::warn!(
|
||||
task_id = %generated.task_id,
|
||||
@@ -2093,35 +2131,6 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
}
|
||||
}
|
||||
|
||||
if is_character_generation || image_style == EditorImageGenerationStyle::PixelArt {
|
||||
let (restored_image, dimension_restore_error) =
|
||||
restore_editor_generated_image_output_dimensions_or_original_with_filter(
|
||||
image,
|
||||
generation_options.model,
|
||||
image_size.as_ref(),
|
||||
if pixel_art_applied {
|
||||
image::imageops::FilterType::Nearest
|
||||
} else {
|
||||
image::imageops::FilterType::Lanczos3
|
||||
},
|
||||
);
|
||||
if let Some(error) = dimension_restore_error {
|
||||
generation_warning = merge_editor_generation_warnings(
|
||||
generation_warning,
|
||||
Some(editor_dimension_restore_warning(&error)),
|
||||
);
|
||||
tracing::warn!(
|
||||
task_id = %generated.task_id,
|
||||
provider_width,
|
||||
provider_height,
|
||||
target_size = %image_size,
|
||||
error = ?error,
|
||||
"画板角色透明主图尺寸恢复失败,保留后处理原图"
|
||||
);
|
||||
}
|
||||
image = restored_image;
|
||||
}
|
||||
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
@@ -2214,7 +2223,8 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
resource: generated_asset.resource,
|
||||
asset: generated_asset.asset,
|
||||
project: completed_project,
|
||||
warning: generation_warning,
|
||||
// 中文注释:成功路径同时收口尺寸归一降级与像素规整等生成告警。
|
||||
warning: merge_editor_generation_warnings(generation_warning, dimension_warning),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -3011,6 +3021,28 @@ fn apply_editor_postprocessed_alpha_to_provider_source_with_policy(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let source_ratio = f64::from(provider_source.width()) / f64::from(provider_source.height());
|
||||
let postprocessed_ratio = f64::from(postprocessed.width()) / f64::from(postprocessed.height());
|
||||
let ratio_drift = if source_ratio >= postprocessed_ratio {
|
||||
1.0 - postprocessed_ratio / source_ratio
|
||||
} else {
|
||||
1.0 - source_ratio / postprocessed_ratio
|
||||
};
|
||||
if ratio_drift > EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "editor-image-postprocess",
|
||||
"code": "postprocessed-alpha-aspect-ratio-mismatch",
|
||||
"message": "透明后处理图比例与原图偏差过大,禁止拉伸 Alpha 蒙版",
|
||||
"sourceWidth": provider_source.width(),
|
||||
"sourceHeight": provider_source.height(),
|
||||
"postprocessedWidth": postprocessed.width(),
|
||||
"postprocessedHeight": postprocessed.height(),
|
||||
"ratioDrift": ratio_drift,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。
|
||||
// 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。
|
||||
let postprocessed = postprocessed.to_rgba8();
|
||||
@@ -9049,6 +9081,39 @@ mod tests {
|
||||
assert_eq!(restored.extension, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_image_postprocess_rejects_wrong_aspect_ratio_alpha_mask() {
|
||||
let provider_source = image::DynamicImage::new_rgba8(1024, 576);
|
||||
let mut provider_source_bytes = Cursor::new(Vec::new());
|
||||
provider_source
|
||||
.write_to(&mut provider_source_bytes, image::ImageFormat::Png)
|
||||
.expect("provider source should encode");
|
||||
let provider_source = DownloadedOpenAiImage {
|
||||
bytes: provider_source_bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
|
||||
let postprocessed = image::DynamicImage::new_rgba8(512, 512);
|
||||
let mut postprocessed_bytes = Cursor::new(Vec::new());
|
||||
postprocessed
|
||||
.write_to(&mut postprocessed_bytes, image::ImageFormat::Png)
|
||||
.expect("postprocessed image should encode");
|
||||
let postprocessed = DownloadedOpenAiImage {
|
||||
bytes: postprocessed_bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
|
||||
let error =
|
||||
apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed)
|
||||
.expect_err("wrong-aspect alpha masks must use source-only fallback");
|
||||
assert_eq!(
|
||||
error.details().and_then(|details| details.get("code")),
|
||||
Some(&json!("postprocessed-alpha-aspect-ratio-mismatch"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_art_sampling_uses_provider_rgb_even_when_bgfilter_dimensions_match() {
|
||||
let provider_source = image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 10, 20, 255]));
|
||||
@@ -9463,6 +9528,85 @@ mod tests {
|
||||
assert_eq!(fallback.provider_image_size, "1K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_generation_covers_full_model_ratio_and_size_matrix() {
|
||||
for (image_size, aspect_ratio, expected_delivery_size) in [
|
||||
("0.5K", "1:1", "512x512"),
|
||||
("0.5K", "4:3", "512x384"),
|
||||
("0.5K", "3:2", "512x341"),
|
||||
("0.5K", "2:3", "341x512"),
|
||||
("0.5K", "9:16", "288x512"),
|
||||
("0.5K", "16:9", "512x288"),
|
||||
("1K", "1:1", "1024x1024"),
|
||||
("1K", "4:3", "1024x768"),
|
||||
("1K", "3:2", "1024x683"),
|
||||
("1K", "2:3", "683x1024"),
|
||||
("1K", "9:16", "576x1024"),
|
||||
("1K", "16:9", "1024x576"),
|
||||
("2K", "1:1", "2048x2048"),
|
||||
("2K", "4:3", "2048x1536"),
|
||||
("2K", "3:2", "2048x1365"),
|
||||
("2K", "2:3", "1365x2048"),
|
||||
("2K", "9:16", "1152x2048"),
|
||||
("2K", "16:9", "2048x1152"),
|
||||
] {
|
||||
let mut models = vec![EDITOR_IMAGE_MODEL_NANOBANANA2];
|
||||
if image_size != "0.5K" {
|
||||
models.push(GPT_IMAGE_2_MODEL);
|
||||
}
|
||||
for model in models {
|
||||
let (expected_width, expected_height) =
|
||||
parse_editor_image_edit_pixel_size(expected_delivery_size)
|
||||
.expect("delivery matrix should contain valid pixel dimensions");
|
||||
let options = normalize_editor_generation_options(
|
||||
Some(model),
|
||||
Some(aspect_ratio),
|
||||
Some(image_size),
|
||||
);
|
||||
assert_eq!(
|
||||
options.size, expected_delivery_size,
|
||||
"{model} {image_size} {aspect_ratio} should use the shared delivery matrix"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_editor_image_request_size(Some("character"), None, true, &options,),
|
||||
expected_delivery_size,
|
||||
"character source and cutout should share {model} {image_size} {aspect_ratio} delivery dimensions"
|
||||
);
|
||||
|
||||
let provider_width = (expected_width * 9).div_ceil(8);
|
||||
let provider_height = (expected_height * 9).div_ceil(8);
|
||||
let provider_image =
|
||||
image::DynamicImage::new_luma8(provider_width, provider_height);
|
||||
let mut provider_bytes = Cursor::new(Vec::new());
|
||||
provider_image
|
||||
.write_to(&mut provider_bytes, image::ImageFormat::Png)
|
||||
.expect("synthetic provider image should encode");
|
||||
let provider_output = DownloadedOpenAiImage {
|
||||
bytes: provider_bytes.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let (restored, restore_error) =
|
||||
restore_editor_generated_image_output_dimensions_or_original(
|
||||
provider_output,
|
||||
model,
|
||||
expected_delivery_size,
|
||||
);
|
||||
assert!(
|
||||
restore_error.is_none(),
|
||||
"{model} {image_size} {aspect_ratio} should restore a larger provider image"
|
||||
);
|
||||
let restored = image::load_from_memory(restored.bytes.as_slice())
|
||||
.expect("restored character source should remain a valid image");
|
||||
assert_eq!(
|
||||
(restored.width(), restored.height()),
|
||||
(expected_width, expected_height),
|
||||
"{model} {image_size} {aspect_ratio} should restore real pixels before background removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_material_request_size_keeps_workflow_pixels() {
|
||||
let options =
|
||||
@@ -10654,7 +10798,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_postprocess_fallback_response_keeps_source_and_warning() {
|
||||
fn editor_character_postprocess_fallback_response_keeps_source_and_dimension_warning() {
|
||||
let dimension_warning = EditorGenerationWarningResponse {
|
||||
code: EDITOR_GENERATION_DIMENSION_WARNING_CODE,
|
||||
reason: "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位".to_string(),
|
||||
};
|
||||
let response = EditorImageGenerationResponse {
|
||||
image_src: "/api/assets/source-character.png".to_string(),
|
||||
object_key: Some("generated/editor/character-source.png".to_string()),
|
||||
@@ -10670,8 +10818,9 @@ mod tests {
|
||||
resource: None,
|
||||
asset: None,
|
||||
project: None,
|
||||
warning: Some(editor_postprocess_fallback_warning(
|
||||
warning: Some(editor_postprocess_fallback_warning_with_dimension(
|
||||
"生成任务成功,后处理失败。",
|
||||
Some(&dimension_warning),
|
||||
)),
|
||||
};
|
||||
|
||||
@@ -10691,7 +10840,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
payload["warning"]["reason"],
|
||||
json!("生成任务成功,后处理失败。")
|
||||
json!(
|
||||
"生成任务成功,后处理失败。 provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11822,7 +11973,10 @@ mod tests {
|
||||
"complete_editor_canvas_generation",
|
||||
"source_record.resource.as_ref()",
|
||||
warning_assignment,
|
||||
"editor_postprocess_fallback_warning(",
|
||||
// 中文注释:普通图片与角色分支的后处理降级分别使用
|
||||
// editor_postprocess_fallback_warning 和 _with_dimension,
|
||||
// 断言只固定共同前缀,不锁定具体重载。
|
||||
"editor_postprocess_fallback_warning",
|
||||
],
|
||||
);
|
||||
|
||||
@@ -11882,6 +12036,12 @@ mod tests {
|
||||
.expect("character generation function end marker should exist");
|
||||
let body = &function_tail[..end];
|
||||
|
||||
let dimension_restore = body
|
||||
.find("restore_editor_generated_image_output_dimensions_or_original")
|
||||
.expect("character provider image should be restored to delivery dimensions");
|
||||
let source_persist = body
|
||||
.find("persist_editor_provider_source_image")
|
||||
.expect("character provider source should be persisted");
|
||||
let phase = body
|
||||
.find("caller.report_processing_phase(state).await?")
|
||||
.expect("phase report should remain fallible");
|
||||
@@ -11901,13 +12061,18 @@ mod tests {
|
||||
phase < removal,
|
||||
"phase errors must fail before fallback is considered"
|
||||
);
|
||||
assert!(
|
||||
dimension_restore < source_persist && source_persist < removal,
|
||||
"character delivery dimensions must be restored before source persistence and background removal"
|
||||
);
|
||||
for snippet in [
|
||||
"Err(error)",
|
||||
"complete_editor_canvas_generation",
|
||||
"source_record.resource.as_ref()",
|
||||
"return Ok(json_success_body",
|
||||
"warning: merge_editor_generation_warnings(",
|
||||
"editor_postprocess_fallback_warning(",
|
||||
// 中文注释:角色分支现在走带尺寸告警的重载,断言只固定共同前缀。
|
||||
"editor_postprocess_fallback_warning",
|
||||
] {
|
||||
assert!(
|
||||
fallback.contains(snippet),
|
||||
@@ -11930,6 +12095,30 @@ mod tests {
|
||||
assert!(success_tail.contains("persist_editor_generated_image("));
|
||||
assert!(success_tail.contains("persist_editor_generated_asset("));
|
||||
assert!(success_tail.contains(".await?;"));
|
||||
|
||||
let alpha_restore = body
|
||||
.find("apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original")
|
||||
.expect("character postprocess should restore alpha to the source dimensions");
|
||||
let dimension_fallback = body[alpha_restore..]
|
||||
.find("if let Some(error) = postprocess_dimension_error")
|
||||
.map(|offset| alpha_restore + offset)
|
||||
.expect("character postprocess dimension errors should be handled");
|
||||
let processed_image = body[dimension_fallback..]
|
||||
.find("image = restored_removal_image;")
|
||||
.map(|offset| dimension_fallback + offset)
|
||||
.expect("processed image should only continue after dimension validation");
|
||||
let dimension_fallback_body = &body[dimension_fallback..processed_image];
|
||||
for snippet in [
|
||||
"complete_editor_canvas_generation",
|
||||
"source_record.resource.as_ref()",
|
||||
"return Ok(json_success_body",
|
||||
"warning: Some(editor_postprocess_fallback_warning",
|
||||
] {
|
||||
assert!(
|
||||
dimension_fallback_body.contains(snippet),
|
||||
"character dimension fallback should contain {snippet}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
dispatchPointerEvent,
|
||||
ImageCanvasEditorView,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
withEditorProjectCanvasRevision,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
function render(ui: Parameters<typeof testingLibraryRender>[0]) {
|
||||
@@ -870,7 +871,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
|
||||
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '生成资源项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
@@ -900,7 +901,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
}));
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
@@ -971,14 +972,14 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
}));
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
|
||||
@@ -74,17 +74,12 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
uploadEditorMediaAssetObjectFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./ImageCanvasProjectCoverSnapshotModel')
|
||||
>('./ImageCanvasProjectCoverSnapshotModel');
|
||||
return {
|
||||
...actual,
|
||||
createProjectCoverSnapshotBlob: vi.fn(async () => null),
|
||||
};
|
||||
});
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||||
createProjectCoverSnapshotBlob: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
describe('ImageCanvasEditorView generation integration', () => {
|
||||
setupImageCanvasEditorViewTestLifecycle({
|
||||
@@ -415,6 +410,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
project: {
|
||||
projectId: input.projectId,
|
||||
title: '默认项目',
|
||||
canvas: { revision: 1 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
resources: [
|
||||
...projectResources,
|
||||
@@ -482,6 +478,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-generated-dialog',
|
||||
title: '已生成画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -551,6 +548,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-generated-layer-only',
|
||||
title: '已生成画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -605,6 +603,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-uploaded-tagged-layer',
|
||||
title: '标签素材画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -650,6 +649,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-context-tagged-layer',
|
||||
title: '右键标签素材画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -700,6 +700,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-publication-dialog',
|
||||
title: '宣发画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -1338,6 +1339,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icons-generating',
|
||||
title: '图标素材生成中画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -1710,6 +1712,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: rememberedProjectLayers,
|
||||
resources: rememberedProjectResources,
|
||||
@@ -2345,6 +2348,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: characterReferenceProjectLayers,
|
||||
resources: characterReferenceProjectResources,
|
||||
@@ -2522,6 +2526,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icons',
|
||||
title: '图标素材画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -2697,6 +2702,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icon-library-refresh',
|
||||
title: '图标素材刷新画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -2842,6 +2848,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId,
|
||||
title: '刷新恢复画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -2884,6 +2891,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId,
|
||||
title: '刷新恢复画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -3152,6 +3160,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-character-animation',
|
||||
title: '角色动画画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -3379,6 +3388,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-ui-extract',
|
||||
title: 'UI素材提取画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -3552,6 +3562,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-ui-extract-dismiss',
|
||||
title: 'UI素材提取外部关闭画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { ContextType } from 'react';
|
||||
import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type {
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||
|
||||
@@ -73,7 +78,7 @@ export const defaultEditorProjectResources = [
|
||||
height: 405,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
];
|
||||
] satisfies EditorProjectResourceSnapshot[];
|
||||
|
||||
export const defaultEditorProjectLayers = [
|
||||
{
|
||||
@@ -102,7 +107,31 @@ export const defaultEditorProjectLayers = [
|
||||
zIndex: 2,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
];
|
||||
] satisfies EditorProjectLayerSnapshot[];
|
||||
|
||||
export function withEditorProjectCanvasRevision<
|
||||
T extends EditorProjectSnapshot,
|
||||
>(project: T, revision = 0): T {
|
||||
if (typeof project.canvas?.revision === 'number') {
|
||||
return project;
|
||||
}
|
||||
return {
|
||||
...project,
|
||||
canvas: {
|
||||
canvasId:
|
||||
project.canvas?.canvasId ?? `${project.projectId}:canvas:default`,
|
||||
projectId: project.projectId,
|
||||
title: project.canvas?.title ?? '默认画布',
|
||||
viewport: project.canvas?.viewport ?? project.viewport,
|
||||
layers: project.canvas?.layers ?? project.layers,
|
||||
revision,
|
||||
layoutStorageVersion: project.canvas?.layoutStorageVersion ?? 0,
|
||||
backgroundColor: project.canvas?.backgroundColor,
|
||||
createdAt: project.canvas?.createdAt,
|
||||
updatedAt: project.canvas?.updatedAt ?? project.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultEditorAssetLibraryAssets = [
|
||||
{
|
||||
@@ -235,14 +264,14 @@ export function setupImageCanvasEditorViewTestLifecycle({
|
||||
window.sessionStorage.clear();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
|
||||
immediateAsync({
|
||||
immediateAsync(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: defaultEditorProjectLayers,
|
||||
resources: defaultEditorProjectResources,
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
})),
|
||||
);
|
||||
loadEditorAssetLibraryMock.mockImplementation(() =>
|
||||
immediateAsync({
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
immediateAsync,
|
||||
readZipText,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
withEditorProjectCanvasRevision,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
type EditorAgentListConversations =
|
||||
@@ -457,14 +458,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-guide',
|
||||
title: '空白项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
loadEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-guide',
|
||||
title: '空白项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
@@ -505,14 +508,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('opens the requested canvas startup tool from the project query once', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-music',
|
||||
title: '音乐项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
loadEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-music',
|
||||
title: '音乐项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
@@ -528,7 +533,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(window.location.search).toBe('?projectid=editor-project-music');
|
||||
});
|
||||
|
||||
it('shows the loaded project title and a topbar entry back to projects', async () => {
|
||||
it('flushes project persistence before returning from the topbar', async () => {
|
||||
const onPopState = vi.fn();
|
||||
window.addEventListener('popstate', onPopState);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(
|
||||
@@ -538,6 +545,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(projectLink.getAttribute('href')).toBe('/project');
|
||||
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
|
||||
fireEvent.click(projectLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/project');
|
||||
expect(onPopState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
it('opens login modal when the asset library is unauthorized', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import {
|
||||
createEditorAsset,
|
||||
@@ -310,9 +311,9 @@ export function ImageCanvasEditorView({
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
const captureCanvasHistoryRef = useRef<
|
||||
(action: CanvasHistoryAction) => void
|
||||
>(() => {});
|
||||
const captureCanvasHistoryRef = useRef<(action: CanvasHistoryAction) => void>(
|
||||
() => {},
|
||||
);
|
||||
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
|
||||
(matchesLayer: (layer: CanvasLayer) => boolean) => void
|
||||
>(() => {});
|
||||
@@ -790,10 +791,8 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
const canvasHistoryEffects = useMemo(
|
||||
() => ({
|
||||
persistRestoredAssetKind: (
|
||||
layer: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) =>
|
||||
persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1159,12 +1158,14 @@ export function ImageCanvasEditorView({
|
||||
isProjectReady,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
} = useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasSize,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
@@ -1172,6 +1173,11 @@ export function ImageCanvasEditorView({
|
||||
openEditorLoginModal,
|
||||
onProjectAccessLost,
|
||||
});
|
||||
const returnToProjects = useCallback(async () => {
|
||||
await flushProjectPersistence();
|
||||
replaceAppHistoryPath('/project');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, [flushProjectPersistence]);
|
||||
const setEditorProjectContextId = useImageCanvasContextStore(
|
||||
(state) => state.setProjectId,
|
||||
);
|
||||
@@ -1368,11 +1374,7 @@ export function ImageCanvasEditorView({
|
||||
const rollbackLayer = layersRef.current.find(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
);
|
||||
persistUpdatedLayerResource(
|
||||
layer,
|
||||
rollbackLayer ?? layer,
|
||||
historyEntryId,
|
||||
);
|
||||
persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId);
|
||||
};
|
||||
const {
|
||||
assetExportStatus,
|
||||
@@ -1477,7 +1479,9 @@ export function ImageCanvasEditorView({
|
||||
if (!projectId || tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
|
||||
const warning = tasks
|
||||
.find((task) => task.warning?.trim())
|
||||
?.warning?.trim();
|
||||
if (warning) {
|
||||
showGenerationWarning(warning);
|
||||
}
|
||||
@@ -2001,9 +2005,7 @@ export function ImageCanvasEditorView({
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({
|
||||
type: targetLayerIds.length
|
||||
? 'move-image'
|
||||
: 'move-generation-result',
|
||||
type: targetLayerIds.length ? 'move-image' : 'move-generation-result',
|
||||
count: targetLayerIds.length || targetDialogIds.length,
|
||||
});
|
||||
if (targetLayerIds.length) {
|
||||
@@ -2309,6 +2311,7 @@ export function ImageCanvasEditorView({
|
||||
onRequestWalletDetails: loadRechargeCenter,
|
||||
onRecharge: openAccountPaymentModal,
|
||||
onOpenWalletLedger: openWalletLedgerPanel,
|
||||
onReturnToProjects: returnToProjects,
|
||||
onOpenAccount: () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
buildProjectCoverSnapshotSignature,
|
||||
normalizeProjectCoverSnapshotViewport,
|
||||
resolveProjectCoverDrawableLayers,
|
||||
resolveProjectCoverSnapshotFrame,
|
||||
} from './ImageCanvasProjectCoverSnapshotModel';
|
||||
|
||||
function layer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
@@ -25,9 +27,33 @@ function layer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
}
|
||||
|
||||
describe('ImageCanvasProjectCoverSnapshotModel', () => {
|
||||
it('uses at least a centered 1280 by 960 viewport while preserving larger viewports', () => {
|
||||
expect(
|
||||
normalizeProjectCoverSnapshotViewport({
|
||||
viewport: { x: 12, y: -8, scale: 0.5 },
|
||||
viewportSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({
|
||||
viewport: { x: 202, y: 152, scale: 0.5 },
|
||||
viewportSize: { width: 1280, height: 960 },
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeProjectCoverSnapshotViewport({
|
||||
viewport: { x: 12, y: -8, scale: 0.5 },
|
||||
viewportSize: { width: 1920, height: 1080 },
|
||||
}),
|
||||
).toEqual({
|
||||
viewport: { x: 12, y: -8, scale: 0.5 },
|
||||
viewportSize: { width: 1920, height: 1080 },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a stable cover signature from visible drawable layers', () => {
|
||||
const signature = buildProjectCoverSnapshotSignature({
|
||||
viewport: { x: 12, y: -8, scale: 0.75 },
|
||||
viewportSize: { width: 900, height: 640 },
|
||||
backgroundColor: '#f8fafc',
|
||||
layers: [
|
||||
layer({ id: 'hidden', hidden: true }),
|
||||
layer({
|
||||
@@ -49,12 +75,36 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => {
|
||||
});
|
||||
|
||||
expect(signature).toContain('"scale":0.75');
|
||||
expect(signature).toContain('"width":900');
|
||||
expect(signature).toContain('"backgroundColor":"#f8fafc"');
|
||||
expect(signature).toContain('"id":"back"');
|
||||
expect(signature).toContain('"id":"front"');
|
||||
expect(signature).not.toContain('"id":"hidden"');
|
||||
expect(signature).not.toContain('"id":"audio"');
|
||||
});
|
||||
|
||||
it('center-crops the live canvas viewport to fill the 4:3 cover', () => {
|
||||
const landscapeFrame = resolveProjectCoverSnapshotFrame({
|
||||
viewport: { x: -260, y: 70, scale: 0.5 },
|
||||
viewportSize: { width: 900, height: 640 },
|
||||
});
|
||||
|
||||
expect(landscapeFrame.renderScale).toBe(0.5);
|
||||
expect(landscapeFrame.frameScale).toBeCloseTo(0.375);
|
||||
expect(landscapeFrame.offsetX).toBeCloseTo(-8.75);
|
||||
expect(landscapeFrame.offsetY).toBeCloseTo(0);
|
||||
|
||||
const portraitFrame = resolveProjectCoverSnapshotFrame({
|
||||
viewport: { x: 30, y: -40, scale: 1.25 },
|
||||
viewportSize: { width: 640, height: 900 },
|
||||
});
|
||||
|
||||
expect(portraitFrame.renderScale).toBe(1.25);
|
||||
expect(portraitFrame.frameScale).toBeCloseTo(0.5);
|
||||
expect(portraitFrame.offsetX).toBeCloseTo(0);
|
||||
expect(portraitFrame.offsetY).toBeCloseTo(-105);
|
||||
});
|
||||
|
||||
it('uses the first image-sequence frame as the drawable source', () => {
|
||||
expect(
|
||||
resolveProjectCoverDrawableLayers([
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import { getSignedAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
|
||||
export const PROJECT_COVER_SNAPSHOT_ASSET_KIND = 'project-cover-snapshot';
|
||||
export const PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND =
|
||||
'editor_project_cover_snapshot';
|
||||
export const PROJECT_COVER_SNAPSHOT_SIZE = { width: 320, height: 240 } as const;
|
||||
export const PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE = {
|
||||
width: 900,
|
||||
height: 640,
|
||||
export const PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE = {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
} as const;
|
||||
|
||||
type ProjectCoverDrawableLayer = {
|
||||
export type ProjectCoverSnapshotViewportSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type ProjectCoverDrawableLayer = {
|
||||
id: string;
|
||||
src: string;
|
||||
objectKey: string | null;
|
||||
cacheVersion: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -25,6 +34,73 @@ function numberOrFallback(value: number, fallback: number) {
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function positiveNumberOrFallback(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeProjectCoverViewportSize(
|
||||
viewportSize: ProjectCoverSnapshotViewportSize,
|
||||
) {
|
||||
return {
|
||||
width: positiveNumberOrFallback(
|
||||
viewportSize.width,
|
||||
PROJECT_COVER_SNAPSHOT_SIZE.width,
|
||||
),
|
||||
height: positiveNumberOrFallback(
|
||||
viewportSize.height,
|
||||
PROJECT_COVER_SNAPSHOT_SIZE.height,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectCoverSnapshotViewport({
|
||||
viewport,
|
||||
viewportSize,
|
||||
}: {
|
||||
viewport: CanvasViewport;
|
||||
viewportSize: ProjectCoverSnapshotViewportSize;
|
||||
}) {
|
||||
const sourceViewportSize = {
|
||||
width: positiveNumberOrFallback(
|
||||
viewportSize.width,
|
||||
PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width,
|
||||
),
|
||||
height: positiveNumberOrFallback(
|
||||
viewportSize.height,
|
||||
PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height,
|
||||
),
|
||||
};
|
||||
const normalizedViewportSize = {
|
||||
width: Math.max(
|
||||
PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.width,
|
||||
sourceViewportSize.width,
|
||||
),
|
||||
height: Math.max(
|
||||
PROJECT_COVER_SNAPSHOT_MIN_VIEWPORT_SIZE.height,
|
||||
sourceViewportSize.height,
|
||||
),
|
||||
};
|
||||
return {
|
||||
viewport: {
|
||||
x:
|
||||
numberOrFallback(viewport.x, 0) +
|
||||
(normalizedViewportSize.width - sourceViewportSize.width) / 2,
|
||||
y:
|
||||
numberOrFallback(viewport.y, 0) +
|
||||
(normalizedViewportSize.height - sourceViewportSize.height) / 2,
|
||||
scale: numberOrFallback(viewport.scale, 1),
|
||||
},
|
||||
viewportSize: normalizedViewportSize,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectCoverBackgroundColor(backgroundColor: string) {
|
||||
return (
|
||||
normalizeCanvasBackgroundHex(backgroundColor) ??
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeObjectKey(value: string | null | undefined) {
|
||||
const normalized = value?.trim().replace(/^\/+/u, '') ?? '';
|
||||
return normalized || null;
|
||||
@@ -41,6 +117,7 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) {
|
||||
return {
|
||||
src: frameSrc,
|
||||
objectKey: normalizeObjectKey(frame?.objectKey),
|
||||
cacheVersion: `${layer.taskId ?? layer.resourceId}:${frame?.frameIndex ?? 0}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -53,6 +130,9 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) {
|
||||
objectKey: layer.thumbnailSrc?.trim()
|
||||
? null
|
||||
: normalizeObjectKey(layer.objectKey),
|
||||
cacheVersion: layer.thumbnailSrc?.trim()
|
||||
? `${layer.taskId ?? layer.resourceId}:poster`
|
||||
: (layer.taskId ?? layer.resourceId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,10 +149,14 @@ export function resolveProjectCoverDrawableLayers(
|
||||
id: layer.id,
|
||||
src: source.src,
|
||||
objectKey: source.objectKey,
|
||||
cacheVersion: source.cacheVersion,
|
||||
x: numberOrFallback(layer.x, 0),
|
||||
y: numberOrFallback(layer.y, 0),
|
||||
width: Math.max(1, numberOrFallback(layer.width, layer.originalWidth)),
|
||||
height: Math.max(1, numberOrFallback(layer.height, layer.originalHeight)),
|
||||
height: Math.max(
|
||||
1,
|
||||
numberOrFallback(layer.height, layer.originalHeight),
|
||||
),
|
||||
zIndex: numberOrFallback(layer.zIndex, 0),
|
||||
} satisfies ProjectCoverDrawableLayer;
|
||||
})
|
||||
@@ -83,116 +167,78 @@ export function resolveProjectCoverDrawableLayers(
|
||||
export function buildProjectCoverSnapshotSignature({
|
||||
layers,
|
||||
viewport,
|
||||
viewportSize,
|
||||
backgroundColor,
|
||||
}: {
|
||||
layers: readonly CanvasLayer[];
|
||||
// we may call it camera
|
||||
viewport: CanvasViewport;
|
||||
viewportSize: ProjectCoverSnapshotViewportSize;
|
||||
backgroundColor: string;
|
||||
}) {
|
||||
const coverLayers = resolveProjectCoverDrawableLayers(layers);
|
||||
if (coverLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalizedViewportSize =
|
||||
normalizeProjectCoverViewportSize(viewportSize);
|
||||
return JSON.stringify({
|
||||
viewport: {
|
||||
x: numberOrFallback(viewport.x, 0),
|
||||
y: numberOrFallback(viewport.y, 0),
|
||||
scale: numberOrFallback(viewport.scale, 1),
|
||||
},
|
||||
viewportSize: normalizedViewportSize,
|
||||
backgroundColor: normalizeProjectCoverBackgroundColor(backgroundColor),
|
||||
layers: coverLayers,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveProjectCoverPlacement(
|
||||
viewport: CanvasViewport,
|
||||
layer: ProjectCoverDrawableLayer,
|
||||
) {
|
||||
const safeScale =
|
||||
export function resolveProjectCoverSnapshotFrame({
|
||||
viewport,
|
||||
viewportSize,
|
||||
}: {
|
||||
viewport: CanvasViewport;
|
||||
viewportSize: ProjectCoverSnapshotViewportSize;
|
||||
}) {
|
||||
const normalizedViewportSize =
|
||||
normalizeProjectCoverViewportSize(viewportSize);
|
||||
const renderScale =
|
||||
viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1;
|
||||
const viewportCenterX =
|
||||
(PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / 2 - viewport.x) / safeScale;
|
||||
const viewportCenterY =
|
||||
(PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / 2 - viewport.y) / safeScale;
|
||||
const worldPreviewWidth =
|
||||
PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / safeScale;
|
||||
const worldPreviewHeight =
|
||||
PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / safeScale;
|
||||
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||
const scale = Math.min(
|
||||
PROJECT_COVER_SNAPSHOT_SIZE.width / worldPreviewWidth,
|
||||
PROJECT_COVER_SNAPSHOT_SIZE.height / worldPreviewHeight,
|
||||
const frameScale = Math.max(
|
||||
PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width,
|
||||
PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height,
|
||||
);
|
||||
const offsetX = -previewMinX * scale;
|
||||
const offsetY = -previewMinY * scale;
|
||||
return {
|
||||
x: offsetX + layer.x * scale,
|
||||
y: offsetY + layer.y * scale,
|
||||
width: layer.width * scale,
|
||||
height: layer.height * scale,
|
||||
viewportX: numberOrFallback(viewport.x, 0),
|
||||
viewportY: numberOrFallback(viewport.y, 0),
|
||||
renderScale,
|
||||
frameScale,
|
||||
offsetX:
|
||||
(PROJECT_COVER_SNAPSHOT_SIZE.width -
|
||||
normalizedViewportSize.width * frameScale) /
|
||||
2,
|
||||
offsetY:
|
||||
(PROJECT_COVER_SNAPSHOT_SIZE.height -
|
||||
normalizedViewportSize.height * frameScale) /
|
||||
2,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) {
|
||||
const src = layer.objectKey
|
||||
? await getSignedAssetReadUrl({ objectKey: layer.objectKey })
|
||||
: layer.src;
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('project cover image load failed'));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProjectCoverSnapshotBlob({
|
||||
layers,
|
||||
viewport,
|
||||
}: {
|
||||
layers: readonly CanvasLayer[];
|
||||
viewport: CanvasViewport;
|
||||
}) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const coverLayers = resolveProjectCoverDrawableLayers(layers);
|
||||
if (coverLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width;
|
||||
canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context || typeof canvas.toBlob !== 'function') {
|
||||
return null;
|
||||
}
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
let drawnLayerCount = 0;
|
||||
for (const layer of coverLayers) {
|
||||
try {
|
||||
const image = await loadProjectCoverImage(layer);
|
||||
const placement = resolveProjectCoverPlacement(viewport, layer);
|
||||
context.drawImage(
|
||||
image,
|
||||
placement.x,
|
||||
placement.y,
|
||||
placement.width,
|
||||
placement.height,
|
||||
);
|
||||
drawnLayerCount += 1;
|
||||
} catch {
|
||||
// Individual layer load failures should not block a cover from other layers.
|
||||
}
|
||||
}
|
||||
|
||||
if (drawnLayerCount === 0) {
|
||||
return null;
|
||||
}
|
||||
return canvasToBlob(canvas);
|
||||
export function resolveProjectCoverPlacement(
|
||||
viewport: CanvasViewport,
|
||||
viewportSize: ProjectCoverSnapshotViewportSize,
|
||||
layer: ProjectCoverDrawableLayer,
|
||||
) {
|
||||
const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize });
|
||||
return {
|
||||
x:
|
||||
frame.offsetX +
|
||||
(frame.viewportX + layer.x * frame.renderScale) * frame.frameScale,
|
||||
y:
|
||||
frame.offsetY +
|
||||
(frame.viewportY + layer.y * frame.renderScale) * frame.frameScale,
|
||||
width: layer.width * frame.renderScale * frame.frameScale,
|
||||
height: layer.height * frame.renderScale * frame.frameScale,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer';
|
||||
|
||||
function layer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-offscreen',
|
||||
resourceId: 'resource-offscreen',
|
||||
title: '离屏图层',
|
||||
src: '/generated/offscreen.webp',
|
||||
x: 10_000,
|
||||
y: 10_000,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasProjectCoverSnapshotRenderer', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exports the canvas background when every drawable layer is outside the cover frame', async () => {
|
||||
const fillRect = vi.fn();
|
||||
const drawImage = vi.fn();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
fillRect,
|
||||
drawImage,
|
||||
fillStyle: '',
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback) => callback(new Blob(['cover'], { type: 'image/webp' })),
|
||||
);
|
||||
|
||||
const blob = await createProjectCoverSnapshotBlob({
|
||||
layers: [layer()],
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
viewportSize: { width: 1280, height: 960 },
|
||||
backgroundColor: '#f8fafc',
|
||||
});
|
||||
|
||||
expect(blob?.type).toBe('image/webp');
|
||||
expect(fillRect).toHaveBeenCalledWith(0, 0, 320, 240);
|
||||
expect(drawImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
getSignedAssetReadUrl,
|
||||
resolveAssetReadUrl,
|
||||
shouldResolveAssetReadUrl,
|
||||
} from '../../services/assetReadUrlService';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
normalizeProjectCoverBackgroundColor,
|
||||
PROJECT_COVER_SNAPSHOT_SIZE,
|
||||
type ProjectCoverDrawableLayer,
|
||||
type ProjectCoverSnapshotViewportSize,
|
||||
resolveProjectCoverDrawableLayers,
|
||||
resolveProjectCoverPlacement,
|
||||
} from './ImageCanvasProjectCoverSnapshotModel';
|
||||
|
||||
const PROJECT_COVER_IMAGE_LOAD_CONCURRENCY = 6;
|
||||
const PROJECT_COVER_SNAPSHOT_MIME_TYPE = 'image/webp';
|
||||
const PROJECT_COVER_SNAPSHOT_WEBP_QUALITY = 0.82;
|
||||
|
||||
type ProjectCoverDrawItem = {
|
||||
layer: ProjectCoverDrawableLayer;
|
||||
placement: ReturnType<typeof resolveProjectCoverPlacement>;
|
||||
};
|
||||
|
||||
function placementIntersectsCover(
|
||||
placement: ProjectCoverDrawItem['placement'],
|
||||
) {
|
||||
return (
|
||||
placement.x < PROJECT_COVER_SNAPSHOT_SIZE.width &&
|
||||
placement.y < PROJECT_COVER_SNAPSHOT_SIZE.height &&
|
||||
placement.x + placement.width > 0 &&
|
||||
placement.y + placement.height > 0
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProjectCoverImageUrl(layer: ProjectCoverDrawableLayer) {
|
||||
if (layer.objectKey) {
|
||||
return getSignedAssetReadUrl({ objectKey: layer.objectKey }, undefined, {
|
||||
cacheVersion: layer.cacheVersion,
|
||||
});
|
||||
}
|
||||
if (shouldResolveAssetReadUrl(layer.src)) {
|
||||
return resolveAssetReadUrl(layer.src, {
|
||||
refreshKey: layer.cacheVersion,
|
||||
});
|
||||
}
|
||||
return layer.src;
|
||||
}
|
||||
|
||||
async function loadProjectCoverImage(layer: ProjectCoverDrawableLayer) {
|
||||
const src = await resolveProjectCoverImageUrl(layer);
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('project cover image load failed'));
|
||||
image.src = src;
|
||||
});
|
||||
return image;
|
||||
}
|
||||
|
||||
async function loadProjectCoverDrawItems(items: ProjectCoverDrawItem[]) {
|
||||
const loadedImages = new Array<HTMLImageElement | null>(items.length).fill(
|
||||
null,
|
||||
);
|
||||
let nextIndex = 0;
|
||||
const loadNext = async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
try {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
loadedImages[index] = await loadProjectCoverImage(item.layer);
|
||||
}
|
||||
} catch {
|
||||
// Individual layer load failures should not block other cover layers.
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(PROJECT_COVER_IMAGE_LOAD_CONCURRENCY, items.length) },
|
||||
() => loadNext(),
|
||||
),
|
||||
);
|
||||
return loadedImages;
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => resolve(blob),
|
||||
PROJECT_COVER_SNAPSHOT_MIME_TYPE,
|
||||
PROJECT_COVER_SNAPSHOT_WEBP_QUALITY,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProjectCoverSnapshotBlob({
|
||||
layers,
|
||||
viewport,
|
||||
viewportSize,
|
||||
backgroundColor,
|
||||
}: {
|
||||
layers: readonly CanvasLayer[];
|
||||
viewport: CanvasViewport;
|
||||
viewportSize: ProjectCoverSnapshotViewportSize;
|
||||
backgroundColor: string;
|
||||
}) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const drawableLayers = resolveProjectCoverDrawableLayers(layers);
|
||||
if (drawableLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const drawItems = drawableLayers
|
||||
.map((layer) => ({
|
||||
layer,
|
||||
placement: resolveProjectCoverPlacement(viewport, viewportSize, layer),
|
||||
}))
|
||||
.filter(({ placement }) => placementIntersectsCover(placement));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECT_COVER_SNAPSHOT_SIZE.width;
|
||||
canvas.height = PROJECT_COVER_SNAPSHOT_SIZE.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context || typeof canvas.toBlob !== 'function') {
|
||||
return null;
|
||||
}
|
||||
context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor);
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (drawItems.length === 0) {
|
||||
return canvasToBlob(canvas);
|
||||
}
|
||||
|
||||
const loadedImages = await loadProjectCoverDrawItems(drawItems);
|
||||
let drawnLayerCount = 0;
|
||||
drawItems.forEach(({ placement }, index) => {
|
||||
const image = loadedImages[index];
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
context.drawImage(
|
||||
image,
|
||||
placement.x,
|
||||
placement.y,
|
||||
placement.width,
|
||||
placement.height,
|
||||
);
|
||||
drawnLayerCount += 1;
|
||||
});
|
||||
if (drawnLayerCount === 0) {
|
||||
return null;
|
||||
}
|
||||
return canvasToBlob(canvas);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -67,6 +73,7 @@ function renderTopbar(
|
||||
onRecharge: vi.fn(),
|
||||
onOpenWalletLedger: vi.fn(),
|
||||
onOpenAccount: vi.fn(),
|
||||
onReturnToProjects: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -123,6 +130,29 @@ describe('ImageCanvasTopbarView', () => {
|
||||
expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for project persistence before allowing another return request', async () => {
|
||||
let resolveReturn!: () => void;
|
||||
const pendingReturn = new Promise<void>((resolve) => {
|
||||
resolveReturn = resolve;
|
||||
});
|
||||
const onReturnToProjects = vi.fn(() => pendingReturn);
|
||||
renderTopbar({ onReturnToProjects });
|
||||
const projectLink = screen.getByRole('link', {
|
||||
name: '返回项目页面',
|
||||
});
|
||||
|
||||
fireEvent.click(projectLink);
|
||||
fireEvent.click(projectLink);
|
||||
|
||||
expect(onReturnToProjects).toHaveBeenCalledTimes(1);
|
||||
expect(projectLink.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
resolveReturn();
|
||||
await waitFor(() => {
|
||||
expect(projectLink.getAttribute('aria-disabled')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the current user avatar beside the mud point balance', () => {
|
||||
const props = renderTopbar({
|
||||
currentUser: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Pencil,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
|
||||
|
||||
@@ -51,6 +52,7 @@ export type ImageCanvasTopbarViewProps = {
|
||||
onRecharge: () => void;
|
||||
onOpenWalletLedger: () => void;
|
||||
onOpenAccount: () => void;
|
||||
onReturnToProjects?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) {
|
||||
@@ -88,7 +90,9 @@ export function ImageCanvasTopbarView({
|
||||
onRecharge,
|
||||
onOpenWalletLedger,
|
||||
onOpenAccount,
|
||||
onReturnToProjects,
|
||||
}: ImageCanvasTopbarViewProps) {
|
||||
const [isReturningToProjects, setIsReturningToProjects] = useState(false);
|
||||
const projectId = useImageCanvasContextStore((state) => state.projectId);
|
||||
const hasExportableLayer = layers.some(
|
||||
(layer) => layer.src.trim().length > 0,
|
||||
@@ -107,7 +111,21 @@ export function ImageCanvasTopbarView({
|
||||
className="image-canvas-editor__project-back-button"
|
||||
href="/project"
|
||||
aria-label="返回项目页面"
|
||||
aria-disabled={isReturningToProjects}
|
||||
title="返回项目"
|
||||
onClick={(event) => {
|
||||
if (!onReturnToProjects) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (isReturningToProjects) {
|
||||
return;
|
||||
}
|
||||
setIsReturningToProjects(true);
|
||||
void Promise.resolve(onReturnToProjects()).finally(() => {
|
||||
setIsReturningToProjects(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
@@ -27,15 +27,9 @@ const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY =
|
||||
'genarrative.imageCanvas.projectSnapshot.v2:user-test:recent';
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./ImageCanvasProjectCoverSnapshotModel')
|
||||
>('./ImageCanvasProjectCoverSnapshotModel');
|
||||
return {
|
||||
...actual,
|
||||
createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock,
|
||||
};
|
||||
});
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||||
createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -51,7 +45,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
|
||||
@@ -115,9 +109,11 @@ function ProjectPersistenceHarness({
|
||||
y: 0,
|
||||
scale: 1,
|
||||
});
|
||||
const canvasSize = { width: 900, height: 640 };
|
||||
const [isViewportInteracting, setIsViewportInteracting] = useState(false);
|
||||
const [projectTitle, setProjectTitle] = useState('');
|
||||
const [projectRenameValue, setProjectRenameValue] = useState('');
|
||||
const [flushCompleted, setFlushCompleted] = useState(false);
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
||||
@@ -173,6 +169,7 @@ function ProjectPersistenceHarness({
|
||||
layers,
|
||||
canvasGenerationDialogs: generationDialogs,
|
||||
viewport,
|
||||
canvasSize,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
@@ -213,6 +210,18 @@ function ProjectPersistenceHarness({
|
||||
.join(',')}
|
||||
</span>
|
||||
<span data-testid="background">{canvasBackgroundColor}</span>
|
||||
<span data-testid="flush-completed">{String(flushCompleted)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFlushCompleted(false);
|
||||
void persistence.flushProjectPersistence().then(() => {
|
||||
setFlushCompleted(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
flush project persistence
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -989,7 +998,9 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(staleReload.promise);
|
||||
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
|
||||
staleReload.promise,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(<ProjectPersistenceHarness />);
|
||||
await Promise.resolve();
|
||||
@@ -1605,6 +1616,15 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
viewport: { x: 202, y: 152, scale: 0.5 },
|
||||
viewportSize: { width: 1280, height: 960 },
|
||||
backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -1718,6 +1738,59 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes the latest layout and waits for the local cover cache before returning', async () => {
|
||||
const coverBlob = new Blob(['cover'], { type: 'image/webp' });
|
||||
const coverCacheWrite = createDeferred<void>();
|
||||
createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob);
|
||||
putEditorProjectCoverCacheMock.mockReturnValue(coverCacheWrite.promise);
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append server resource' }).click();
|
||||
screen.getByRole('button', { name: 'move viewport' }).click();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5');
|
||||
});
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'flush project persistence' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
viewport: { x: 10, y: 5, scale: 1 },
|
||||
}),
|
||||
);
|
||||
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
viewport: { x: 200, y: 165, scale: 0.5 },
|
||||
viewportSize: { width: 1280, height: 960 },
|
||||
}),
|
||||
);
|
||||
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
|
||||
projectId: 'editor-project-default',
|
||||
blob: coverBlob,
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('flush-completed').textContent).toBe('false');
|
||||
|
||||
coverCacheWrite.resolve();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('flush-completed').textContent).toBe('true');
|
||||
});
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({ assetKind: 'project-cover-snapshot' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads and saves viewport scale with display zoom semantics', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
@@ -1739,11 +1812,12 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
|
||||
it('does not serialize or write session cache while viewport interactions are active', async () => {
|
||||
const sessionSetItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
sessionSetItemSpy.mockClear();
|
||||
const cachedBeforeInteraction = globalThis.sessionStorage.getItem(
|
||||
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
||||
);
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1762,7 +1836,11 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
expect(sessionSetItemSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
globalThis.sessionStorage.getItem(
|
||||
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
||||
),
|
||||
).toBe(cachedBeforeInteraction);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'end viewport interaction' }).click();
|
||||
@@ -1780,9 +1858,17 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
viewport: { x: 20, y: 10, scale: 1 },
|
||||
}),
|
||||
);
|
||||
expect(sessionSetItemSpy).toHaveBeenCalled();
|
||||
const cachedAfterInteraction = JSON.parse(
|
||||
globalThis.sessionStorage.getItem(
|
||||
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
||||
) ?? '{}',
|
||||
) as { project?: EditorProjectSnapshot };
|
||||
expect(cachedAfterInteraction.project?.viewport).toEqual({
|
||||
x: 20,
|
||||
y: 10,
|
||||
scale: 1,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
sessionSetItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not create a project resource again for backend-persisted generated layers', async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import { uploadEditorMediaAssetObjectFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
type EditorProjectResourceSnapshot,
|
||||
@@ -35,11 +35,13 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
buildProjectCoverSnapshotSignature,
|
||||
createProjectCoverSnapshotBlob,
|
||||
normalizeProjectCoverSnapshotViewport,
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
|
||||
PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND,
|
||||
PROJECT_COVER_SNAPSHOT_SIZE,
|
||||
type ProjectCoverSnapshotViewportSize,
|
||||
} from './ImageCanvasProjectCoverSnapshotModel';
|
||||
import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer';
|
||||
|
||||
type ProjectResourceOptions = {
|
||||
onCreated?: (resourceId: string) => void;
|
||||
@@ -111,6 +113,7 @@ type ImageCanvasProjectPersistenceOptions = {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: ProjectCoverSnapshotViewportSize;
|
||||
canvasBackgroundColor: string;
|
||||
isViewportInteracting: boolean;
|
||||
canAccessProtectedData: boolean;
|
||||
@@ -120,10 +123,7 @@ type ImageCanvasProjectPersistenceOptions = {
|
||||
};
|
||||
|
||||
function isEditorAuthError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 401
|
||||
);
|
||||
return error instanceof ApiClientError && error.status === 401;
|
||||
}
|
||||
|
||||
function isEditorProjectAccessError(error: unknown) {
|
||||
@@ -377,6 +377,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasSize,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
@@ -416,11 +417,17 @@ export function useImageCanvasProjectPersistence({
|
||||
null,
|
||||
);
|
||||
const isProjectLayoutSaveRunningRef = useRef(false);
|
||||
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
|
||||
const skipNextProjectLayoutSaveRef = useRef(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const projectTitleRef = useRef('未命名画布');
|
||||
const coverSnapshotSignatureRef = useRef<string | null>(null);
|
||||
const coverSnapshotUploadRequestRef = useRef(0);
|
||||
const activeCoverSnapshotPersistenceRef = useRef<{
|
||||
signature: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const coverSnapshotViewportSizeRef = useRef(canvasSize);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||
const {
|
||||
@@ -433,6 +440,7 @@ export function useImageCanvasProjectPersistence({
|
||||
restoreCanvasGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
} = setters;
|
||||
coverSnapshotViewportSizeRef.current = canvasSize;
|
||||
|
||||
const clearPendingProjectLayoutSave = useCallback(() => {
|
||||
pendingProjectLayoutSaveRef.current = null;
|
||||
@@ -476,7 +484,7 @@ export function useImageCanvasProjectPersistence({
|
||||
hasAuthoritativeProjectSnapshotRef.current &&
|
||||
authoritativeProjectIdRef.current === pendingSave.projectId;
|
||||
let runNextSave = false;
|
||||
void saveEditorProjectLayout(pendingSave.projectId, {
|
||||
const savePromise = saveEditorProjectLayout(pendingSave.projectId, {
|
||||
...pendingSave.input,
|
||||
expectedRevision,
|
||||
})
|
||||
@@ -580,6 +588,9 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeProjectLayoutSavePromiseRef.current === savePromise) {
|
||||
activeProjectLayoutSavePromiseRef.current = null;
|
||||
}
|
||||
isProjectLayoutSaveRunningRef.current = false;
|
||||
if (
|
||||
runNextSave &&
|
||||
@@ -589,6 +600,8 @@ export function useImageCanvasProjectPersistence({
|
||||
runPendingProjectLayoutSave();
|
||||
}
|
||||
});
|
||||
activeProjectLayoutSavePromiseRef.current = savePromise;
|
||||
void savePromise;
|
||||
},
|
||||
[openEditorLoginModal],
|
||||
);
|
||||
@@ -596,26 +609,44 @@ export function useImageCanvasProjectPersistence({
|
||||
const persistProjectCoverSnapshot = useCallback(
|
||||
(
|
||||
nextProjectId: string,
|
||||
coverViewport: CanvasViewport,
|
||||
coverDisplayViewport: CanvasViewport,
|
||||
coverLayers: readonly CanvasLayer[],
|
||||
) => {
|
||||
if (!canAccessProtectedData) {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
const rawCoverViewport =
|
||||
canvasDisplayViewportToViewport(coverDisplayViewport);
|
||||
const { viewport: coverViewport, viewportSize } =
|
||||
normalizeProjectCoverSnapshotViewport({
|
||||
viewport: rawCoverViewport,
|
||||
viewportSize: coverSnapshotViewportSizeRef.current,
|
||||
});
|
||||
const backgroundColor = refs.canvasBackgroundColorRef.current;
|
||||
const signature = buildProjectCoverSnapshotSignature({
|
||||
layers: coverLayers,
|
||||
viewport: coverViewport,
|
||||
viewportSize,
|
||||
backgroundColor,
|
||||
});
|
||||
if (!signature || coverSnapshotSignatureRef.current === signature) {
|
||||
return;
|
||||
if (!signature) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (coverSnapshotSignatureRef.current === signature) {
|
||||
return activeCoverSnapshotPersistenceRef.current?.signature ===
|
||||
signature
|
||||
? activeCoverSnapshotPersistenceRef.current.promise
|
||||
: Promise.resolve();
|
||||
}
|
||||
coverSnapshotSignatureRef.current = signature;
|
||||
coverSnapshotUploadRequestRef.current += 1;
|
||||
const requestId = coverSnapshotUploadRequestRef.current;
|
||||
|
||||
void createProjectCoverSnapshotBlob({
|
||||
const persistencePromise = createProjectCoverSnapshotBlob({
|
||||
layers: coverLayers,
|
||||
viewport: coverViewport,
|
||||
viewportSize,
|
||||
backgroundColor,
|
||||
})
|
||||
.then(async (blob) => {
|
||||
if (!blob) {
|
||||
@@ -627,14 +658,18 @@ export function useImageCanvasProjectPersistence({
|
||||
if (coverSnapshotUploadRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
void putEditorProjectCoverCache({
|
||||
await putEditorProjectCoverCache({
|
||||
projectId: nextProjectId,
|
||||
blob,
|
||||
});
|
||||
const coverFile = new File([blob], `${nextProjectId}-cover.png`, {
|
||||
type: 'image/png',
|
||||
});
|
||||
const uploadedCover = await uploadEditorMediaAssetFile(
|
||||
const coverFileExtension =
|
||||
blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
const coverFile = new File(
|
||||
[blob],
|
||||
`${nextProjectId}-cover.${coverFileExtension}`,
|
||||
{ type: blob.type || 'image/webp' },
|
||||
);
|
||||
const uploadedCover = await uploadEditorMediaAssetObjectFile(
|
||||
coverFile,
|
||||
'image',
|
||||
{
|
||||
@@ -671,9 +706,22 @@ export function useImageCanvasProjectPersistence({
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
activeCoverSnapshotPersistenceRef.current?.promise ===
|
||||
persistencePromise
|
||||
) {
|
||||
activeCoverSnapshotPersistenceRef.current = null;
|
||||
}
|
||||
});
|
||||
activeCoverSnapshotPersistenceRef.current = {
|
||||
signature,
|
||||
promise: persistencePromise,
|
||||
};
|
||||
return persistencePromise;
|
||||
},
|
||||
[canAccessProtectedData, openEditorLoginModal],
|
||||
[canAccessProtectedData, openEditorLoginModal, refs],
|
||||
);
|
||||
|
||||
const queueProjectLayoutSave = useCallback(
|
||||
@@ -683,7 +731,7 @@ export function useImageCanvasProjectPersistence({
|
||||
Parameters<typeof saveEditorProjectLayout>[1],
|
||||
'expectedRevision'
|
||||
>,
|
||||
options: { delayMs?: number } = {},
|
||||
options: { delayMs?: number; persistCover?: boolean } = {},
|
||||
) => {
|
||||
const revision = projectRevisionRef.current;
|
||||
if (
|
||||
@@ -717,24 +765,29 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
|
||||
const delayMs = options.delayMs ?? 0;
|
||||
const shouldPersistCover = options.persistCover !== false;
|
||||
if (delayMs > 0) {
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
if (shouldPersistCover) {
|
||||
void persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
}
|
||||
runPendingProjectLayoutSave();
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
if (shouldPersistCover) {
|
||||
void persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
input.viewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
}
|
||||
runPendingProjectLayoutSave();
|
||||
},
|
||||
[
|
||||
@@ -745,6 +798,69 @@ export function useImageCanvasProjectPersistence({
|
||||
],
|
||||
);
|
||||
|
||||
const flushProjectPersistence = useCallback(async () => {
|
||||
while (activeProjectLayoutSavePromiseRef.current) {
|
||||
await activeProjectLayoutSavePromiseRef.current;
|
||||
}
|
||||
|
||||
const nextProjectId = projectIdRef.current;
|
||||
if (
|
||||
!nextProjectId ||
|
||||
!canAccessProtectedDataRef.current ||
|
||||
!hasAuthoritativeProjectSnapshotRef.current ||
|
||||
authoritativeProjectIdRef.current !== nextProjectId ||
|
||||
projectRevisionRef.current === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coverDisplayViewport = viewportToCanvasDisplayViewport(
|
||||
refs.viewportRef.current,
|
||||
);
|
||||
queueProjectLayoutSave(
|
||||
nextProjectId,
|
||||
{
|
||||
viewport: coverDisplayViewport,
|
||||
layers: serializeCanvasLayout({
|
||||
layers: refs.layersRef.current,
|
||||
canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current,
|
||||
canvasBackgroundColor: refs.canvasBackgroundColorRef.current,
|
||||
}),
|
||||
},
|
||||
{ persistCover: false },
|
||||
);
|
||||
|
||||
const coverSave = persistProjectCoverSnapshot(
|
||||
nextProjectId,
|
||||
coverDisplayViewport,
|
||||
refs.layersRef.current,
|
||||
);
|
||||
while (
|
||||
activeProjectLayoutSavePromiseRef.current ||
|
||||
pendingProjectLayoutSaveRef.current
|
||||
) {
|
||||
const activeLayoutSave = activeProjectLayoutSavePromiseRef.current;
|
||||
if (activeLayoutSave) {
|
||||
await activeLayoutSave;
|
||||
continue;
|
||||
}
|
||||
if (saveTimerRef.current !== null) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
runPendingProjectLayoutSave();
|
||||
if (!activeProjectLayoutSavePromiseRef.current) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await coverSave;
|
||||
}, [
|
||||
persistProjectCoverSnapshot,
|
||||
queueProjectLayoutSave,
|
||||
refs,
|
||||
runPendingProjectLayoutSave,
|
||||
]);
|
||||
|
||||
const applyCreatedProjectResourceLayer = useCallback(
|
||||
(pendingLayer: PendingCreatedProjectResourceLayer) => {
|
||||
if (
|
||||
@@ -839,8 +955,7 @@ export function useImageCanvasProjectPersistence({
|
||||
const pendingLayer: PendingCreatedProjectResourceLayer = {
|
||||
projectId: readyProjectId,
|
||||
ownerUserId: requestOwnerUserId,
|
||||
authoritativeSnapshotSequence:
|
||||
requestAuthoritativeSnapshotSequence,
|
||||
authoritativeSnapshotSequence: requestAuthoritativeSnapshotSequence,
|
||||
layer,
|
||||
options,
|
||||
resourceId: resource.resourceId,
|
||||
@@ -1093,7 +1208,8 @@ export function useImageCanvasProjectPersistence({
|
||||
allowProjectSwitch: true,
|
||||
});
|
||||
if (!projectIsAuthoritative) {
|
||||
const activeAuthoritativeProjectId = authoritativeProjectIdRef.current;
|
||||
const activeAuthoritativeProjectId =
|
||||
authoritativeProjectIdRef.current;
|
||||
if (
|
||||
hasAuthoritativeProjectSnapshotRef.current &&
|
||||
activeAuthoritativeProjectId !== null &&
|
||||
@@ -1210,5 +1326,6 @@ export function useImageCanvasProjectPersistence({
|
||||
createProjectResourceForLayer,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export type UploadedEditorMediaAsset = {
|
||||
legacyPublicPath: string;
|
||||
};
|
||||
|
||||
export type UploadedEditorMediaAssetObject = Omit<
|
||||
UploadedEditorMediaAsset,
|
||||
'src'
|
||||
>;
|
||||
|
||||
export type EditorMediaAssetUploadOptions = {
|
||||
assetKind?: string;
|
||||
pathSegments?: string[];
|
||||
@@ -69,11 +74,11 @@ function sanitizeEditorMediaFileName(
|
||||
return trimmedName || resolveFallbackFileName(mediaType);
|
||||
}
|
||||
|
||||
export async function uploadEditorMediaAssetFile(
|
||||
export async function uploadEditorMediaAssetObjectFile(
|
||||
file: File,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
options: EditorMediaAssetUploadOptions = {},
|
||||
): Promise<UploadedEditorMediaAsset> {
|
||||
): Promise<UploadedEditorMediaAssetObject> {
|
||||
const assetKind = options.assetKind ?? resolveEditorMediaAssetKind(mediaType);
|
||||
const fileName = sanitizeEditorMediaFileName(file.name, mediaType);
|
||||
const contentType = file.type.trim() || 'application/octet-stream';
|
||||
@@ -128,20 +133,34 @@ export async function uploadEditorMediaAssetFile(
|
||||
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
|
||||
);
|
||||
|
||||
// 中文注释:素材库立即预览使用短期 signed URL;持久化仍记录 legacy path 和 objectKey。
|
||||
const signedUrl = await getSignedAssetReadUrl(
|
||||
{
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
|
||||
return {
|
||||
src: signedUrl,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
legacyPublicPath: ticket.upload.legacyPublicPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadEditorMediaAssetFile(
|
||||
file: File,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
options: EditorMediaAssetUploadOptions = {},
|
||||
): Promise<UploadedEditorMediaAsset> {
|
||||
const uploaded = await uploadEditorMediaAssetObjectFile(
|
||||
file,
|
||||
mediaType,
|
||||
options,
|
||||
);
|
||||
// 中文注释:只有需要立即预览的调用方才换签;只做持久化的封面路径直接复用 object-only 结果。
|
||||
const signedUrl = await getSignedAssetReadUrl(
|
||||
{
|
||||
objectKey: uploaded.objectKey,
|
||||
expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
return {
|
||||
src: signedUrl,
|
||||
...uploaded,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user