diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b40f41334..f3df70daa 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1718,6 +1718,28 @@ fn build_editor_scene_generation_inputs( json!({ "fields": fields, "references": references }) } +fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(default) +} + +fn normalize_editor_scene_generation_options( + model: Option<&str>, + aspect_ratio: Option<&str>, + image_size: Option<&str>, +) -> EditorGenerationOptions { + normalize_editor_generation_options( + Some(normalize_editor_scene_optional_text( + model, + EDITOR_IMAGE_MODEL_NANOBANANA2, + )), + Some(normalize_editor_scene_optional_text(aspect_ratio, "16:9")), + Some(normalize_editor_scene_optional_text(image_size, "1K")), + ) +} + pub async fn generate_editor_scene( State(state): State, Extension(request_context): Extension, @@ -1725,13 +1747,10 @@ pub async fn generate_editor_scene( payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; - let generation_options = normalize_editor_generation_options( - payload - .model - .as_deref() - .or(Some(EDITOR_IMAGE_MODEL_NANOBANANA2)), - payload.aspect_ratio.as_deref().or(Some("16:9")), - payload.image_size.as_deref().or(Some("1K")), + let generation_options = normalize_editor_scene_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), ); let prompt = build_editor_scene_prompt( payload.scene_content.as_str(), @@ -18286,6 +18305,32 @@ mod tests { assert_eq!(profile.slot, "scene"); } + #[test] + fn scene_generation_blank_options_match_omitted_defaults() { + let omitted = normalize_editor_scene_generation_options(None, None, None); + let blank = + normalize_editor_scene_generation_options(Some(" "), Some(" \t "), Some("\n ")); + let explicit = normalize_editor_scene_generation_options( + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some("16:9"), + Some("1K"), + ); + + assert_eq!(omitted, explicit); + assert_eq!(blank, explicit); + assert_eq!(explicit.model, EDITOR_IMAGE_MODEL_NANOBANANA2); + assert_eq!(explicit.aspect_ratio, "16:9"); + assert_eq!(explicit.image_size, "1K"); + assert_eq!( + crate::editor_generation_config::editor_image_generation_mud_points( + Some("scene"), + Some(explicit.model), + Some(explicit.image_size), + ), + 12, + ); + } + #[test] fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() { let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({ diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index baf272f14..a2725baff 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -22,10 +22,9 @@ use crate::{ generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing, get_editor_project, list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, - save_editor_project_layout, snap_editor_image_to_pixel_art, - split_editor_icon_spritesheet, submit_editor_asset_showcase, - toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, - update_editor_project_resource_showcase, + save_editor_project_layout, snap_editor_image_to_pixel_art, split_editor_icon_spritesheet, + submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, + update_editor_asset_folder, update_editor_project_resource_showcase, }, state::AppState, }; diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 25c30c134..30893ba4a 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -32,8 +32,9 @@ const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ "assetKind", "generationInputs", ]; -const EDITOR_CANVAS_ASSET_KINDS: [&str; 11] = [ +const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [ "spec", + "scene", "character", "character-animation", "icon", @@ -8828,14 +8829,13 @@ fn validate_editor_pixel_art_planned_canvas_layout( .filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE) .ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?; let current_structured_json = build_structured_canvas_layout_json(ctx, canvas)?; - let current_structured_hash = canonical_layout_sha256(current_structured_json.as_str())?; - let current_structured_integrity = - canvas_layout_integrity(current_structured_json.as_str())?; - verify_migration_layout( + let current_resource_asset_kinds = + project_resource_asset_kinds(ctx, project_id, owner_user_id); + verify_active_structured_canvas_migration( &active_migration, canvas.revision, - current_structured_hash.as_str(), - ¤t_structured_integrity, + current_structured_json.as_str(), + ¤t_resource_asset_kinds, )?; } normalize_layout_json(layers_json).and_then(|layers_json| { @@ -8850,6 +8850,18 @@ fn validate_editor_pixel_art_planned_canvas_layout( }) } +fn verify_active_structured_canvas_migration( + migration: &EditorCanvasLayoutMigration, + revision: u64, + structured_json: &str, + resource_asset_kinds: &BTreeMap>, +) -> Result<(), String> { + let structured_hash = + canonical_layout_sha256_with_resource_asset_kinds(structured_json, resource_asset_kinds)?; + let integrity = canvas_layout_integrity(structured_json)?; + verify_migration_layout(migration, revision, structured_hash.as_str(), &integrity) +} + fn backfill_editor_canvas_layout( ctx: &ReducerContext, input: EditorCanvasLayoutMigrationInput, @@ -10000,6 +10012,48 @@ mod tests { )); } + #[test] + fn pixel_art_canvas_completion_accepts_scene_asset_kind() { + let layout = json!([{ + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "placeholder": { + "x": 100, + "y": 50, + "width": 200, + "height": 100, + "originalWidth": 200, + "originalHeight": 100 + } + } + }]); + let mut resource = pixel_art_project_resource(); + resource.asset_kind = Some("scene".to_string()); + + let applied = match plan_editor_pixel_art_canvas_layout( + layout.to_string().as_str(), + &pixel_art_canvas_completion(), + &resource, + ) + .expect("scene pixel-art completion should apply") + { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => layers_json, + _ => panic!("scene completion must apply"), + }; + let parsed = parse_structured_canvas_layout(applied.as_str()) + .expect("scene result layer should be accepted by structured layout parsing"); + let extension: JsonValue = serde_json::from_str(&parsed.layers[0].item_json) + .expect("scene result layer extension"); + + assert_eq!(parsed.layers.len(), 1); + assert_eq!(extension["assetKind"], json!("scene")); + } + #[test] fn pixel_art_canvas_completion_keeps_deleted_dialog_absent() { let layout = json!([{ @@ -11578,6 +11632,77 @@ mod tests { ); } + #[test] + fn structured_canvas_scene_resource_kind_survives_canonical_normalization() { + let layout = r#"[{"layerId":"layer-1","resourceId":"resource-scene"}]"#; + let resource_asset_kinds = + BTreeMap::from([("resource-scene".to_string(), Some("scene".to_string()))]); + + let canonical = + canonical_layout_json_with_resource_asset_kinds(layout, &resource_asset_kinds) + .expect("scene resource kind should canonicalize"); + let parsed = parse_structured_canvas_layout(canonical.as_str()) + .expect("canonical scene layout should remain structurally valid"); + let rebuilt = serialize_structured_canvas_layout(&parsed) + .expect("canonical scene layout should persist structurally"); + let rebuilt: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt scene layout"); + + assert_eq!( + parsed.layers[0].asset_kind_override.as_deref(), + Some("scene") + ); + assert_eq!(rebuilt[0]["assetKindOverride"], json!("scene")); + } + + #[test] + fn active_migration_verification_uses_scene_resource_kind() { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + let structured_json = r#"[{"layerId":"layer-1","resourceId":"resource-scene"}]"#; + let resource_asset_kinds = + BTreeMap::from([("resource-scene".to_string(), Some("scene".to_string()))]); + let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + structured_json, + &resource_asset_kinds, + ) + .expect("scene-aware migration hash"); + let integrity = canvas_layout_integrity(structured_json).expect("scene layout integrity"); + let migration = EditorCanvasLayoutMigration { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + source_layout_sha256: "source".to_string(), + structured_layout_sha256: structured_hash, + verified_revision: 7, + layer_count: integrity.layer_count, + dialog_count: integrity.dialog_count, + resource_refs_sha256: integrity.resource_refs_sha256, + migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + status: EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE.to_string(), + migrated_at: now, + activated_at: Some(now), + rolled_back_at: None, + updated_at: now, + }; + + verify_active_structured_canvas_migration( + &migration, + 7, + structured_json, + &resource_asset_kinds, + ) + .expect("scene resource kind should match the active migration hash"); + assert!( + verify_active_structured_canvas_migration( + &migration, + 7, + structured_json, + &BTreeMap::new(), + ) + .is_err(), + "dropping the scene resource kind must still be detected as drift" + ); + } + #[test] fn structured_canvas_allows_distinct_layers_to_share_one_resource() { let shared = r#"[