新增图片画布编辑器

新增 /editor 图片画布入口与 Lovart 风格画布交互

新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF

接入图片生成和修改的 VectorEngine gpt-image-2 后端通道

完善素材库文件夹、重命名、上传删除、图层和元数据交互

补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
2026-06-13 16:22:18 +08:00
parent f8a80cd795
commit 747473024d
53 changed files with 6694 additions and 29 deletions

View File

@@ -0,0 +1,500 @@
use crate::*;
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_PROJECT_MAX_TITLE_CHARS: usize = 80;
const EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES: usize = 256 * 1024;
const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"];
#[spacetimedb::table(
accessor = editor_project,
index(accessor = by_editor_project_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct EditorProject {
#[primary_key]
project_id: String,
owner_user_id: String,
title: String,
viewport_x: f64,
viewport_y: f64,
viewport_scale: f64,
layers_json: String,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = editor_project_resource,
index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])),
index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct EditorProjectResource {
#[primary_key]
resource_id: String,
project_id: String,
owner_user_id: String,
asset_object_id: Option<String>,
image_src: String,
object_key: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectViewportSnapshot {
pub x: f64,
pub y: f64,
pub scale: f64,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectCreateInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectGetInput {
pub project_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectGetRecentInput {
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectLayoutSaveInput {
pub project_id: String,
pub owner_user_id: String,
pub viewport: EditorProjectViewportSnapshot,
pub layers_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectResourceCreateInput {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectResourceSnapshot {
pub resource_id: String,
pub project_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectSnapshot {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub viewport: EditorProjectViewportSnapshot,
pub layers_json: String,
pub resources: Vec<EditorProjectResourceSnapshot>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectProcedureResult {
pub ok: bool,
pub project: Option<EditorProjectSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectResourceProcedureResult {
pub ok: bool,
pub resource: Option<EditorProjectResourceSnapshot>,
pub error_message: Option<String>,
}
#[spacetimedb::procedure]
pub fn create_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectCreateInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| create_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_recent_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectGetRecentInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| get_recent_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(project),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectGetInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| get_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn save_editor_project_layout_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectLayoutSaveInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| save_editor_project_layout(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn create_editor_project_resource_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectResourceCreateInput,
) -> EditorProjectResourceProcedureResult {
match ctx.try_with_tx(|tx| create_editor_project_resource(tx, input.clone())) {
Ok(resource) => EditorProjectResourceProcedureResult {
ok: true,
resource: Some(resource),
error_message: None,
},
Err(message) => EditorProjectResourceProcedureResult {
ok: false,
resource: None,
error_message: Some(message),
},
}
}
fn create_editor_project(
ctx: &ReducerContext,
input: EditorProjectCreateInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let title = normalize_title(&input.title);
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
if ctx
.db
.editor_project()
.project_id()
.find(&project_id)
.is_some()
{
return Err("图片画布工程已存在".to_string());
}
ctx.db.editor_project().insert(EditorProject {
project_id: project_id.clone(),
owner_user_id,
title,
viewport_x: 0.0,
viewport_y: 0.0,
viewport_scale: 1.0,
layers_json: "[]".to_string(),
created_at: now,
updated_at: now,
});
build_project_snapshot(ctx, project_id.as_str())
}
fn get_recent_editor_project(
ctx: &ReducerContext,
input: EditorProjectGetRecentInput,
) -> Result<Option<EditorProjectSnapshot>, String> {
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let mut projects = ctx
.db
.editor_project()
.by_editor_project_owner_user_id()
.filter(&owner_user_id)
.collect::<Vec<_>>();
projects.sort_by(|left, right| {
right
.updated_at
.to_micros_since_unix_epoch()
.cmp(&left.updated_at.to_micros_since_unix_epoch())
.then_with(|| right.project_id.cmp(&left.project_id))
});
projects
.first()
.map(|project| build_project_snapshot(ctx, project.project_id.as_str()))
.transpose()
}
fn get_editor_project(
ctx: &ReducerContext,
input: EditorProjectGetInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
build_project_snapshot(ctx, project.project_id.as_str())
}
fn save_editor_project_layout(
ctx: &ReducerContext,
input: EditorProjectLayoutSaveInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let layers_json = normalize_layout_json(input.layers_json)?;
let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
ctx.db.editor_project().project_id().delete(&project_id);
ctx.db.editor_project().insert(EditorProject {
project_id: project.project_id.clone(),
owner_user_id: project.owner_user_id,
title: project.title,
viewport_x: input.viewport.x,
viewport_y: input.viewport.y,
viewport_scale: input.viewport.scale.clamp(0.01, 8.0),
layers_json,
created_at: project.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros),
});
build_project_snapshot(ctx, project_id.as_str())
}
fn create_editor_project_resource(
ctx: &ReducerContext,
input: EditorProjectResourceCreateInput,
) -> Result<EditorProjectResourceSnapshot, String> {
let resource_id = normalize_required(
&input.resource_id,
"editor_project_resource.resource_id",
)?;
let project_id = normalize_required(&input.project_id, "editor_project_resource.project_id")?;
let owner_user_id = normalize_required(
&input.owner_user_id,
"editor_project_resource.owner_user_id",
)?;
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
let image_src = normalize_required(&input.image_src, "editor_project_resource.image_src")?;
let source_type = normalize_required(
&input.source_type,
"editor_project_resource.source_type",
)?;
if !EDITOR_PROJECT_SOURCE_TYPES.contains(&source_type.as_str()) {
return Err("画布资源来源类型只支持 uploaded、generated 或 mock_generated".to_string());
}
if input.width == 0 || input.height == 0 {
return Err("画布资源尺寸必须大于 0".to_string());
}
if ctx
.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.is_some()
{
return Err("画布资源已存在".to_string());
}
let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db
.editor_project_resource()
.insert(EditorProjectResource {
resource_id: resource_id.clone(),
project_id,
owner_user_id,
asset_object_id: normalize_optional(input.asset_object_id),
image_src,
object_key: normalize_optional(input.object_key),
width: input.width,
height: input.height,
source_type,
prompt: normalize_optional(input.prompt),
actual_prompt: normalize_optional(input.actual_prompt),
model: normalize_optional(input.model),
provider: normalize_optional(input.provider),
task_id: normalize_optional(input.task_id),
source_resource_id: normalize_optional(input.source_resource_id),
created_at: now,
updated_at: now,
});
ctx.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.map(resource_snapshot_from_row)
.ok_or_else(|| "画布资源创建失败".to_string())
}
fn build_project_snapshot(
ctx: &ReducerContext,
project_id: &str,
) -> Result<EditorProjectSnapshot, String> {
let project_key = project_id.to_string();
let project = ctx
.db
.editor_project()
.project_id()
.find(&project_key)
.ok_or_else(|| "图片画布工程不存在".to_string())?;
let mut resources = ctx
.db
.editor_project_resource()
.by_editor_project_resource_project_id()
.filter(&project_key)
.map(resource_snapshot_from_row)
.collect::<Vec<_>>();
resources.sort_by(|left, right| {
left.created_at_micros
.cmp(&right.created_at_micros)
.then_with(|| left.resource_id.cmp(&right.resource_id))
});
Ok(EditorProjectSnapshot {
project_id: project.project_id,
owner_user_id: project.owner_user_id,
title: project.title,
viewport: EditorProjectViewportSnapshot {
x: project.viewport_x,
y: project.viewport_y,
scale: project.viewport_scale,
},
layers_json: project.layers_json,
resources,
created_at_micros: project.created_at.to_micros_since_unix_epoch(),
updated_at_micros: project.updated_at.to_micros_since_unix_epoch(),
})
}
fn require_owned_project(
ctx: &ReducerContext,
project_id: &str,
owner_user_id: &str,
) -> Result<EditorProject, String> {
let project_key = project_id.to_string();
let project = ctx
.db
.editor_project()
.project_id()
.find(&project_key)
.ok_or_else(|| "图片画布工程不存在".to_string())?;
if project.owner_user_id != owner_user_id {
return Err("无权访问该图片画布工程".to_string());
}
Ok(project)
}
fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResourceSnapshot {
EditorProjectResourceSnapshot {
resource_id: row.resource_id,
project_id: row.project_id,
asset_object_id: row.asset_object_id,
image_src: row.image_src,
object_key: row.object_key,
width: row.width,
height: row.height,
source_type: row.source_type,
prompt: row.prompt,
actual_prompt: row.actual_prompt,
model: row.model,
provider: row.provider,
task_id: row.task_id,
source_resource_id: row.source_resource_id,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn normalize_required(value: &str, field: &str) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(format!("{field} 不能为空"));
}
Ok(normalized.to_string())
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn normalize_title(value: &str) -> String {
let title = value.trim();
if title.is_empty() {
return EDITOR_PROJECT_DEFAULT_TITLE.to_string();
}
title.chars().take(EDITOR_PROJECT_MAX_TITLE_CHARS).collect()
}
fn normalize_layout_json(value: String) -> Result<String, String> {
if value.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES {
return Err("图片画布图层布局过大".to_string());
}
serde_json::from_str::<JsonValue>(&value)
.map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?;
Ok(value)
}
fn editor_project_ok(project: Option<EditorProjectSnapshot>) -> EditorProjectProcedureResult {
EditorProjectProcedureResult {
ok: true,
project,
error_message: None,
}
}
fn editor_project_error(message: String) -> EditorProjectProcedureResult {
EditorProjectProcedureResult {
ok: false,
project: None,
error_message: Some(message),
}
}

View File

@@ -30,6 +30,7 @@ mod bark_battle;
mod big_fish;
mod custom_world;
mod domain_types;
mod editor_project_storage;
mod entry;
mod gameplay;
mod jump_hop;
@@ -50,6 +51,7 @@ pub use bark_battle::*;
pub use big_fish::*;
pub use custom_world::*;
pub use domain_types::*;
pub use editor_project_storage::*;
pub use entry::*;
pub use gameplay::*;
pub use jump_hop::*;

View File

@@ -228,6 +228,8 @@ macro_rules! migration_tables {
asset_object,
asset_entity_binding,
asset_event,
editor_project,
editor_project_resource,
puzzle_agent_session,
puzzle_background_compile_task,
puzzle_agent_message,