新增图片画布编辑器
新增 /editor 图片画布入口与 Lovart 风格画布交互 新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF 接入图片生成和修改的 VectorEngine gpt-image-2 后端通道 完善素材库文件夹、重命名、上传删除、图层和元数据交互 补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
500
server-rs/crates/spacetime-module/src/editor_project_storage.rs
Normal file
500
server-rs/crates/spacetime-module/src/editor_project_storage.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user