refactor: extract platform media crates
This commit is contained in:
@@ -33,7 +33,9 @@ module-square-hole = { workspace = true }
|
||||
module-story = { workspace = true }
|
||||
module-visual-novel = { workspace = true }
|
||||
platform-agent = { workspace = true }
|
||||
platform-audio = { workspace = true }
|
||||
platform-auth = { workspace = true }
|
||||
platform-hyper3d = { workspace = true }
|
||||
platform-image = { workspace = true }
|
||||
platform-llm = { workspace = true }
|
||||
platform-oss = { workspace = true }
|
||||
|
||||
@@ -9,8 +9,8 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AuthProvider, BindingStatus, RuntimeGuestTokenClaims,
|
||||
RuntimeGuestTokenClaimsInput, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, read_refresh_session_token,
|
||||
AccessTokenClaims, AuthProvider, BindingStatus, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY,
|
||||
RuntimeGuestTokenClaims, RuntimeGuestTokenClaimsInput, read_refresh_session_token,
|
||||
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -1792,9 +1792,8 @@ mod tests {
|
||||
"publishedAtMicros": 1_713_686_401_234_000i64
|
||||
});
|
||||
|
||||
let work =
|
||||
map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string())
|
||||
.expect("gallery summary should use provided author display name");
|
||||
let work = map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string())
|
||||
.expect("gallery summary should use provided author display name");
|
||||
|
||||
assert_eq!(work.author_display_name, "画廊作者");
|
||||
assert_eq!(
|
||||
|
||||
@@ -148,7 +148,8 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
|
||||
description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(),
|
||||
cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC
|
||||
.to_string(),
|
||||
prize_pool_mud_points: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
|
||||
prize_pool_mud_points:
|
||||
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
|
||||
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
|
||||
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
|
||||
},
|
||||
@@ -269,7 +270,10 @@ mod tests {
|
||||
assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}");
|
||||
assert_eq!(baby_object_match.sort_order, 90);
|
||||
assert_eq!(baby_object_match.category_id, "character");
|
||||
assert_eq!(baby_object_match.category_label, "\u{89d2}\u{8272}\u{521b}\u{4f5c}");
|
||||
assert_eq!(
|
||||
baby_object_match.category_label,
|
||||
"\u{89d2}\u{8272}\u{521b}\u{4f5c}"
|
||||
);
|
||||
assert_eq!(baby_object_match.category_sort_order, 40);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_image::PlatformImageFailureAudit;
|
||||
use module_runtime::RuntimeTrackingScopeKind;
|
||||
use platform_image::PlatformImageFailureAudit;
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,20 @@
|
||||
// 中文注释:C0 先落公共骨架,真实调用方迁移到 C1 后再移除未使用豁免。
|
||||
#![allow(dead_code, unused_imports)]
|
||||
pub mod adapter {
|
||||
pub use platform_image::generated_assets::adapter::*;
|
||||
}
|
||||
|
||||
pub mod adapter;
|
||||
pub mod helpers;
|
||||
pub mod helpers {
|
||||
pub use platform_image::generated_assets::helpers::*;
|
||||
}
|
||||
|
||||
pub(crate) use adapter::{GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary};
|
||||
pub(crate) use helpers::{
|
||||
GeneratedImageAssetDataUrl, GeneratedImageAssetImageFormat, GeneratedImageAssetMetadataInput,
|
||||
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
|
||||
build_generated_image_asset_storage_paths, decode_generated_image_asset_data_url,
|
||||
merge_generated_image_asset_metadata, normalize_generated_image_asset_mime,
|
||||
pub(crate) use adapter::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary,
|
||||
GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput,
|
||||
GeneratedImageAssetPreparedPut,
|
||||
};
|
||||
pub(crate) use helpers::{
|
||||
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
|
||||
GeneratedImageAssetMetadataInput, GeneratedImageAssetStoragePaths,
|
||||
build_generated_image_asset_metadata, build_generated_image_asset_storage_paths,
|
||||
decode_generated_image_asset_data_url, merge_generated_image_asset_metadata,
|
||||
normalize_generated_image_asset_mime,
|
||||
};
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest};
|
||||
|
||||
use super::helpers::{
|
||||
GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat,
|
||||
GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata,
|
||||
build_generated_image_asset_storage_paths, merge_generated_image_asset_metadata,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetAdapterBoundary;
|
||||
|
||||
impl GeneratedImageAssetAdapterBoundary {
|
||||
pub(crate) const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence.";
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetAdapter;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetPersistInput {
|
||||
pub(crate) prefix: LegacyAssetPrefix,
|
||||
pub(crate) path_segments: Vec<String>,
|
||||
pub(crate) file_stem: String,
|
||||
pub(crate) image: GeneratedImageAssetDataUrl,
|
||||
pub(crate) access: OssObjectAccess,
|
||||
pub(crate) metadata: GeneratedImageAssetAdapterMetadata,
|
||||
pub(crate) extra_metadata: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetAdapterMetadata {
|
||||
pub(crate) asset_kind: Option<String>,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) entity_kind: Option<String>,
|
||||
pub(crate) entity_id: Option<String>,
|
||||
pub(crate) slot: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetPreparedPut {
|
||||
pub(crate) request: OssPutObjectRequest,
|
||||
pub(crate) storage_paths: GeneratedImageAssetStoragePaths,
|
||||
pub(crate) format: GeneratedImageAssetImageFormat,
|
||||
}
|
||||
|
||||
impl GeneratedImageAssetAdapter {
|
||||
/// Adapter boundary: this skeleton intentionally does not read, reserve, charge, refund,
|
||||
/// or otherwise mutate billing state. Real callers must keep billing orchestration outside
|
||||
/// generated_image_assets when they migrate onto this adapter.
|
||||
pub(crate) fn prepare_put_object(
|
||||
input: GeneratedImageAssetPersistInput,
|
||||
) -> Result<GeneratedImageAssetPreparedPut, GeneratedImageAssetHelperError> {
|
||||
let file_name = format!(
|
||||
"{}.{}",
|
||||
input.file_stem.trim(),
|
||||
input.image.format.extension
|
||||
);
|
||||
let storage_paths = build_generated_image_asset_storage_paths(
|
||||
input.prefix,
|
||||
&input.path_segments,
|
||||
file_name.as_str(),
|
||||
)?;
|
||||
let metadata = merge_generated_image_asset_metadata(
|
||||
build_generated_image_asset_metadata(input.metadata.into()),
|
||||
input.extra_metadata,
|
||||
);
|
||||
let format = input.image.format.clone();
|
||||
|
||||
Ok(GeneratedImageAssetPreparedPut {
|
||||
request: OssPutObjectRequest {
|
||||
prefix: input.prefix,
|
||||
path_segments: input.path_segments,
|
||||
file_name,
|
||||
content_type: Some(format.mime_type.clone()),
|
||||
access: input.access,
|
||||
metadata,
|
||||
body: input.image.bytes,
|
||||
},
|
||||
storage_paths,
|
||||
format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GeneratedImageAssetAdapterMetadata> for super::helpers::GeneratedImageAssetMetadataInput {
|
||||
fn from(value: GeneratedImageAssetAdapterMetadata) -> Self {
|
||||
Self {
|
||||
asset_kind: value.asset_kind,
|
||||
owner_user_id: value.owner_user_id,
|
||||
entity_kind: value.entity_kind,
|
||||
entity_id: value.entity_id,
|
||||
slot: value.slot,
|
||||
provider: value.provider,
|
||||
task_id: value.task_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod generated_image_assets_adapter_tests {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
|
||||
use super::*;
|
||||
use crate::generated_image_assets::helpers::decode_generated_image_asset_data_url;
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_adapter_prepares_put_without_billing_side_effects() {
|
||||
let image = decode_generated_image_asset_data_url(&format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(b"png bytes")
|
||||
))
|
||||
.expect("image should decode");
|
||||
|
||||
let prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix: LegacyAssetPrefix::SquareHoleAssets,
|
||||
path_segments: vec!["work/1".to_string(), "cover".to_string()],
|
||||
file_stem: "image".to_string(),
|
||||
image,
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some("square-hole-cover".to_string()),
|
||||
owner_user_id: Some("user-1".to_string()),
|
||||
entity_kind: Some("work".to_string()),
|
||||
entity_id: Some("work-1".to_string()),
|
||||
slot: Some("cover".to_string()),
|
||||
provider: Some("dashscope".to_string()),
|
||||
task_id: Some("task-1".to_string()),
|
||||
},
|
||||
extra_metadata: BTreeMap::from([("caller".to_string(), "unit-test".to_string())]),
|
||||
})
|
||||
.expect("put object should be prepared");
|
||||
|
||||
assert_eq!(
|
||||
GeneratedImageAssetAdapterBoundary::BILLING_BOUNDARY_COMMENT,
|
||||
"generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."
|
||||
);
|
||||
assert_eq!(prepared.request.prefix, LegacyAssetPrefix::SquareHoleAssets);
|
||||
assert_eq!(prepared.request.file_name, "image.png");
|
||||
assert_eq!(prepared.request.content_type, Some("image/png".to_string()));
|
||||
assert_eq!(prepared.request.body, b"png bytes");
|
||||
assert_eq!(
|
||||
prepared.storage_paths.object_key,
|
||||
"generated-square-hole-assets/work-1/cover/image.png"
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.storage_paths.legacy_public_path,
|
||||
"/generated-square-hole-assets/work-1/cover/image.png"
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.request.metadata.get("asset_kind"),
|
||||
Some(&"square-hole-cover".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.request.metadata.get("caller"),
|
||||
Some(&"unit-test".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
|
||||
const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetImageFormat {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) extension: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetDataUrl {
|
||||
pub(crate) format: GeneratedImageAssetImageFormat,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetMetadataInput {
|
||||
pub(crate) asset_kind: Option<String>,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) entity_kind: Option<String>,
|
||||
pub(crate) entity_id: Option<String>,
|
||||
pub(crate) slot: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetStoragePaths {
|
||||
pub(crate) object_key: String,
|
||||
pub(crate) legacy_public_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GeneratedImageAssetHelperError {
|
||||
InvalidDataUrl,
|
||||
UnsupportedEncoding,
|
||||
DecodeBase64(String),
|
||||
InvalidFileName,
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_generated_image_asset_mime(
|
||||
raw_content_type: impl AsRef<str>,
|
||||
) -> GeneratedImageAssetImageFormat {
|
||||
let mime_type = raw_content_type
|
||||
.as_ref()
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or(DEFAULT_IMAGE_MIME)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match mime_type.as_str() {
|
||||
"image/png" => image_format("image/png", "png"),
|
||||
"image/webp" => image_format("image/webp", "webp"),
|
||||
"image/gif" => image_format("image/gif", "gif"),
|
||||
"image/jpeg" | "image/jpg" | "application/octet-stream" | "" => {
|
||||
image_format(DEFAULT_IMAGE_MIME, "jpg")
|
||||
}
|
||||
_ => image_format(DEFAULT_IMAGE_MIME, "jpg"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_generated_image_asset_data_url(
|
||||
raw_data_url: &str,
|
||||
) -> Result<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
|
||||
let (metadata, encoded) = raw_data_url
|
||||
.trim()
|
||||
.split_once(',')
|
||||
.ok_or(GeneratedImageAssetHelperError::InvalidDataUrl)?;
|
||||
let metadata = metadata.trim();
|
||||
if !metadata.to_ascii_lowercase().starts_with("data:") {
|
||||
return Err(GeneratedImageAssetHelperError::InvalidDataUrl);
|
||||
}
|
||||
|
||||
let header = &metadata["data:".len()..];
|
||||
let mut parts = header
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty());
|
||||
let mime_type = parts.next().unwrap_or(DEFAULT_IMAGE_MIME);
|
||||
let is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64"));
|
||||
if !is_base64 {
|
||||
return Err(GeneratedImageAssetHelperError::UnsupportedEncoding);
|
||||
}
|
||||
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(encoded.trim())
|
||||
.map_err(|error| GeneratedImageAssetHelperError::DecodeBase64(error.to_string()))?;
|
||||
|
||||
Ok(GeneratedImageAssetDataUrl {
|
||||
format: normalize_generated_image_asset_mime(mime_type),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_generated_image_asset_storage_paths(
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: &[String],
|
||||
file_name: &str,
|
||||
) -> Result<GeneratedImageAssetStoragePaths, GeneratedImageAssetHelperError> {
|
||||
let file_name = sanitize_generated_image_asset_file_name(file_name)?;
|
||||
let mut parts = vec![prefix.as_str().to_string()];
|
||||
parts.extend(
|
||||
path_segments
|
||||
.iter()
|
||||
.map(|segment| sanitize_generated_image_asset_path_segment(segment))
|
||||
.filter(|segment| !segment.is_empty()),
|
||||
);
|
||||
parts.push(file_name);
|
||||
|
||||
let object_key = parts.join("/");
|
||||
Ok(GeneratedImageAssetStoragePaths {
|
||||
legacy_public_path: format!("/{object_key}"),
|
||||
object_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_generated_image_asset_metadata(
|
||||
input: GeneratedImageAssetMetadataInput,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut metadata = BTreeMap::new();
|
||||
insert_optional_metadata(&mut metadata, "asset_kind", input.asset_kind);
|
||||
insert_optional_metadata(&mut metadata, "owner_user_id", input.owner_user_id);
|
||||
insert_optional_metadata(&mut metadata, "entity_kind", input.entity_kind);
|
||||
insert_optional_metadata(&mut metadata, "entity_id", input.entity_id);
|
||||
insert_optional_metadata(&mut metadata, "slot", input.slot);
|
||||
insert_optional_metadata(&mut metadata, "provider", input.provider);
|
||||
insert_optional_metadata(&mut metadata, "task_id", input.task_id);
|
||||
metadata
|
||||
}
|
||||
|
||||
pub(crate) fn merge_generated_image_asset_metadata(
|
||||
base: BTreeMap<String, String>,
|
||||
overlay: BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut merged = BTreeMap::new();
|
||||
for (key, value) in base.into_iter().chain(overlay) {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
if key.is_empty() || value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
merged.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn image_format(mime_type: &str, extension: &str) -> GeneratedImageAssetImageFormat {
|
||||
GeneratedImageAssetImageFormat {
|
||||
mime_type: mime_type.to_string(),
|
||||
extension: extension.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_optional_metadata(
|
||||
metadata: &mut BTreeMap<String, String>,
|
||||
key: &str,
|
||||
value: Option<String>,
|
||||
) {
|
||||
if let Some(value) = value {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
metadata.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_generated_image_asset_path_segment(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches('/')
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
||||
ch if ch.is_control() => '-',
|
||||
ch => ch,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn sanitize_generated_image_asset_file_name(
|
||||
raw: &str,
|
||||
) -> Result<String, GeneratedImageAssetHelperError> {
|
||||
let sanitized = sanitize_generated_image_asset_path_segment(raw);
|
||||
if sanitized.is_empty() || sanitized == "." || sanitized == ".." || sanitized.contains('/') {
|
||||
return Err(GeneratedImageAssetHelperError::InvalidFileName);
|
||||
}
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod generated_image_assets_tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_normalize_mime_and_extension() {
|
||||
assert_eq!(
|
||||
normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "),
|
||||
image_format("image/png", "png")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_image_asset_mime("image/jpg"),
|
||||
image_format("image/jpeg", "jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_image_asset_mime("text/plain"),
|
||||
image_format("image/jpeg", "jpg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_decode_data_url_base64() {
|
||||
let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=")
|
||||
.expect("data url should decode");
|
||||
|
||||
assert_eq!(decoded.format, image_format("image/webp", "webp"));
|
||||
assert_eq!(decoded.bytes, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_reject_non_base64_data_url() {
|
||||
assert_eq!(
|
||||
decode_generated_image_asset_data_url("data:image/png,hello").unwrap_err(),
|
||||
GeneratedImageAssetHelperError::UnsupportedEncoding
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_build_object_key_and_legacy_path() {
|
||||
let paths = build_generated_image_asset_storage_paths(
|
||||
LegacyAssetPrefix::BigFishAssets,
|
||||
&[" world/001 ".to_string(), "slot:cover".to_string()],
|
||||
" image.png ",
|
||||
)
|
||||
.expect("paths should build");
|
||||
|
||||
assert_eq!(
|
||||
paths.object_key,
|
||||
"generated-big-fish-assets/world-001/slot-cover/image.png"
|
||||
);
|
||||
assert_eq!(
|
||||
paths.legacy_public_path,
|
||||
"/generated-big-fish-assets/world-001/slot-cover/image.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_merge_metadata_trims_and_overlay_wins() {
|
||||
let base = BTreeMap::from([
|
||||
("asset_kind".to_string(), " old ".to_string()),
|
||||
("empty".to_string(), " ".to_string()),
|
||||
]);
|
||||
let overlay = BTreeMap::from([
|
||||
("asset_kind".to_string(), "cover".to_string()),
|
||||
(" task_id ".to_string(), " task-1 ".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
merge_generated_image_asset_metadata(base, overlay),
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), "cover".to_string()),
|
||||
("task_id".to_string(), "task-1".to_string()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_image_assets_build_metadata_omits_blank_values() {
|
||||
let metadata = build_generated_image_asset_metadata(GeneratedImageAssetMetadataInput {
|
||||
asset_kind: Some(" scene ".to_string()),
|
||||
owner_user_id: Some("".to_string()),
|
||||
entity_kind: Some("world".to_string()),
|
||||
entity_id: None,
|
||||
slot: Some(" cover ".to_string()),
|
||||
provider: Some("dashscope".to_string()),
|
||||
task_id: Some(" task-1 ".to_string()),
|
||||
});
|
||||
|
||||
assert_eq!(metadata.get("asset_kind"), Some(&"scene".to_string()));
|
||||
assert_eq!(metadata.get("owner_user_id"), None);
|
||||
assert_eq!(metadata.get("slot"), Some(&"cover".to_string()));
|
||||
assert_eq!(metadata.get("task_id"), Some(&"task-1".to_string()));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,14 @@ use shared_contracts::jump_hop::{
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
http_error::AppError,
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
||||
slice_generated_asset_sheet,
|
||||
@@ -35,16 +37,18 @@ use crate::{
|
||||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||||
normalize_generated_image_asset_mime,
|
||||
},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft},
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"];
|
||||
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] =
|
||||
["start", "normal", "target", "finish", "bonus", "accent"];
|
||||
|
||||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||||
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||||
@@ -384,7 +388,10 @@ async fn maybe_generate_jump_hop_assets(
|
||||
}
|
||||
if payload.character_asset.is_some()
|
||||
&& payload.tile_atlas_asset.is_some()
|
||||
&& payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty())
|
||||
&& payload
|
||||
.tile_assets
|
||||
.as_ref()
|
||||
.is_some_and(|assets| !assets.is_empty())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -397,19 +404,18 @@ async fn maybe_generate_jump_hop_assets(
|
||||
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
|
||||
payload.profile_id = Some(profile_id.clone());
|
||||
|
||||
let settings = require_openai_image_settings(state)
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
let http_client = build_openai_image_http_client(&settings)
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
let settings = require_openai_image_settings(state).map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
|
||||
let character_prompt = payload
|
||||
.character_prompt
|
||||
.as_deref()
|
||||
.unwrap_or("俯视角可爱主角,透明背景");
|
||||
let tile_prompt = payload
|
||||
.tile_prompt
|
||||
.as_deref()
|
||||
.unwrap_or("等距立体地块图集");
|
||||
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集");
|
||||
|
||||
let character_generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
@@ -423,16 +429,20 @@ async fn maybe_generate_jump_hop_assets(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
let character_image = character_generated.images.into_iter().next().ok_or_else(|| {
|
||||
jump_hop_error_response(
|
||||
request_context,
|
||||
JUMP_HOP_CREATION_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "跳一跳角色资产生成成功但未返回图片。",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let character_image = character_generated
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
jump_hop_error_response(
|
||||
request_context,
|
||||
JUMP_HOP_CREATION_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "跳一跳角色资产生成成功但未返回图片。",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let character_asset = persist_jump_hop_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -449,7 +459,14 @@ async fn maybe_generate_jump_hop_assets(
|
||||
|
||||
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
||||
subject_text: tile_prompt,
|
||||
item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
||||
item_names: &vec![
|
||||
"start".to_string(),
|
||||
"normal".to_string(),
|
||||
"target".to_string(),
|
||||
"finish".to_string(),
|
||||
"bonus".to_string(),
|
||||
"accent".to_string(),
|
||||
],
|
||||
grid_size: 3,
|
||||
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
|
||||
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
|
||||
@@ -479,7 +496,14 @@ async fn maybe_generate_jump_hop_assets(
|
||||
})?;
|
||||
let tile_slices = slice_generated_asset_sheet(
|
||||
&tile_image,
|
||||
&vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
||||
&vec![
|
||||
"start".to_string(),
|
||||
"normal".to_string(),
|
||||
"target".to_string(),
|
||||
"finish".to_string(),
|
||||
"bonus".to_string(),
|
||||
"accent".to_string(),
|
||||
],
|
||||
3,
|
||||
)
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
@@ -521,10 +545,11 @@ async fn maybe_generate_jump_hop_assets(
|
||||
payload.character_asset = Some(character_asset);
|
||||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||||
payload.tile_assets = Some(tile_assets);
|
||||
payload.cover_composite = payload
|
||||
.cover_composite
|
||||
.clone()
|
||||
.or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png")));
|
||||
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
|
||||
Some(format!(
|
||||
"/generated-jump-hop-assets/{profile_id}/cover-composite.png"
|
||||
))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -541,36 +566,37 @@ async fn persist_jump_hop_generated_image_asset(
|
||||
request_context: &RequestContext,
|
||||
) -> Result<JumpHopCharacterAsset, Response> {
|
||||
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||||
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix,
|
||||
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: image_format,
|
||||
bytes: image.bytes,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(format!("jump-hop-{slot}")),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some("jump_hop_work".to_string()),
|
||||
entity_id: Some(profile_id.to_string()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: Some("vector-engine".to_string()),
|
||||
task_id: None,
|
||||
},
|
||||
extra_metadata: BTreeMap::new(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(
|
||||
request_context,
|
||||
JUMP_HOP_CREATION_PROVIDER,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "generated-image-assets",
|
||||
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix,
|
||||
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: image_format,
|
||||
bytes: image.bytes,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(format!("jump-hop-{slot}")),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some("jump_hop_work".to_string()),
|
||||
entity_id: Some(profile_id.to_string()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: Some("vector-engine".to_string()),
|
||||
task_id: None,
|
||||
},
|
||||
extra_metadata: BTreeMap::new(),
|
||||
})
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(
|
||||
request_context,
|
||||
JUMP_HOP_CREATION_PROVIDER,
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "generated-image-assets",
|
||||
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
jump_hop_error_response(
|
||||
@@ -709,7 +735,6 @@ fn build_jump_hop_work_play_tracking_draft(
|
||||
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
|
||||
}
|
||||
|
||||
|
||||
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||||
JumpHopDraftResponse {
|
||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||
|
||||
@@ -65,7 +65,10 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/runtime-guest-token", post(issue_runtime_guest_token))
|
||||
.route(
|
||||
"/api/auth/runtime-guest-token",
|
||||
post(issue_runtime_guest_token),
|
||||
)
|
||||
.route("/api/auth/phone/send-code", post(send_phone_code))
|
||||
.route("/api/auth/phone/login", post(phone_login))
|
||||
.route("/api/auth/wechat/start", get(start_wechat_login))
|
||||
|
||||
@@ -6,10 +6,10 @@ use axum::{
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
|
||||
generate_bark_battle_image_asset, get_bark_battle_runtime_config,
|
||||
list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
|
||||
start_bark_battle_run, update_bark_battle_draft_config,
|
||||
create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset,
|
||||
get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery,
|
||||
list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run,
|
||||
update_bark_battle_draft_config,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -490,13 +490,15 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
let bytes_len = parsed.bytes.len();
|
||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": build_puzzle_reference_image_too_large_message(bytes_len),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": bytes_len,
|
||||
})));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": build_puzzle_reference_image_too_large_message(bytes_len),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": bytes_len,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return Ok(PuzzleResolvedReferenceImage {
|
||||
mime_type: parsed.mime_type,
|
||||
@@ -648,16 +650,18 @@ pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
if asset_object.content_length == 0
|
||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||
{
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": build_puzzle_reference_image_too_large_message(
|
||||
asset_object.content_length as usize,
|
||||
),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})));
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": build_puzzle_reference_image_too_large_message(
|
||||
asset_object.content_length as usize,
|
||||
),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
pub(super) fn current_utc_micros() -> i64 {
|
||||
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
||||
}
|
||||
|
||||
pub(super) fn current_utc_iso_text() -> String {
|
||||
shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc())
|
||||
.unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros()))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use axum::{Json, extract::rejection::JsonRejection, http::StatusCode, response::Response};
|
||||
use platform_audio::{AudioError, AudioStatusHint};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, request_context::RequestContext};
|
||||
|
||||
use super::types::VECTOR_ENGINE_PROVIDER;
|
||||
|
||||
pub(super) fn normalize_limited_text(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
max_chars: usize,
|
||||
) -> Result<String, AppError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"field": field,
|
||||
"message": format!("{field} 不能为空"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"field": field,
|
||||
"message": format!("{field} 超过 {} 字符", max_chars),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn map_platform_audio_error(error: AudioError) -> AppError {
|
||||
let status = match error.status_hint() {
|
||||
AudioStatusHint::BadRequest => StatusCode::BAD_REQUEST,
|
||||
AudioStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
|
||||
AudioStatusHint::BadGateway => StatusCode::BAD_GATEWAY,
|
||||
AudioStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
};
|
||||
let mut details = json!({
|
||||
"provider": error.provider(),
|
||||
"message": error.message(),
|
||||
});
|
||||
match &error {
|
||||
AudioError::InvalidConfig { .. } | AudioError::InvalidRequest { .. } => {}
|
||||
AudioError::Request {
|
||||
endpoint,
|
||||
timeout,
|
||||
connect,
|
||||
request,
|
||||
body,
|
||||
status_code,
|
||||
source,
|
||||
..
|
||||
} => {
|
||||
details["endpoint"] = json!(endpoint);
|
||||
details["timeout"] = json!(timeout);
|
||||
details["connect"] = json!(connect);
|
||||
details["request"] = json!(request);
|
||||
details["body"] = json!(body);
|
||||
details["status"] = json!(status_code);
|
||||
details["source"] = json!(source);
|
||||
}
|
||||
AudioError::Upstream {
|
||||
upstream_status,
|
||||
raw_excerpt,
|
||||
..
|
||||
} => {
|
||||
details["upstreamStatus"] = json!(upstream_status);
|
||||
details["rawExcerpt"] = json!(raw_excerpt);
|
||||
}
|
||||
AudioError::ResponseParse { raw_excerpt, .. } => {
|
||||
details["rawExcerpt"] = json!(raw_excerpt);
|
||||
}
|
||||
AudioError::MissingAudio { .. } => {}
|
||||
}
|
||||
AppError::from_status(status).with_details(details)
|
||||
}
|
||||
|
||||
pub(super) fn vector_engine_bad_gateway(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn parse_json_payload<T>(
|
||||
request_context: &RequestContext,
|
||||
payload: Result<Json<T>, JsonRejection>,
|
||||
) -> Result<Json<T>, Response> {
|
||||
payload.map_err(|rejection| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(format!("请求体 JSON 不合法:{rejection}"))
|
||||
.into_response_with_context(Some(request_context))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
use super::{
|
||||
clock::current_utc_iso_text,
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
publish::wait_for_generated_audio_asset,
|
||||
tasks::{create_background_music_task_response, create_sound_effect_task_response},
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget},
|
||||
};
|
||||
|
||||
pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
prompt: String,
|
||||
duration: Option<u8>,
|
||||
seed: Option<u64>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let normalized_prompt = platform_audio::normalize_limited_text(
|
||||
&prompt,
|
||||
"prompt",
|
||||
platform_audio::VIDU_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task =
|
||||
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: None,
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
|
||||
&prompt,
|
||||
"prompt",
|
||||
platform_audio::SUNO_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let normalized_title = platform_audio::normalize_limited_text(
|
||||
&title,
|
||||
"title",
|
||||
platform_audio::SUNO_TITLE_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = create_background_music_task_response(
|
||||
state,
|
||||
normalized_prompt.clone(),
|
||||
normalized_title.clone(),
|
||||
tags,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: Some(normalized_title),
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State, rejection::JsonRejection},
|
||||
response::Response,
|
||||
};
|
||||
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::{creation_audio, visual_novel as contract};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
use super::{
|
||||
errors::{map_platform_audio_error, parse_json_payload},
|
||||
publish::publish_generated_audio_asset,
|
||||
settings::require_vector_engine_audio_settings,
|
||||
targets::{
|
||||
build_creation_audio_target, build_visual_novel_audio_target,
|
||||
creation_audio_generation_disabled_error,
|
||||
creation_audio_generation_disabled_error_for_target,
|
||||
},
|
||||
types::AudioAssetSlot,
|
||||
};
|
||||
|
||||
pub async fn create_visual_novel_background_music_task(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<contract::CreateVisualNovelBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let settings = require_vector_engine_audio_settings(&state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = platform_audio::submit_background_music_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
BackgroundMusicTaskRequest {
|
||||
prompt: payload.prompt,
|
||||
title: payload.title,
|
||||
tags: payload.tags,
|
||||
model: payload.model,
|
||||
instrumental: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelAudioGenerationTaskResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
|
||||
task_id: task.task_id,
|
||||
provider: task.provider,
|
||||
status: task.status,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_background_music_task(
|
||||
State(_state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn create_visual_novel_sound_effect_task(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<contract::CreateVisualNovelSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
let settings = require_vector_engine_audio_settings(&state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = platform_audio::submit_sound_effect_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
SoundEffectTaskRequest {
|
||||
prompt: payload.prompt,
|
||||
duration: payload
|
||||
.duration
|
||||
.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
|
||||
.clamp(2, 10),
|
||||
seed: payload.seed,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelAudioGenerationTaskResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
|
||||
task_id: task.task_id,
|
||||
provider: task.provider,
|
||||
status: task.status,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_sound_effect_task(
|
||||
State(_state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_visual_novel_background_music_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| {
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
|
||||
task_id: payload.task_id,
|
||||
provider: payload.provider,
|
||||
status: payload.status,
|
||||
asset_object_id: payload.asset_object_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
audio_src: payload.audio_src,
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_visual_novel_sound_effect_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| {
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
contract::VisualNovelGeneratedAudioAssetResponse {
|
||||
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
|
||||
task_id: payload.task_id,
|
||||
provider: payload.provider,
|
||||
status: payload.status,
|
||||
asset_object_id: payload.asset_object_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
audio_src: payload.audio_src,
|
||||
},
|
||||
)
|
||||
})
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_background_music_asset(
|
||||
State(_state): State<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload)
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_sound_effect_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use axum::http::StatusCode;
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||
generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_audio::{DownloadedAudio, GeneratedAudioPersistInput, GeneratedAudioPersistTarget};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
|
||||
|
||||
use super::{
|
||||
clock::current_utc_micros,
|
||||
errors::{map_asset_field_error, map_spacetime_error},
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PersistedAudioAsset {
|
||||
pub(super) asset_object_id: String,
|
||||
pub(super) audio_src: String,
|
||||
}
|
||||
|
||||
pub(super) async fn persist_generated_audio_asset(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
owner_user_id: &str,
|
||||
task_id: &str,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
audio: DownloadedAudio,
|
||||
) -> Result<PersistedAudioAsset, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
let audio_mime_type = audio.mime_type.clone();
|
||||
let put_request =
|
||||
platform_audio::prepare_generated_audio_put_request(GeneratedAudioPersistInput {
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
task_kind: slot.task_kind(),
|
||||
target: GeneratedAudioPersistTarget {
|
||||
entity_kind: target.entity_kind.clone(),
|
||||
entity_id: target.entity_id.clone(),
|
||||
slot: target.slot.clone(),
|
||||
asset_kind: target.asset_kind.clone(),
|
||||
profile_id: target.profile_id.clone(),
|
||||
storage_prefix: target.storage_prefix,
|
||||
storage_scope: target.storage_scope.clone(),
|
||||
},
|
||||
audio,
|
||||
});
|
||||
let put_result = oss_client
|
||||
.put_object(http_client, put_request)
|
||||
.await
|
||||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
http_client,
|
||||
platform_oss::OssHeadObjectRequest {
|
||||
object_key: put_result.object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||||
let now_micros = current_utc_micros();
|
||||
let asset_object = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(audio_mime_type)),
|
||||
head.content_length,
|
||||
head.etag,
|
||||
target.asset_kind.clone(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
target.profile_id.clone(),
|
||||
Some(target.entity_id.clone()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object.asset_object_id.clone(),
|
||||
target.entity_kind,
|
||||
target.entity_id,
|
||||
target.slot,
|
||||
target.asset_kind,
|
||||
Some(owner_user_id.to_string()),
|
||||
target.profile_id,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
|
||||
Ok(PersistedAudioAsset {
|
||||
asset_object_id: asset_object.asset_object_id,
|
||||
audio_src: put_result.legacy_public_path,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{
|
||||
asset_billing::execute_billable_asset_operation_with_cost, http_error::AppError,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::{
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
persist::persist_generated_audio_asset,
|
||||
settings::require_vector_engine_audio_settings,
|
||||
types::{
|
||||
AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST,
|
||||
CREATION_SOUND_EFFECT_POINTS_COST,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) async fn publish_generated_audio_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
let task_id = platform_audio::normalize_limited_text(&task_id, "taskId", 160)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let (status, audio_urls): (String, Vec<String>) =
|
||||
platform_audio::resolve_audio_task_download_urls(
|
||||
&http_client,
|
||||
&settings,
|
||||
slot.task_kind(),
|
||||
&task_id,
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
if platform_audio::is_pending_task_status(&status) && audio_urls.is_empty() {
|
||||
return Ok(creation_audio::GeneratedAudioAssetResponse {
|
||||
kind: slot.creation_contract_kind(),
|
||||
task_id,
|
||||
provider: slot.provider().to_string(),
|
||||
status: status.clone(),
|
||||
asset_object_id: None,
|
||||
asset_kind: None,
|
||||
audio_src: None,
|
||||
});
|
||||
}
|
||||
|
||||
if platform_audio::is_failed_task_status(&status) {
|
||||
return Err(vector_engine_bad_gateway(
|
||||
"音频生成任务失败,请调整提示词后重试",
|
||||
));
|
||||
}
|
||||
|
||||
let audio_url = audio_urls
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
|
||||
let billing_asset_kind = target.asset_kind.clone();
|
||||
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
|
||||
let points_cost = resolve_creation_audio_points_cost(slot, &target);
|
||||
let persisted = execute_billable_asset_operation_with_cost(
|
||||
state,
|
||||
owner_user_id,
|
||||
billing_asset_kind.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
points_cost,
|
||||
async {
|
||||
let audio =
|
||||
platform_audio::download_generated_audio(&http_client, &audio_url, slot.provider())
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
persist_generated_audio_asset(
|
||||
state,
|
||||
&http_client,
|
||||
owner_user_id,
|
||||
&task_id,
|
||||
slot,
|
||||
target.clone(),
|
||||
audio,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(creation_audio::GeneratedAudioAssetResponse {
|
||||
kind: slot.creation_contract_kind(),
|
||||
task_id,
|
||||
provider: slot.provider().to_string(),
|
||||
status: "completed".to_string(),
|
||||
asset_object_id: Some(persisted.asset_object_id),
|
||||
asset_kind: Some(target.asset_kind),
|
||||
audio_src: Some(persisted.audio_src),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_generated_audio_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
let mut latest_status = String::new();
|
||||
for _ in 0..40 {
|
||||
let response = publish_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task_id.clone(),
|
||||
slot,
|
||||
target.clone(),
|
||||
)
|
||||
.await?;
|
||||
if response
|
||||
.audio_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
latest_status = response.status;
|
||||
tokio::time::sleep(Duration::from_millis(3_000)).await;
|
||||
}
|
||||
|
||||
Err(vector_engine_bad_gateway(format!(
|
||||
"音频生成超时:{}",
|
||||
if latest_status.trim().is_empty() {
|
||||
task_id
|
||||
} else {
|
||||
latest_status
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) fn build_audio_billing_asset_id(
|
||||
task_id: &str,
|
||||
slot: AudioAssetSlot,
|
||||
target: &AudioAssetBindingTarget,
|
||||
) -> String {
|
||||
format!(
|
||||
"creation-audio:{}:{}:{}:{}",
|
||||
slot.file_stem(),
|
||||
task_id,
|
||||
target.entity_id,
|
||||
target.slot
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_creation_audio_points_cost(
|
||||
slot: AudioAssetSlot,
|
||||
_target: &AudioAssetBindingTarget,
|
||||
) -> u64 {
|
||||
match slot {
|
||||
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
|
||||
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_audio::VectorEngineAudioSettings;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
use super::types::VECTOR_ENGINE_PROVIDER;
|
||||
|
||||
pub(super) fn require_vector_engine_audio_settings(
|
||||
state: &AppState,
|
||||
) -> Result<VectorEngineAudioSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(VectorEngineAudioSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_audio_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use serde_json::json;
|
||||
use shared_contracts::{creation_audio, visual_novel as contract};
|
||||
|
||||
use crate::http_error::AppError;
|
||||
|
||||
use super::{
|
||||
errors::{normalize_limited_text, normalize_optional_text},
|
||||
types::{AUDIO_ENTITY_KIND, AudioAssetBindingTarget, AudioAssetSlot, VECTOR_ENGINE_PROVIDER},
|
||||
};
|
||||
|
||||
pub(super) fn build_visual_novel_audio_target(
|
||||
payload: contract::PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
slot: AudioAssetSlot,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?;
|
||||
Ok(AudioAssetBindingTarget {
|
||||
entity_kind: AUDIO_ENTITY_KIND.to_string(),
|
||||
entity_id,
|
||||
slot: slot.slot().to_string(),
|
||||
asset_kind: slot.asset_kind().to_string(),
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
storage_scope: "visual-novel".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_creation_audio_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
_slot: AudioAssetSlot,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload))
|
||||
}
|
||||
|
||||
pub(super) fn creation_audio_generation_disabled_error() -> AppError {
|
||||
AppError::from_status(StatusCode::GONE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "当前创作音频目标未开放",
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn creation_audio_generation_disabled_error_for_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
) -> AppError {
|
||||
creation_audio_generation_disabled_error().with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "当前创作音频目标未开放",
|
||||
"entityKind": payload.entity_kind.trim(),
|
||||
"slot": payload.slot.trim(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings};
|
||||
|
||||
pub(super) async fn create_background_music_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = platform_audio::submit_background_music_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
BackgroundMusicTaskRequest {
|
||||
prompt,
|
||||
title,
|
||||
tags,
|
||||
model,
|
||||
instrumental: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
task_id: task.task_id,
|
||||
provider: task.provider,
|
||||
status: task.status,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn create_sound_effect_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
duration: Option<u8>,
|
||||
seed: Option<u64>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = platform_audio::submit_sound_effect_task(
|
||||
&http_client,
|
||||
&settings,
|
||||
SoundEffectTaskRequest {
|
||||
prompt,
|
||||
duration: duration
|
||||
.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
|
||||
.clamp(2, 10),
|
||||
seed,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
|
||||
task_id: task.task_id,
|
||||
provider: task.provider,
|
||||
status: task.status,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use super::{
|
||||
publish::resolve_creation_audio_points_cost,
|
||||
targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target},
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn creation_audio_billing_uses_lower_cost_for_background_music() {
|
||||
let target = AudioAssetBindingTarget {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "puzzle_background_music".to_string(),
|
||||
profile_id: Some("puzzle-profile-1".to_string()),
|
||||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
storage_scope: "puzzle_work".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() {
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "puzzle_background_music".to_string(),
|
||||
profile_id: Some("puzzle-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_work".to_string(),
|
||||
entity_id: "match3d-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "match3d_background_music".to_string(),
|
||||
profile_id: Some("match3d-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_item".to_string(),
|
||||
entity_id: "match3d-item-1".to_string(),
|
||||
slot: "click_sound".to_string(),
|
||||
asset_kind: "match3d_click_sound".to_string(),
|
||||
profile_id: Some("match3d-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "wooden_fish_work".to_string(),
|
||||
entity_id: "wooden-fish-profile-1".to_string(),
|
||||
slot: "hit_sound".to_string(),
|
||||
asset_kind: "wooden_fish_hit_sound".to_string(),
|
||||
profile_id: Some("wooden-fish-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets),
|
||||
};
|
||||
let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
|
||||
.expect_err("wooden fish hit sound target should be disabled");
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use platform_audio::AudioTaskKind;
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
pub(super) const VECTOR_ENGINE_PROVIDER: &str = platform_audio::VECTOR_ENGINE_PROVIDER;
|
||||
pub(super) const AUDIO_ENTITY_KIND: &str = "visual_novel_scene";
|
||||
pub(super) const MUSIC_ASSET_KIND: &str = "visual_novel_music";
|
||||
pub(super) const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound";
|
||||
pub(super) const MUSIC_SLOT: &str = "music";
|
||||
pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound";
|
||||
pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5;
|
||||
pub(super) const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct AudioAssetBindingTarget {
|
||||
pub(super) entity_kind: String,
|
||||
pub(super) entity_id: String,
|
||||
pub(super) slot: String,
|
||||
pub(super) asset_kind: String,
|
||||
pub(super) profile_id: Option<String>,
|
||||
pub(super) storage_prefix: LegacyAssetPrefix,
|
||||
pub(super) storage_scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedCreationAudioTarget {
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
pub profile_id: Option<String>,
|
||||
pub storage_prefix: LegacyAssetPrefix,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum AudioAssetSlot {
|
||||
BackgroundMusic,
|
||||
SoundEffect,
|
||||
}
|
||||
|
||||
impl AudioAssetSlot {
|
||||
pub(super) fn task_kind(self) -> AudioTaskKind {
|
||||
match self {
|
||||
Self::BackgroundMusic => AudioTaskKind::BackgroundMusic,
|
||||
Self::SoundEffect => AudioTaskKind::SoundEffect,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn provider(self) -> &'static str {
|
||||
self.task_kind().provider()
|
||||
}
|
||||
|
||||
pub(super) fn asset_kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => MUSIC_ASSET_KIND,
|
||||
Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn slot(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => MUSIC_SLOT,
|
||||
Self::SoundEffect => AMBIENT_SOUND_SLOT,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_stem(self) -> &'static str {
|
||||
self.task_kind().file_stem()
|
||||
}
|
||||
|
||||
pub(super) fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind {
|
||||
match self {
|
||||
Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,11 +254,7 @@ pub async fn checkpoint_wooden_fish_run(
|
||||
let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.checkpoint_wooden_fish_run(
|
||||
run_id,
|
||||
principal.subject().to_string(),
|
||||
payload,
|
||||
)
|
||||
.checkpoint_wooden_fish_run(run_id, principal.subject().to_string(), payload)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
@@ -285,11 +281,7 @@ pub async fn finish_wooden_fish_run(
|
||||
let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.finish_wooden_fish_run(
|
||||
run_id,
|
||||
principal.subject().to_string(),
|
||||
payload,
|
||||
)
|
||||
.finish_wooden_fish_run(run_id, principal.subject().to_string(), payload)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
@@ -655,8 +647,10 @@ async fn generate_wooden_fish_image_assets(
|
||||
"message": "生成敲木鱼背景环境图失败:上游未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let background_reference_image =
|
||||
downloaded_wooden_fish_reference_image(&background_image, "wooden-fish-generated-background");
|
||||
let background_reference_image = downloaded_wooden_fish_reference_image(
|
||||
&background_image,
|
||||
"wooden-fish-generated-background",
|
||||
);
|
||||
let background_asset = persist_wooden_fish_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -701,10 +695,8 @@ async fn generate_wooden_fish_image_assets(
|
||||
"message": "生成敲木鱼返回按钮图失败:上游未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let back_button_image = prepare_wooden_fish_green_screen_image_for_persist(
|
||||
back_button_image,
|
||||
"敲木鱼返回按钮图",
|
||||
)?;
|
||||
let back_button_image =
|
||||
prepare_wooden_fish_green_screen_image_for_persist(back_button_image, "敲木鱼返回按钮图")?;
|
||||
let back_button_asset = persist_wooden_fish_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -1234,7 +1226,9 @@ mod tests {
|
||||
assert!(prompt.contains("圆形外沿加一圈和主题色搭配的干净外描边"));
|
||||
assert!(prompt.contains("只保留一个清晰、简洁、居中的向左返回箭头"));
|
||||
assert!(prompt.contains("不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案"));
|
||||
assert!(prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具"));
|
||||
assert!(
|
||||
prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具")
|
||||
);
|
||||
assert!(prompt.contains("按钮底色不要使用与绿幕接近的纯绿色"));
|
||||
assert!(prompt.contains("主题为:玉米"));
|
||||
}
|
||||
@@ -1268,11 +1262,7 @@ mod tests {
|
||||
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(
|
||||
decoded.get_pixel(0, 0).0[3],
|
||||
0,
|
||||
"绿幕背景必须在入库前去除"
|
||||
);
|
||||
assert_eq!(decoded.get_pixel(0, 0).0[3], 0, "绿幕背景必须在入库前去除");
|
||||
assert_eq!(decoded.get_pixel(4, 4).0[3], 255);
|
||||
assert_eq!(
|
||||
decoded.get_pixel(6, 6).0[3],
|
||||
|
||||
Reference in New Issue
Block a user