修复编辑器付费生成目标预检 #151
@@ -4391,3 +4391,10 @@
|
||||
- receipt 存在不等于引用 object 仍然可信:省略 candidate 的已登记 object 在重放时也要回读 owner/key/task/kind/媒体身份。同时先查同 operation ID job,存在 job 却漏传 completion 必须整笔回滚,否则会得到 receipt 成功而 job 仍 running 的永久分裂。resource/asset/binding 也不得仅核对 object ID/key,必须按 operation 合法 tuple 交叉验证业务元数据。
|
||||
- 验证:故障注入覆盖 resource 后 asset/binding 失败、canvas CAS 冲突、过期 lease、同 operation 异 fingerprint / 异 commit、receipt 缺失的部分既有记录、精确既有 object 复用与 object 内容漂移;成功重放必须证明记录数、时间、binding/job 事件数和 canvas revision 全部不变。
|
||||
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134。
|
||||
|
||||
## 付费生成不能把素材目录归属校验留到 provider 之后(2026-08-07)
|
||||
|
||||
- 现象:登录用户给自己的合法 `projectId` 搭配不存在或属于其他账号的 `assetFolderId`,图片、改图、图集、UI 提取、视频、角色动作或音频生成会先扣泥点并调用付费 provider / OSS,直到创建 `editor_asset` 才拒绝目录;失败退款让用户成本归零,平台侧 provider 和存储成本不可逆。
|
||||
- 原因:`normalize_generated_asset_folder_id` 只处理 `project`、旧 `folder-*` 和默认目录 ID 的兼容映射,不读取 SpacetimeDB;真正的 `require_owned_asset_folder` 位于生成结果持久化末端。把“失败会退款”误当成副作用补偿,漏掉退款不能撤销 provider 请求与 OSS PUT。
|
||||
- 处理:所有付费编辑器生成在队列 enqueue 前和 worker / inline 执行前复用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选项目及归一化目录;读取失败和归属不匹配一律失败关闭。helper 返回 canonical 项目与目录并覆写后续入队 / worker / 原子准备使用的 payload,不能校验 trim 后的项目却持久化原始空白值。角色图片、角色动作、图标 spritesheet 与 UI 提取省略目录时按实际默认目录预检;默认目录允许尚未创建,自定义目录必须存在且 owned。预检不替代最终 procedure 复验,也不保证跨外部调用的目录锁定。
|
||||
- 验证:源码顺序回归必须覆盖图片生成、图片修改、图标 spritesheet、UI 设计图提取、视频、角色动作、SFX 与 BGM 的 enqueue / direct 两层,证明纯本地格式和 `data:` / `blob:` 稳定引用门禁先执行,canonical target 在预检后写回 payload,远端引用解析、generation input rebuild、扣费、入队、provider 与 OSS 均留在预检之后;模块侧扫描证明预检只调用 runtime identity、项目、目录只读校验且不含 insert / update / delete,并覆盖带空白项目、`project`、旧 `folder-*`、默认目录 ID、自定义目录与 `None` 归一化。
|
||||
|
||||
@@ -240,10 +240,10 @@ npm run check:server-rs-ddd
|
||||
3. 每日免费泥点是独立于每日任务和会员周期的正式余额额度,基础发放量读取 `profile_wallet_config.daily_free_points_per_day`,不得由前端或后台任务配置改写。`profile_daily_free_points` 保存当前北京时间业务日、当日基础发放及跨日退款叠加后的总额度和剩余额度;北京时间每日 `00:00` 作为业务日边界,个人中心、充值中心、账单读取和钱包扣费入口在首次触达新业务日时原子清除昨日剩余及退款叠加量,并按当时最新配置重置今日 `granted_points`、`remaining_points`。同一业务日已初始化的用户不因后台改配置被即时追补或回收;新配置从尚未初始化当日额度的用户或下一次跨日重置起生效。首次初始化使用 `daily_free_grant` 流水,跨日重置使用 `daily_free_reset` 流水。充值中心的 `dailyFreeResetPoints` 显式来自该配置,不得用可因跨日退款增大的当日 `granted_points` 反推。惰性落库不能改变“北京时间 00:00 后读取即为新日额度”的对外语义。
|
||||
4. 每日任务奖励继续使用 `daily_task_reward` 流水并进入普通永久泥点,但主站隐藏每日任务卡片和任务中心入口,不再把每日登录任务描述为“每日免费泥点”。任务配置、进度、领取记录和后台管理能力暂时保留,除非后续需求明确删除。
|
||||
5. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。
|
||||
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。进入预扣或外部生成队列前,还必须调用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选 `projectId`,并校验归一化后的可选 `assetFolderId`。请求 JSON、纯本地格式、引用数量及 `data:` / `blob:` 稳定媒体门禁必须先于该数据库预检返回 4xx;目标预检随后执行,并且仍必须早于定价读取、引用 owner 解析、generation input rebuild、入队、扣费、provider 请求或 OSS 写入,不得为调整错误优先级把任何远端读取或副作用搬到预检前。`project`、任意旧 `folder-*` 与 owner 默认目录 ID 都统一指向当前 owner 的默认素材目录;角色图片、角色动作、图标 spritesheet 与 UI 提取在请求省略目录时也必须按最终真实写入的默认目录预检。尚未创建的默认目录允许通过,自定义目录必须已存在且归属当前 owner。预检 helper 必须返回同一份 canonical `projectId + assetFolderId`,调用方在入队、worker/provider 执行和原子结果准备中都复用这份值;禁止校验 trim/默认映射后的值却继续序列化或持久化原始请求。worker / inline 执行在首个 provider 或 OSS 写副作用前再次执行同一预检,不能只信任入队时结果;任一读取不可达、超时、项目或目录不匹配都失败关闭。该预检不创建锁或 reservation,最终资源 / 素材 procedure 仍必须重新校验归属,以处理预检后并发删除或转移。
|
||||
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
||||
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
|
||||
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 对生成目标执行上一条只读 owner / project / asset folder 预检,但参考媒体的登记状态和归属仍由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通参考字符串可以入队,但必须在签名和 provider 调用前失败;若以后要求无效参考引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
|
||||
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
|
||||
11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
|
||||
12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。
|
||||
|
||||
@@ -83,7 +83,7 @@ use crate::{
|
||||
EditorGenerationOperationContext, PreparedEditorGenerationResultItem,
|
||||
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
persist_editor_generation_result_atomically,
|
||||
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
|
||||
remove_editor_generated_screen_background_with_bgfilter,
|
||||
resolve_editor_reference_object_key_for_owner, sanitize_editor_client_generation_inputs,
|
||||
serialize_editor_generation_inputs, serialize_editor_image_sequence_frames,
|
||||
@@ -643,7 +643,7 @@ pub(crate) async fn enqueue_editor_character_animation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: EditorCharacterAnimationGenerateRequest,
|
||||
mut payload: EditorCharacterAnimationGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
if matches_inline_media_source(payload.source_image_src.as_str()) {
|
||||
@@ -654,6 +654,19 @@ pub(crate) async fn enqueue_editor_character_animation_for_owner(
|
||||
),
|
||||
));
|
||||
}
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
owner_user_id,
|
||||
payload.project_id.clone(),
|
||||
payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some("project".to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_animation_error_response(request_context, error))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
request_context,
|
||||
@@ -695,7 +708,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
let Json(mut payload) = payload.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -705,15 +718,9 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
)
|
||||
})?;
|
||||
let owner_user_id = caller.owner_user_id.clone();
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let source_resource_id = payload.source_resource_id.clone();
|
||||
let asset_folder_id = payload.asset_folder_id.clone();
|
||||
let asset_label =
|
||||
resolve_editor_character_animation_asset_label(payload.asset_label.as_deref());
|
||||
if canvas_completion.is_some()
|
||||
&& project_id
|
||||
if payload.canvas_completion.is_some()
|
||||
&& payload
|
||||
.project_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -732,6 +739,26 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
|
||||
),
|
||||
));
|
||||
}
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some("project".to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let source_resource_id = payload.source_resource_id.clone();
|
||||
let asset_folder_id = payload.asset_folder_id.clone();
|
||||
let asset_label =
|
||||
resolve_editor_character_animation_asset_label(payload.asset_label.as_deref());
|
||||
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
@@ -1079,9 +1106,21 @@ pub(crate) async fn enqueue_editor_video_generation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: EditorVideoGenerateRequest,
|
||||
mut payload: EditorVideoGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
ensure_editor_video_reference_sources_are_stable(&payload)
|
||||
.map_err(|error| editor_video_error_response(request_context, error))?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
owner_user_id,
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| editor_video_error_response(request_context, error))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
editor_video_error_response(
|
||||
request_context,
|
||||
@@ -1116,7 +1155,7 @@ pub(crate) async fn generate_editor_video_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
let Json(mut payload) = payload.map_err(|error| {
|
||||
editor_video_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -1126,6 +1165,18 @@ pub(crate) async fn generate_editor_video_for_owner(
|
||||
)
|
||||
})?;
|
||||
let owner_user_id = caller.owner_user_id.clone();
|
||||
ensure_editor_video_reference_sources_are_stable(&payload)
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| editor_video_error_response(&request_context, error))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
@@ -4434,6 +4485,26 @@ fn matches_inline_media_source(value: &str) -> bool {
|
||||
value.starts_with("data:") || value.starts_with("blob:")
|
||||
}
|
||||
|
||||
fn ensure_editor_video_reference_sources_are_stable(
|
||||
payload: &EditorVideoGenerateRequest,
|
||||
) -> Result<(), AppError> {
|
||||
for (label, sources) in [
|
||||
("参考图片", payload.reference_image_srcs.as_slice()),
|
||||
("参考视频", payload.reference_video_srcs.as_slice()),
|
||||
("参考音频", payload.reference_audio_srcs.as_slice()),
|
||||
] {
|
||||
if sources
|
||||
.iter()
|
||||
.any(|source| matches_inline_media_source(source.as_str()))
|
||||
{
|
||||
return Err(editor_video_bad_request(format!(
|
||||
"{label}必须先上传 OSS,并使用稳定媒体引用。"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_editor_video_reference_src(
|
||||
value: &str,
|
||||
label: &str,
|
||||
@@ -7101,6 +7172,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_and_video_targets_preflight_before_charge_or_queue() {
|
||||
let source = include_str!("character_animation_assets.rs");
|
||||
for (start, end, local_validation, terminal) in [
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_character_animation_for_owner",
|
||||
"pub(crate) async fn generate_editor_character_animation_for_owner",
|
||||
"matches_inline_media_source",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_character_animation_for_owner",
|
||||
"pub async fn generate_editor_video",
|
||||
"matches_inline_media_source",
|
||||
"execute_billable_asset_operation_with_cost",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_video_generation_for_owner",
|
||||
"pub(crate) async fn generate_editor_video_for_owner",
|
||||
"ensure_editor_video_reference_sources_are_stable",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_video_for_owner",
|
||||
"pub async fn get_character_animation_job",
|
||||
"ensure_editor_video_reference_sources_are_stable",
|
||||
"execute_billable_asset_operation_with_cost",
|
||||
),
|
||||
] {
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
start,
|
||||
end,
|
||||
&[
|
||||
local_validation,
|
||||
"preflight_editor_billable_generation_target",
|
||||
"payload.project_id = target.project_id",
|
||||
"payload.asset_folder_id = target.asset_folder_id",
|
||||
".editor_generation_pricing()",
|
||||
terminal,
|
||||
],
|
||||
);
|
||||
}
|
||||
for (start, end) in [
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_character_animation_for_owner",
|
||||
"pub(crate) async fn generate_editor_character_animation_for_owner",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_character_animation_for_owner",
|
||||
"pub async fn generate_editor_video",
|
||||
),
|
||||
] {
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
start,
|
||||
end,
|
||||
&[
|
||||
"preflight_editor_billable_generation_target",
|
||||
".or_else(|| Some(\"project\".to_string()))",
|
||||
"payload.asset_folder_id = target.asset_folder_id",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_rejects_inline_media_before_queueing() {
|
||||
let source = include_str!("character_animation_assets.rs");
|
||||
|
||||
@@ -44,10 +44,10 @@ use shared_contracts::editor_generation::{
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::editor_project::{
|
||||
EditorGenerationResultPersistItemRecordInput, EditorGenerationResultPersistRecord,
|
||||
EditorGenerationResultPersistRecordInput, EditorPixelArtCanvasCompletionRecordInput,
|
||||
EditorPixelArtCanvasPlaceholderRecordInput, EditorPixelArtResultPersistRecordInput,
|
||||
EditorPixelArtResultPreflightRecordInput, EditorSpritesheetSliceBatchPersistRecordInput,
|
||||
EditorSpritesheetSlicePersistItemRecordInput,
|
||||
EditorGenerationResultPersistRecordInput, EditorGenerationTargetPreflightRecordInput,
|
||||
EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput,
|
||||
EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput,
|
||||
EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput,
|
||||
};
|
||||
use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
@@ -2513,13 +2513,13 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
|
||||
) -> Result<ExternalGenerationJobRecord, AppError> {
|
||||
payload.generation_inputs =
|
||||
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
ensure_editor_reference_image_sources_are_stable(
|
||||
payload.reference_image_srcs.as_deref(),
|
||||
"editor-image-generation",
|
||||
"referenceImageSrcs",
|
||||
"生成参考图",
|
||||
)?;
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
|
||||
let is_publication_material_generation =
|
||||
matches!(normalized_kind, Some("publication-material"));
|
||||
@@ -2545,6 +2545,18 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"生成参考图",
|
||||
)?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone().or_else(|| {
|
||||
matches!(normalized_kind, Some("character"))
|
||||
.then(|| EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let price_mud_points = u64::from(
|
||||
state
|
||||
.editor_generation_pricing()
|
||||
@@ -2587,6 +2599,7 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
payload.generation_inputs =
|
||||
sanitize_editor_client_generation_inputs(payload.generation_inputs.take());
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
ensure_editor_reference_image_sources_are_stable(
|
||||
payload.reference_image_srcs.as_deref(),
|
||||
"editor-image-generation",
|
||||
@@ -2603,7 +2616,6 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
);
|
||||
}
|
||||
|
||||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||||
let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character"));
|
||||
let (image_style, mut generation_warning) =
|
||||
@@ -2651,6 +2663,18 @@ pub(crate) async fn generate_editor_image_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"生成参考图",
|
||||
)?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone().or_else(|| {
|
||||
matches!(normalized_kind, Some("character"))
|
||||
.then(|| EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
payload.generation_inputs = rebuild_editor_generation_input_references(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -5015,7 +5039,6 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"修改参考图",
|
||||
)?;
|
||||
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
|
||||
let generation_options = normalize_editor_image_edit_generation_options(
|
||||
payload.model.as_deref(),
|
||||
payload.aspect_ratio.as_deref(),
|
||||
@@ -5031,6 +5054,16 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"修改参考图",
|
||||
)?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
|
||||
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||||
let price_mud_points = u64::from(
|
||||
resolve_editor_image_edit_price(
|
||||
@@ -5077,7 +5110,6 @@ pub(crate) async fn edit_editor_image_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"修改参考图",
|
||||
)?;
|
||||
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
|
||||
let prompt = payload.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(
|
||||
@@ -5102,6 +5134,16 @@ pub(crate) async fn edit_editor_image_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"修改参考图",
|
||||
)?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
|
||||
payload.generation_inputs = rebuild_editor_generation_input_references(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -7492,6 +7534,18 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"图标素材参考图",
|
||||
)?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let price_mud_points = u64::from(
|
||||
resolve_editor_icon_spritesheet_price(
|
||||
state,
|
||||
@@ -7552,6 +7606,22 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"图标素材参考图",
|
||||
)?;
|
||||
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions.clone())?;
|
||||
let (image_style, mut generation_warning) =
|
||||
normalize_editor_image_generation_style(payload.style.as_deref(), true);
|
||||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
payload.generation_inputs = rebuild_editor_generation_input_references(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -7563,15 +7633,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
|
||||
let user_prompt = icon_descriptions.join("\n");
|
||||
let (image_style, mut generation_warning) =
|
||||
normalize_editor_image_generation_style(payload.style.as_deref(), true);
|
||||
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
|
||||
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
|
||||
let requested_screen_color = payload.screen_color.clone();
|
||||
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
|
||||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||||
let mut reference_images =
|
||||
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
|
||||
reference_images.push(
|
||||
@@ -8802,6 +8868,18 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"UI素材参考图",
|
||||
)?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let price_mud_points = u64::from(
|
||||
resolve_editor_ui_design_asset_extraction_price(
|
||||
state,
|
||||
@@ -8862,6 +8940,19 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
"referenceImageSrcs",
|
||||
"UI素材参考图",
|
||||
)?;
|
||||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload
|
||||
.asset_folder_id
|
||||
.clone()
|
||||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||||
)
|
||||
.await?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
payload.generation_inputs = rebuild_editor_generation_input_references(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -8881,7 +8972,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
"从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。",
|
||||
payload.aspect_ratio, payload.image_size
|
||||
);
|
||||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||||
let reference_image = parse_editor_reference_image(
|
||||
state,
|
||||
caller.owner_user_id.as_str(),
|
||||
@@ -11772,6 +11862,49 @@ fn normalize_generated_asset_folder_id(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct EditorBillableGenerationTarget {
|
||||
pub(crate) project_id: Option<String>,
|
||||
pub(crate) asset_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_editor_billable_generation_target(
|
||||
owner_user_id: &str,
|
||||
project_id: Option<String>,
|
||||
asset_folder_id: Option<String>,
|
||||
) -> EditorBillableGenerationTarget {
|
||||
let project_id = normalize_optional_string(project_id);
|
||||
let asset_folder_id = normalize_generated_asset_folder_id(asset_folder_id, owner_user_id);
|
||||
EditorBillableGenerationTarget {
|
||||
project_id,
|
||||
asset_folder_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn preflight_editor_billable_generation_target(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
project_id: Option<String>,
|
||||
asset_folder_id: Option<String>,
|
||||
) -> Result<EditorBillableGenerationTarget, AppError> {
|
||||
let owner_user_id = owner_user_id.trim().to_string();
|
||||
let target = normalize_editor_billable_generation_target(
|
||||
owner_user_id.as_str(),
|
||||
project_id,
|
||||
asset_folder_id,
|
||||
);
|
||||
state
|
||||
.spacetime_client()
|
||||
.preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput {
|
||||
owner_user_id,
|
||||
project_id: target.project_id.clone(),
|
||||
asset_folder_id: target.asset_folder_id.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn default_editor_asset_folder_id(owner_user_id: &str) -> String {
|
||||
format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}")
|
||||
}
|
||||
@@ -21234,9 +21367,152 @@ mod tests {
|
||||
),
|
||||
Some("user-1:asset-folder:project".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_asset_folder_id(
|
||||
Some("editor-asset-folder-custom".to_string()),
|
||||
"user-1",
|
||||
),
|
||||
Some("editor-asset-folder-custom".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_asset_folder_id(Some(" ".to_string()), "user-1"),
|
||||
None
|
||||
);
|
||||
assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_generation_target_canonicalizes_project_and_folder_together() {
|
||||
assert_eq!(
|
||||
normalize_editor_billable_generation_target(
|
||||
"user-1",
|
||||
Some(" project-1 ".to_string()),
|
||||
Some(" folder-legacy ".to_string()),
|
||||
),
|
||||
EditorBillableGenerationTarget {
|
||||
project_id: Some("project-1".to_string()),
|
||||
asset_folder_id: Some("user-1:asset-folder:project".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_editor_billable_generation_target("user-1", Some(" ".to_string()), None,),
|
||||
EditorBillableGenerationTarget {
|
||||
project_id: None,
|
||||
asset_folder_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_editor_generation_targets_are_preflighted_before_charge_or_queue() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
for (start, end, local_validation, first_remote_step, terminal) in [
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_image_generation_for_owner",
|
||||
"pub(crate) async fn generate_editor_image_for_owner",
|
||||
"ensure_editor_reference_image_sources_are_stable",
|
||||
".editor_generation_pricing()",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_image_for_owner",
|
||||
"fn editor_image_generation_billing_asset_kind",
|
||||
"ensure_editor_reference_image_sources_are_stable",
|
||||
"rebuild_editor_generation_input_references",
|
||||
"execute_billable_asset_operation_with_cost",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_image_edit_for_owner",
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
"ensure_editor_reference_image_source_is_stable",
|
||||
"ensure_editor_image_edit_source_allowed",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
"pub async fn remove_editor_image_background",
|
||||
"ensure_editor_reference_image_source_is_stable",
|
||||
"ensure_editor_image_edit_source_allowed",
|
||||
"execute_billable_asset_operation_with_cost",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner",
|
||||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||||
"ensure_editor_reference_image_source_is_stable",
|
||||
"resolve_editor_icon_spritesheet_price",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||||
"fn resolve_editor_icon_spritesheet_source_layer_asset_kind",
|
||||
"ensure_editor_reference_image_source_is_stable",
|
||||
"rebuild_editor_generation_input_references",
|
||||
"execute_billable_asset_operation_with_cost",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner",
|
||||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||||
"ensure_editor_reference_image_source_is_stable",
|
||||
"resolve_editor_ui_design_asset_extraction_price",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||||
"async fn repair_editor_project_record_inline_media",
|
||||
"ensure_editor_reference_image_source_is_stable",
|
||||
"rebuild_editor_generation_input_references",
|
||||
"execute_billable_asset_operation_with_cost",
|
||||
),
|
||||
] {
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
start,
|
||||
end,
|
||||
&[
|
||||
local_validation,
|
||||
"preflight_editor_billable_generation_target",
|
||||
"payload.project_id = target.project_id",
|
||||
"payload.asset_folder_id = target.asset_folder_id",
|
||||
first_remote_step,
|
||||
terminal,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
"pub(crate) async fn preflight_editor_billable_generation_target",
|
||||
"fn default_editor_asset_folder_id",
|
||||
&[
|
||||
"normalize_editor_billable_generation_target",
|
||||
".preflight_editor_generation_target(",
|
||||
"Ok(target)",
|
||||
],
|
||||
);
|
||||
for (start, end) in [
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_image_generation_for_owner",
|
||||
"pub(crate) async fn generate_editor_image_for_owner",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_image_for_owner",
|
||||
"fn editor_image_generation_billing_asset_kind",
|
||||
),
|
||||
] {
|
||||
assert_function_contains_in_order(
|
||||
source,
|
||||
start,
|
||||
end,
|
||||
&[
|
||||
"preflight_editor_billable_generation_target",
|
||||
"matches!(normalized_kind, Some(\"character\"))",
|
||||
"EDITOR_ASSET_DEFAULT_FOLDER_ID",
|
||||
"payload.asset_folder_id = target.asset_folder_id",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
|
||||
let start_index = source
|
||||
.find(start)
|
||||
|
||||
@@ -33,8 +33,8 @@ use crate::{
|
||||
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
normalize_optional_string, persist_editor_generation_result_atomically,
|
||||
prepare_editor_generated_asset, sanitize_editor_client_generation_inputs,
|
||||
with_editor_media_duration_generation_input,
|
||||
preflight_editor_billable_generation_target, prepare_editor_generated_asset,
|
||||
sanitize_editor_client_generation_inputs, with_editor_media_duration_generation_input,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
@@ -240,9 +240,19 @@ pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
payload: assets::EditorSoundEffectGenerateRequest,
|
||||
mut payload: assets::EditorSoundEffectGenerateRequest,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
owner_user_id,
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_details(json!({
|
||||
@@ -276,8 +286,18 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let Json(mut payload) = parse_json_payload(&request_context, payload)?;
|
||||
let owner_user_id = caller.owner_user_id.clone();
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
@@ -443,9 +463,19 @@ async fn enqueue_prepared_editor_background_music_generation_for_owner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
prepared: PreparedEditorBackgroundMusicQueueJob,
|
||||
mut prepared: PreparedEditorBackgroundMusicQueueJob,
|
||||
external_idempotency_key: Option<&str>,
|
||||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
state,
|
||||
owner_user_id,
|
||||
prepared.payload.project_id.clone(),
|
||||
prepared.payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(request_context)))?;
|
||||
prepared.payload.project_id = target.project_id;
|
||||
prepared.payload.asset_folder_id = target.asset_folder_id;
|
||||
let source_entity_id = editor_generation_source_entity_id(
|
||||
prepared.payload.project_id.as_deref(),
|
||||
"editor-background-music",
|
||||
@@ -540,8 +570,18 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
caller: EditorGenerationCaller,
|
||||
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let Json(mut payload) = parse_json_payload(&request_context, payload)?;
|
||||
let owner_user_id = caller.owner_user_id.clone();
|
||||
let target = preflight_editor_billable_generation_target(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.clone(),
|
||||
payload.asset_folder_id.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
payload.project_id = target.project_id;
|
||||
payload.asset_folder_id = target.asset_folder_id;
|
||||
let project_id = payload.project_id.clone();
|
||||
let canvas_completion = payload.canvas_completion.clone();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
@@ -1251,6 +1291,64 @@ mod tests {
|
||||
assert!(body.contains("\"actualPrompt\": input.prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_audio_targets_preflight_before_provider_or_queue() {
|
||||
let source = include_str!("generation.rs");
|
||||
for (start, end, payload_prefix, terminal) in [
|
||||
(
|
||||
"pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner",
|
||||
"pub(crate) async fn generate_editor_sound_effect_for_owner",
|
||||
"payload",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_sound_effect_for_owner",
|
||||
"fn build_editor_sound_effect_generate_response",
|
||||
"payload",
|
||||
"platform_audio::submit_editor_sound_effect_task",
|
||||
),
|
||||
(
|
||||
"async fn enqueue_prepared_editor_background_music_generation_for_owner",
|
||||
"async fn enqueue_logged_in_editor_background_music_generation_for_owner",
|
||||
"prepared.payload",
|
||||
"enqueue_editor_generation_job_for_caller",
|
||||
),
|
||||
(
|
||||
"pub(crate) async fn generate_editor_background_music_for_owner",
|
||||
"fn build_editor_audio_target",
|
||||
"payload",
|
||||
"platform_audio::submit_editor_background_music_task",
|
||||
),
|
||||
] {
|
||||
let start_index = source
|
||||
.find(start)
|
||||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||||
let body = &source[start_index..];
|
||||
let end_index = body
|
||||
.find(end)
|
||||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||||
let body = &body[..end_index];
|
||||
let preflight = body
|
||||
.find("preflight_editor_billable_generation_target")
|
||||
.unwrap_or_else(|| panic!("{start} must preflight the generation target"));
|
||||
let project_assignment = body
|
||||
.find(format!("{payload_prefix}.project_id = target.project_id").as_str())
|
||||
.unwrap_or_else(|| panic!("{start} must reuse canonical project id"));
|
||||
let folder_assignment = body
|
||||
.find(format!("{payload_prefix}.asset_folder_id = target.asset_folder_id").as_str())
|
||||
.unwrap_or_else(|| panic!("{start} must reuse canonical asset folder id"));
|
||||
let terminal = body
|
||||
.find(terminal)
|
||||
.unwrap_or_else(|| panic!("{start} must retain {terminal}"));
|
||||
assert!(
|
||||
preflight < project_assignment
|
||||
&& project_assignment < folder_assignment
|
||||
&& folder_assignment < terminal,
|
||||
"{start} must reuse canonical target before {terminal}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_background_music_normalization_uses_canonical_generation_prompt() {
|
||||
let representative = background_music_prompt_fixture("representative-complex");
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EditorGenerationTargetPreflightRecordInput {
|
||||
pub owner_user_id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub asset_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorPixelArtCanvasPlaceholderRecordInput {
|
||||
pub x: f64,
|
||||
@@ -88,6 +95,18 @@ impl From<EditorPixelArtResultPreflightRecordInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorGenerationTargetPreflightRecordInput>
|
||||
for crate::module_bindings::EditorGenerationTargetPreflightInput
|
||||
{
|
||||
fn from(input: EditorGenerationTargetPreflightRecordInput) -> Self {
|
||||
Self {
|
||||
owner_user_id: input.owner_user_id,
|
||||
project_id: input.project_id,
|
||||
asset_folder_id: input.asset_folder_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorPixelArtResultPersistRecordInput>
|
||||
for crate::module_bindings::EditorPixelArtResultPersistInput
|
||||
{
|
||||
@@ -238,6 +257,31 @@ impl From<EditorGenerationResultPersistRecordInput>
|
||||
}
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn preflight_editor_generation_target(
|
||||
&self,
|
||||
input: EditorGenerationTargetPreflightRecordInput,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"preflight_editor_generation_target_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.preflight_editor_generation_target_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_editor_generation_target_preflight_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn preflight_editor_pixel_art_result(
|
||||
&self,
|
||||
input: EditorPixelArtResultPreflightRecordInput,
|
||||
@@ -1250,6 +1294,15 @@ impl SpacetimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_editor_generation_target_preflight_result(
|
||||
result: crate::module_bindings::EditorGenerationTargetPreflightResult,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
if result.ok {
|
||||
return Ok(());
|
||||
}
|
||||
Err(SpacetimeClientError::procedure_failed(result.error_message))
|
||||
}
|
||||
|
||||
fn map_editor_pixel_art_result_preflight_result(
|
||||
result: crate::module_bindings::EditorPixelArtResultPreflightResult,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
|
||||
@@ -306,6 +306,8 @@ pub mod editor_generation_result_persisted_item_type;
|
||||
pub mod editor_generation_runtime_identity_rotate_input_type;
|
||||
pub mod editor_generation_runtime_identity_rotation_table;
|
||||
pub mod editor_generation_runtime_identity_rotation_type;
|
||||
pub mod editor_generation_target_preflight_input_type;
|
||||
pub mod editor_generation_target_preflight_result_type;
|
||||
pub mod editor_pixel_art_canvas_completion_input_type;
|
||||
pub mod editor_pixel_art_canvas_placeholder_input_type;
|
||||
pub mod editor_pixel_art_result_persist_input_type;
|
||||
@@ -498,6 +500,7 @@ pub mod persist_editor_spritesheet_slice_batch_and_return_procedure;
|
||||
pub mod player_progression_grant_source_type;
|
||||
pub mod player_progression_table;
|
||||
pub mod player_progression_type;
|
||||
pub mod preflight_editor_generation_target_and_return_procedure;
|
||||
pub mod preflight_editor_pixel_art_result_and_return_procedure;
|
||||
pub mod prepare_profile_recharge_refund_hold_and_return_procedure;
|
||||
pub mod preview_profile_recharge_refund_hold_and_return_procedure;
|
||||
@@ -1149,6 +1152,8 @@ pub use editor_generation_result_persisted_item_type::EditorGenerationResultPers
|
||||
pub use editor_generation_runtime_identity_rotate_input_type::EditorGenerationRuntimeIdentityRotateInput;
|
||||
pub use editor_generation_runtime_identity_rotation_table::*;
|
||||
pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation;
|
||||
pub use editor_generation_target_preflight_input_type::EditorGenerationTargetPreflightInput;
|
||||
pub use editor_generation_target_preflight_result_type::EditorGenerationTargetPreflightResult;
|
||||
pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput;
|
||||
pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput;
|
||||
pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput;
|
||||
@@ -1341,6 +1346,7 @@ pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_edi
|
||||
pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
|
||||
pub use player_progression_table::*;
|
||||
pub use player_progression_type::PlayerProgression;
|
||||
pub use preflight_editor_generation_target_and_return_procedure::preflight_editor_generation_target_and_return;
|
||||
pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return;
|
||||
pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return;
|
||||
pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return;
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorGenerationTargetPreflightInput {
|
||||
pub owner_user_id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub asset_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorGenerationTargetPreflightInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct EditorGenerationTargetPreflightResult {
|
||||
pub ok: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorGenerationTargetPreflightResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::editor_generation_target_preflight_input_type::EditorGenerationTargetPreflightInput;
|
||||
use super::editor_generation_target_preflight_result_type::EditorGenerationTargetPreflightResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct PreflightEditorGenerationTargetAndReturnArgs {
|
||||
pub input: EditorGenerationTargetPreflightInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PreflightEditorGenerationTargetAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `preflight_editor_generation_target_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait preflight_editor_generation_target_and_return {
|
||||
fn preflight_editor_generation_target_and_return(
|
||||
&self,
|
||||
input: EditorGenerationTargetPreflightInput,
|
||||
) {
|
||||
self.preflight_editor_generation_target_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn preflight_editor_generation_target_and_return_then(
|
||||
&self,
|
||||
input: EditorGenerationTargetPreflightInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorGenerationTargetPreflightResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl preflight_editor_generation_target_and_return for super::RemoteProcedures {
|
||||
fn preflight_editor_generation_target_and_return_then(
|
||||
&self,
|
||||
input: EditorGenerationTargetPreflightInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorGenerationTargetPreflightResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, EditorGenerationTargetPreflightResult>(
|
||||
"preflight_editor_generation_target_and_return",
|
||||
PreflightEditorGenerationTargetAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1036,6 +1036,19 @@ pub struct EditorPixelArtResultPreflightResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct EditorGenerationTargetPreflightInput {
|
||||
pub owner_user_id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub asset_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct EditorGenerationTargetPreflightResult {
|
||||
pub ok: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||
pub struct EditorPixelArtResultPersistInput {
|
||||
pub owner_user_id: String,
|
||||
@@ -1798,6 +1811,24 @@ pub fn preflight_editor_pixel_art_result_and_return(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn preflight_editor_generation_target_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: EditorGenerationTargetPreflightInput,
|
||||
) -> EditorGenerationTargetPreflightResult {
|
||||
let caller = ctx.sender();
|
||||
match ctx.try_with_tx(|tx| preflight_editor_generation_target(tx, caller, input.clone())) {
|
||||
Ok(()) => EditorGenerationTargetPreflightResult {
|
||||
ok: true,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => EditorGenerationTargetPreflightResult {
|
||||
ok: false,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn persist_editor_pixel_art_result_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -2912,7 +2943,7 @@ fn preflight_editor_pixel_art_result(
|
||||
let project_id = normalize_required(&input.project_id, "project_id")?;
|
||||
let asset_folder_id = normalize_required(&input.asset_folder_id, "asset_folder_id")?;
|
||||
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
|
||||
validate_editor_pixel_art_preflight_asset_folder(
|
||||
validate_editor_generation_preflight_asset_folder(
|
||||
ctx,
|
||||
asset_folder_id.as_str(),
|
||||
owner_user_id.as_str(),
|
||||
@@ -2958,7 +2989,27 @@ fn preflight_editor_pixel_art_result(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_editor_pixel_art_preflight_asset_folder(
|
||||
fn preflight_editor_generation_target(
|
||||
ctx: &ReducerContext,
|
||||
caller: Identity,
|
||||
input: EditorGenerationTargetPreflightInput,
|
||||
) -> Result<(), String> {
|
||||
require_editor_generation_runtime_service_identity(ctx, caller)?;
|
||||
let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?;
|
||||
if let Some(project_id) = normalize_optional(input.project_id) {
|
||||
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
|
||||
}
|
||||
if let Some(asset_folder_id) = normalize_optional(input.asset_folder_id) {
|
||||
validate_editor_generation_preflight_asset_folder(
|
||||
ctx,
|
||||
asset_folder_id.as_str(),
|
||||
owner_user_id.as_str(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_editor_generation_preflight_asset_folder(
|
||||
ctx: &ReducerContext,
|
||||
folder_id: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -15051,13 +15102,13 @@ mod tests {
|
||||
.find("fn preflight_editor_pixel_art_result(\n")
|
||||
.expect("preflight implementation");
|
||||
let preflight_end = source[preflight_start..]
|
||||
.find("fn validate_editor_pixel_art_preflight_asset_folder(")
|
||||
.find("fn preflight_editor_generation_target(")
|
||||
.map(|offset| preflight_start + offset)
|
||||
.expect("preflight folder validation boundary");
|
||||
let preflight = &source[preflight_start..preflight_end];
|
||||
for required in [
|
||||
"require_editor_generation_runtime_service_identity",
|
||||
"validate_editor_pixel_art_preflight_asset_folder",
|
||||
"validate_editor_generation_preflight_asset_folder",
|
||||
"prepare_editor_project_resource",
|
||||
"plan_editor_pixel_art_canvas_completion",
|
||||
"validate_editor_pixel_art_planned_canvas_layout",
|
||||
@@ -15096,6 +15147,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_generation_target_preflight_is_read_only_and_checks_owned_targets() {
|
||||
let source = include_str!("editor_project_storage.rs");
|
||||
let preflight_start = source
|
||||
.find("fn preflight_editor_generation_target(\n")
|
||||
.expect("generation target preflight implementation");
|
||||
let preflight_end = source[preflight_start..]
|
||||
.find("fn validate_editor_generation_preflight_asset_folder(")
|
||||
.map(|offset| preflight_start + offset)
|
||||
.expect("generation target folder validation boundary");
|
||||
let preflight = &source[preflight_start..preflight_end];
|
||||
for required in [
|
||||
"require_editor_generation_runtime_service_identity",
|
||||
"require_owned_project",
|
||||
"validate_editor_generation_preflight_asset_folder",
|
||||
] {
|
||||
assert!(
|
||||
preflight.contains(required),
|
||||
"generation target preflight must retain {required}"
|
||||
);
|
||||
}
|
||||
for forbidden in [".insert(", ".update(", ".delete("] {
|
||||
assert!(
|
||||
!preflight.contains(forbidden),
|
||||
"generation target preflight must remain read-only: {forbidden}"
|
||||
);
|
||||
}
|
||||
|
||||
let folder_validator_start = source
|
||||
.find("fn validate_editor_generation_preflight_asset_folder(\n")
|
||||
.expect("generation target folder validator");
|
||||
let folder_validator_end = source[folder_validator_start..]
|
||||
.find("fn persist_editor_pixel_art_result(")
|
||||
.map(|offset| folder_validator_start + offset)
|
||||
.expect("generation target folder validator boundary");
|
||||
let folder_validator = &source[folder_validator_start..folder_validator_end];
|
||||
for required in [
|
||||
"EDITOR_ASSET_DEFAULT_FOLDER_ID",
|
||||
"default_asset_folder_id",
|
||||
"require_owned_asset_folder",
|
||||
] {
|
||||
assert!(
|
||||
folder_validator.contains(required),
|
||||
"generation target folder validator must retain {required}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_art_canvas_completion_applies_once_and_replays_without_mutation() {
|
||||
let layout = json!([
|
||||
|
||||
Reference in New Issue
Block a user