新增图片画布编辑器
新增 /editor 图片画布入口与 Lovart 风格画布交互 新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF 接入图片生成和修改的 VectorEngine gpt-image-2 后端通道 完善素材库文件夹、重命名、上传删除、图层和元数据交互 补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
@@ -43,6 +43,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::auth::router(state.clone()))
|
||||
.merge(modules::profile::router(state.clone()))
|
||||
.merge(modules::assets::router(state.clone()))
|
||||
.merge(modules::editor_project::router(state.clone()))
|
||||
.merge(modules::platform::router(state.clone()))
|
||||
.merge(modules::play_flow::router(state.clone()))
|
||||
.route(
|
||||
|
||||
581
server-rs/crates/api-server/src/editor_project.rs
Normal file
581
server-rs/crates/api-server/src/editor_project.rs
Normal file
@@ -0,0 +1,581 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
GPT_IMAGE_2_MODEL, OpenAiReferenceImage, build_openai_image_http_client,
|
||||
create_openai_image_edit_with_references, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
|
||||
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
|
||||
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
|
||||
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||||
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectCreateRequest {
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorCanvasViewportPayload {
|
||||
x: f64,
|
||||
y: f64,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectLayoutSaveRequest {
|
||||
viewport: EditorCanvasViewportPayload,
|
||||
layers: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectResourceCreateRequest {
|
||||
image_src: String,
|
||||
object_key: Option<String>,
|
||||
asset_object_id: 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationRequest {
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageEditRequest {
|
||||
prompt: String,
|
||||
source_image_src: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectResponse {
|
||||
project: EditorProjectPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectRecentResponse {
|
||||
project: Option<EditorProjectPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectResourceResponse {
|
||||
resource: EditorProjectResourcePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationResponse {
|
||||
image_src: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
source_type: &'static str,
|
||||
prompt: String,
|
||||
actual_prompt: Option<String>,
|
||||
model: &'static str,
|
||||
provider: &'static str,
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectPayload {
|
||||
project_id: String,
|
||||
title: String,
|
||||
viewport: EditorCanvasViewportPayload,
|
||||
layers: Value,
|
||||
resources: Vec<EditorProjectResourcePayload>,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectResourcePayload {
|
||||
resource_id: String,
|
||||
project_id: String,
|
||||
image_src: String,
|
||||
object_key: Option<String>,
|
||||
asset_object_id: 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: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn load_recent_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.get_recent_editor_project(owner_user_id)
|
||||
.await
|
||||
.map_err(map_editor_project_error)?
|
||||
.map(editor_project_payload_from_record);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectRecentResponse { project },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorProjectCreateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
let title = payload
|
||||
.title
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string());
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.create_editor_project(EditorProjectCreateRecordInput {
|
||||
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
title,
|
||||
now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectResponse {
|
||||
project: editor_project_payload_from_record(project),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.get_editor_project(EditorProjectGetRecordInput {
|
||||
project_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectResponse {
|
||||
project: editor_project_payload_from_record(project),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn save_editor_project_layout(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorProjectLayoutSaveRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let layers_json = serialize_editor_layers(payload.layers)?;
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
|
||||
project_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
viewport: payload.viewport.into_record(),
|
||||
layers_json,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectResponse {
|
||||
project: editor_project_payload_from_record(project),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_editor_project_resource(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorProjectResourceCreateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let resource = state
|
||||
.spacetime_client()
|
||||
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
|
||||
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
|
||||
project_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
asset_object_id: normalize_optional_string(payload.asset_object_id),
|
||||
image_src: payload.image_src,
|
||||
object_key: normalize_optional_string(payload.object_key),
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
source_type: payload.source_type,
|
||||
prompt: normalize_optional_string(payload.prompt),
|
||||
actual_prompt: normalize_optional_string(payload.actual_prompt),
|
||||
model: normalize_optional_string(payload.model),
|
||||
provider: normalize_optional_string(payload.provider),
|
||||
task_id: normalize_optional_string(payload.task_id),
|
||||
source_resource_id: normalize_optional_string(payload.source_resource_id),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectResourceResponse {
|
||||
resource: editor_project_resource_payload_from_record(resource),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorImageGenerationRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let prompt = payload.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-generation",
|
||||
"message": "生成提示词不能为空",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(authenticated.claims().user_id().to_string()),
|
||||
None,
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
|
||||
EDITOR_IMAGE_GENERATION_SIZE,
|
||||
1,
|
||||
&[],
|
||||
"图片画布生成图片",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "VectorEngine 未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
let image_src = format!(
|
||||
"data:{};base64,{}",
|
||||
image.mime_type,
|
||||
BASE64_STANDARD.encode(image.bytes.as_slice())
|
||||
);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorImageGenerationResponse {
|
||||
image_src,
|
||||
width,
|
||||
height,
|
||||
source_type: "generated",
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL,
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn edit_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorImageEditRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let prompt = payload.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"message": "修改提示词不能为空",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let reference_image = parse_editor_reference_image(payload.source_image_src.as_str())?;
|
||||
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(authenticated.claims().user_id().to_string()),
|
||||
None,
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
|
||||
EDITOR_IMAGE_GENERATION_SIZE,
|
||||
1,
|
||||
&[reference_image],
|
||||
"图片画布修改图片",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "VectorEngine 未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
let image_src = format!(
|
||||
"data:{};base64,{}",
|
||||
image.mime_type,
|
||||
BASE64_STANDARD.encode(image.bytes.as_slice())
|
||||
);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorImageGenerationResponse {
|
||||
image_src,
|
||||
width,
|
||||
height,
|
||||
source_type: "generated",
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL,
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload {
|
||||
EditorProjectPayload {
|
||||
project_id: record.project_id,
|
||||
title: record.title,
|
||||
viewport: EditorCanvasViewportPayload {
|
||||
x: record.viewport.x,
|
||||
y: record.viewport.y,
|
||||
scale: record.viewport.scale,
|
||||
},
|
||||
layers: record.layers,
|
||||
resources: record
|
||||
.resources
|
||||
.into_iter()
|
||||
.map(editor_project_resource_payload_from_record)
|
||||
.collect(),
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_project_resource_payload_from_record(
|
||||
record: EditorProjectResourceRecord,
|
||||
) -> EditorProjectResourcePayload {
|
||||
EditorProjectResourcePayload {
|
||||
resource_id: record.resource_id,
|
||||
project_id: record.project_id,
|
||||
image_src: record.image_src,
|
||||
object_key: record.object_key,
|
||||
asset_object_id: record.asset_object_id,
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
source_type: record.source_type,
|
||||
prompt: record.prompt,
|
||||
actual_prompt: record.actual_prompt,
|
||||
model: record.model,
|
||||
provider: record.provider,
|
||||
task_id: record.task_id,
|
||||
source_resource_id: record.source_resource_id,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorCanvasViewportPayload {
|
||||
fn into_record(self) -> EditorCanvasViewportRecord {
|
||||
EditorCanvasViewportRecord {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
scale: self.scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
|
||||
let payload = serde_json::to_string(&layers).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": format!("图层布局无法序列化:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if payload.len() > EDITOR_LAYOUT_MAX_BYTES {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": "图层布局过大",
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
|
||||
let Some((header, data)) = source.trim().split_once(',') else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"field": "sourceImageSrc",
|
||||
"message": "修改图片参考图必须是图片 Data URL。",
|
||||
})),
|
||||
);
|
||||
};
|
||||
let Some(mime_type) = header
|
||||
.strip_prefix("data:")
|
||||
.and_then(|value| value.strip_suffix(";base64"))
|
||||
.filter(|value| value.starts_with("image/"))
|
||||
else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"field": "sourceImageSrc",
|
||||
"message": "修改图片参考图必须是 base64 图片 Data URL。",
|
||||
})),
|
||||
);
|
||||
};
|
||||
let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"field": "sourceImageSrc",
|
||||
"message": format!("修改图片参考图解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if bytes.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-image-edit",
|
||||
"field": "sourceImageSrc",
|
||||
"message": "修改图片参考图为空。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let extension = match mime_type {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
_ => "png",
|
||||
};
|
||||
Ok(OpenAiReferenceImage {
|
||||
bytes,
|
||||
mime_type: mime_type.to_string(),
|
||||
file_name: format!("editor-reference.{extension}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
match error {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
SpacetimeClientError::Procedure(message) if message.contains("不存在") => {
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
SpacetimeClientError::Runtime(message) => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": other.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
@@ -36,6 +36,7 @@ mod custom_world_asset_prompts;
|
||||
mod custom_world_foundation_draft;
|
||||
mod custom_world_result_prompts;
|
||||
mod custom_world_rpg_draft_prompts;
|
||||
mod editor_project;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
mod error_middleware;
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod auth;
|
||||
pub mod bark_battle;
|
||||
pub mod big_fish;
|
||||
pub mod custom_world;
|
||||
pub mod editor_project;
|
||||
pub mod edutainment;
|
||||
pub mod health;
|
||||
pub mod internal;
|
||||
|
||||
62
server-rs/crates/api-server/src/modules/editor_project.rs
Normal file
62
server-rs/crates/api-server/src/modules/editor_project.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
editor_project::{
|
||||
create_editor_project, create_editor_project_resource, edit_editor_image,
|
||||
generate_editor_image, get_editor_project, load_recent_editor_project,
|
||||
save_editor_project_layout,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/editor/projects/recent",
|
||||
get(load_recent_editor_project).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/projects",
|
||||
post(create_editor_project).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/projects/{project_id}",
|
||||
get(get_editor_project)
|
||||
.patch(save_editor_project_layout)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/projects/{project_id}/resources",
|
||||
post(create_editor_project_resource).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/generations",
|
||||
post(generate_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/edits",
|
||||
post(edit_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user