Compare commits

..
Author SHA1 Message Date
Brandon Thomas c238a30776 fix frontend dev scripts 2026-06-10 09:27:28 -04:00
64 changed files with 562 additions and 8296 deletions
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
"hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n mf.id as `media_file_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND mf.id < ?\nORDER BY mf.id DESC\nLIMIT ?\n ",
"query": "\nSELECT\n fmf.id as `membership_id: u64`,\n fmf.created_at as `added_to_folder_at: DateTime<Utc>`,\n\n mf.token as `media_file_token: MediaFileToken`,\n mf.media_class as `media_class: MediaFileClass`,\n mf.media_type as `media_type: MediaFileType`,\n\n mf.maybe_batch_token as `maybe_batch_token: BatchGenerationToken`,\n\n mf.public_bucket_directory_hash,\n mf.maybe_public_bucket_prefix,\n mf.maybe_public_bucket_extension,\n\n cover.public_bucket_directory_hash as `maybe_cover_public_bucket_directory_hash?`,\n cover.maybe_public_bucket_prefix as `maybe_cover_public_bucket_prefix?`,\n cover.maybe_public_bucket_extension as `maybe_cover_public_bucket_extension?`,\n\n mf.creator_set_visibility as `creator_set_visibility: Visibility`,\n mf.is_user_upload as `is_user_upload: bool`,\n mf.is_intermediate_system_file as `is_intermediate_system_file: bool`,\n\n mf.maybe_title,\n mf.maybe_prompt_token as `maybe_prompt_token: PromptToken`,\n mf.maybe_origin_filename,\n\n mf.maybe_duration_millis,\n mf.maybe_frame_width,\n mf.maybe_frame_height,\n\n mf.created_at,\n mf.updated_at\nFROM folder_media_files fmf\nJOIN media_files mf\n ON mf.token = fmf.media_file_token\nLEFT JOIN media_files cover\n ON cover.token = mf.maybe_cover_image_media_file_token\nWHERE fmf.folder_token = ?\n AND mf.user_deleted_at IS NULL\n AND mf.mod_deleted_at IS NULL\n AND fmf.id < ?\nORDER BY fmf.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "media_file_id: u64",
"name": "membership_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
"hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211"
}
Generated
-14
View File
@@ -2377,20 +2377,6 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "comet_api_client"
version = "0.0.1"
dependencies = [
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"test_data",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "compact_str"
version = "0.7.1"
-1
View File
@@ -10,7 +10,6 @@ members = [
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/cloud_storage",
"crates/api_clients/comet_api_client",
"crates/api_clients/datadog_client",
"crates/api_clients/fal_client",
"crates/api_clients/google_drive_downloader",
@@ -64,31 +64,6 @@ pub struct FolderThumbnail {
/// Resizing template URL (with `{{width}}` / `{{height}}` placeholders)
/// when the media class supports it. `None` for media classes without
/// a thumbnail variant. (Video media files instead have video previews,
/// which, in turn, have their own thumbnail templates.)
/// a thumbnail variant.
pub maybe_thumbnail_template: Option<String>,
/// Video preview images (still and animated gif) for mp4 video files.
/// These are only set for video media files.
pub maybe_video_previews: Option<FolderThumbnailVideoPreviews>,
}
/// Video preview images for a video media file referenced by a folder.
/// Mirrors the shape of `MediaLinks`' video previews, but is its own type
/// so the folder wire shape can evolve independently.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct FolderThumbnailVideoPreviews {
/// A static single frame preview image of the video.
pub still: Url,
/// An animated gif preview of the video.
pub animated: Url,
/// A template used to construct the still thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub still_thumbnail_template: String,
/// A template used to construct the animated thumbnail URL.
/// Replace the string `{WIDTH}` with the desired width.
pub animated_thumbnail_template: String,
}
@@ -1,30 +0,0 @@
[package]
name = "comet_api_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "comet_api_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# External
log.workspace = true
# `multipart` is enabled on top of the workspace feature set because the
# CometAPI video creation endpoint takes `multipart/form-data` (the
# `input_reference` image fields are binary parts).
reqwest = { workspace = true, features = ["multipart"] }
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
test_data = { path = "../../testing/test_data" }
tokio = { version = "1", features = ["full"] }
@@ -1,17 +0,0 @@
/// A CometAPI key used for Bearer token authentication.
#[derive(Clone)]
pub struct CometApiKey(pub String);
impl CometApiKey {
pub fn new(api_key: String) -> Self {
Self(api_key.trim().to_string())
}
pub fn from_str(api_key: &str) -> Self {
Self(api_key.trim().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
@@ -1 +0,0 @@
pub mod comet_api_key;
@@ -1,157 +0,0 @@
use reqwest::StatusCode;
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::error::comet_specific_api_error::CometSpecificApiError;
/// Marker CometAPI returns from `GET /v1/videos/{id}` for unknown task ids.
const TASK_NOT_EXIST_MARKER: &str = "task_not_exist";
/// Categorize a non-success response from the video creation endpoint
/// (`POST /v1/videos`).
///
/// Per the docs, 400s from this endpoint mean the request itself was invalid
/// (missing prompt, out-of-range duration, image input on an unsupported
/// model) — the caller's fault, surfaced as [`CometBadRequestApiError`].
pub fn categorize_create_video_error(
status_code: StatusCode,
body: String,
) -> CometError {
if status_code == StatusCode::UNAUTHORIZED {
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
}
if status_code == StatusCode::BAD_REQUEST {
return CometBadRequestApiError::InvalidRequestParameters { raw_body: body }.into();
}
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
/// Categorize a non-success response from the poll endpoint
/// (`GET /v1/videos/{id}`).
///
/// Unlike the creation endpoint, a 400 here means the task id is unknown
/// ("task_not_exist") — a specific, well-known cause rather than a
/// user-facing bad request.
pub fn categorize_get_video_task_error(
status_code: StatusCode,
body: String,
task_id: &str,
) -> CometError {
if status_code == StatusCode::UNAUTHORIZED {
return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into();
}
if status_code == StatusCode::BAD_REQUEST && body.contains(TASK_NOT_EXIST_MARKER) {
return CometSpecificApiError::TaskNotFound {
task_id: task_id.to_string(),
raw_body: body,
}.into();
}
CometGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
#[cfg(test)]
mod tests {
use super::*;
mod create_video_tests {
use super::*;
#[test]
fn bad_request_is_caller_fault() {
let body = r#"{"error":{"message":"seconds must be between 4 and 15","type":"invalid_request_error"}}"#.to_string();
let error = categorize_create_video_error(StatusCode::BAD_REQUEST, body.clone());
match error {
CometError::ApiBadRequest(CometBadRequestApiError::InvalidRequestParameters { raw_body }) => {
assert_eq!(raw_body, body);
}
other => panic!("Expected InvalidRequestParameters, got: {:?}", other),
}
}
#[test]
fn unauthorized_is_specific() {
let body = r#"{"error":{"message":"invalid token","type":"cometapi_error"}}"#.to_string();
let error = categorize_create_video_error(StatusCode::UNAUTHORIZED, body);
match error {
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body }) => {
assert!(raw_body.contains("invalid token"));
}
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
}
}
#[test]
fn server_errors_fall_back_to_uncategorized() {
let error = categorize_create_video_error(
StatusCode::BAD_GATEWAY,
"upstream timeout".to_string(),
);
match &error {
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, .. }) => {
assert_eq!(*status_code, StatusCode::BAD_GATEWAY);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
assert!(error.is_having_downtime_issues());
}
}
mod get_video_task_tests {
use super::*;
#[test]
fn task_not_exist_is_task_not_found() {
let body = r#"{"error":{"message":"task_not_exist","type":"cometapi_error"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
match error {
CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, raw_body }) => {
assert_eq!(task_id, "task_abc123");
assert!(raw_body.contains("task_not_exist"));
}
other => panic!("Expected TaskNotFound, got: {:?}", other),
}
}
#[test]
fn other_bad_requests_fall_back_to_uncategorized() {
let body = r#"{"error":{"message":"something else"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123");
match error {
CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn unauthorized_is_specific() {
let body = r#"{"error":{"message":"invalid token"}}"#.to_string();
let error = categorize_get_video_task_error(StatusCode::UNAUTHORIZED, body, "task_abc123");
match error {
CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { .. }) => {}
other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other),
}
}
}
}
@@ -1,22 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// These represent known "400s" that are the fault of the client sending a
/// bad request. They should surface to the end user as 400s.
#[derive(Debug)]
pub enum CometBadRequestApiError {
/// The video creation request was rejected for invalid parameters (eg.
/// missing prompt, out-of-range duration, an `input_reference` image on a
/// model that doesn't support image input).
InvalidRequestParameters { raw_body: String },
}
impl Error for CometBadRequestApiError {}
impl Display for CometBadRequestApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequestParameters { raw_body } => write!(f, "Invalid request parameters: {}", raw_body),
}
}
}
@@ -1,26 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum CometClientError {
/// An error was encountered building the reqwest client or the request.
ReqwestClientError(reqwest::Error),
/// A request field couldn't be validated or converted to the value
/// expected on the wire. Carries the field name and the raw input the
/// caller supplied.
InvalidRequestField { field: &'static str, raw_value: String, reason: String },
}
impl Error for CometClientError {}
impl Display for CometClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err),
Self::InvalidRequestField { field, raw_value, reason } => {
write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason)
}
}
}
}
@@ -1,71 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use crate::error::comet_bad_request_api_error::CometBadRequestApiError;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::error::comet_specific_api_error::CometSpecificApiError;
#[derive(Debug)]
pub enum CometError {
/// An error that happened client-side (before or while making the request).
Client(CometClientError),
/// Known "400s" that are the fault of the client sending a bad request.
/// These should surface to the end user as 400s.
ApiBadRequest(CometBadRequestApiError),
/// Server-side errors with specific, well-known causes.
ApiSpecific(CometSpecificApiError),
/// Server-side errors with unknown / grab-bag causes.
ApiGeneric(CometGenericApiError),
}
impl CometError {
pub fn is_having_downtime_issues(&self) -> bool {
match self {
Self::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => {
matches!(status_code.as_u16(), 502 | 503 | 504 | 524)
},
_ => false,
}
}
}
impl Error for CometError {}
impl Display for CometError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "CometClientError: {:?}", e),
Self::ApiBadRequest(e) => write!(f, "CometBadRequestApiError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "CometSpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "CometGenericApiError: {:?}", e),
}
}
}
impl From<CometClientError> for CometError {
fn from(error: CometClientError) -> Self {
Self::Client(error)
}
}
impl From<CometBadRequestApiError> for CometError {
fn from(error: CometBadRequestApiError) -> Self {
Self::ApiBadRequest(error)
}
}
impl From<CometSpecificApiError> for CometError {
fn from(error: CometSpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<CometGenericApiError> for CometError {
fn from(error: CometGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -1,33 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use reqwest::StatusCode;
/// Server-side errors with unknown / grab-bag causes.
#[derive(Debug)]
pub enum CometGenericApiError {
/// serde_json::Error, likely from a JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client (network failures, timeouts, etc.)
ReqwestError(reqwest::Error),
}
impl Error for CometGenericApiError {}
impl Display for CometGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::ReqwestError(err) => write!(f, "Reqwest error: {}", err),
}
}
}
@@ -1,23 +0,0 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
/// Server-side errors with specific, well-known causes.
#[derive(Debug)]
pub enum CometSpecificApiError {
/// 401: the API key is missing or invalid.
UnauthorizedInvalidApiKey { raw_body: String },
/// The poll endpoint responded "task_not_exist" for the given task id.
TaskNotFound { task_id: String, raw_body: String },
}
impl Error for CometSpecificApiError {}
impl Display for CometSpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnauthorizedInvalidApiKey { raw_body } => write!(f, "Unauthorized: invalid or missing API key: {}", raw_body),
Self::TaskNotFound { task_id, raw_body } => write!(f, "Task not found: {} ({})", task_id, raw_body),
}
}
}
@@ -1,6 +0,0 @@
pub mod categorize_comet_error;
pub mod comet_bad_request_api_error;
pub mod comet_client_error;
pub mod comet_error;
pub mod comet_generic_api_error;
pub mod comet_specific_api_error;
@@ -1 +0,0 @@
pub mod video;
@@ -1,146 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometVideoModelRaw, CometVideoSize, CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 1.0 Pro supports 2-10 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 10;
// ── Args ──
pub struct GenerateDoubaoSeedance1p0ProArgs<'a> {
pub request: GenerateDoubaoSeedance1p0ProRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
/// NB: Seedance 1.0 Pro is text-only — `input_reference` images are not
/// supported, so this request deliberately has no image field.
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance1p0ProRequest {
pub prompt: String,
/// 2-10 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<DoubaoSeedance1p0ProSize>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance1p0ProSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
Exact { width: u32, height: u32 },
}
// ── Response ──
pub struct GenerateDoubaoSeedance1p0ProResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_1p0_pro(
args: GenerateDoubaoSeedance1p0ProArgs<'_>,
) -> Result<GenerateDoubaoSeedance1p0ProResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance1p0ProResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance1p0ProRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 1.0 Pro supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance1p0Pro,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: vec![],
})
}
}
fn map_size(size: DoubaoSeedance1p0ProSize) -> CometVideoSize {
match size {
DoubaoSeedance1p0ProSize::Landscape16x9 => CometVideoSize::Landscape16x9,
DoubaoSeedance1p0ProSize::Standard4x3 => CometVideoSize::Standard4x3,
DoubaoSeedance1p0ProSize::Square1x1 => CometVideoSize::Square,
DoubaoSeedance1p0ProSize::Portrait3x4 => CometVideoSize::Portrait3x4,
DoubaoSeedance1p0ProSize::Portrait9x16 => CometVideoSize::Portrait9x16,
DoubaoSeedance1p0ProSize::UltraWide21x9 => CometVideoSize::UltraWide21x9,
DoubaoSeedance1p0ProSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance1p0ProRequest {
prompt: "time lapse of a city at dusk".to_string(),
duration_seconds: Some(2),
size: Some(DoubaoSeedance1p0ProSize::Square1x1),
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-1-0-pro".to_string()),
("prompt", "time lapse of a city at dusk".to_string()),
("seconds", "2".to_string()),
("size", "1:1".to_string()),
]);
assert!(raw.input_reference_images.is_empty());
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(10)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(11)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateDoubaoSeedance1p0ProRequest {
GenerateDoubaoSeedance1p0ProRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
}
}
}
@@ -1,146 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometVideoModelRaw, CometVideoSize, CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 1.5 Pro supports 4-12 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 12;
// ── Args ──
pub struct GenerateDoubaoSeedance1p5ProArgs<'a> {
pub request: GenerateDoubaoSeedance1p5ProRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
/// NB: Seedance 1.5 Pro is text-only — `input_reference` images are not
/// supported, so this request deliberately has no image field.
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance1p5ProRequest {
pub prompt: String,
/// 4-12 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<DoubaoSeedance1p5ProSize>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance1p5ProSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
Exact { width: u32, height: u32 },
}
// ── Response ──
pub struct GenerateDoubaoSeedance1p5ProResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_1p5_pro(
args: GenerateDoubaoSeedance1p5ProArgs<'_>,
) -> Result<GenerateDoubaoSeedance1p5ProResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance1p5ProResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance1p5ProRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 1.5 Pro supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance1p5Pro,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: vec![],
})
}
}
fn map_size(size: DoubaoSeedance1p5ProSize) -> CometVideoSize {
match size {
DoubaoSeedance1p5ProSize::Landscape16x9 => CometVideoSize::Landscape16x9,
DoubaoSeedance1p5ProSize::Standard4x3 => CometVideoSize::Standard4x3,
DoubaoSeedance1p5ProSize::Square1x1 => CometVideoSize::Square,
DoubaoSeedance1p5ProSize::Portrait3x4 => CometVideoSize::Portrait3x4,
DoubaoSeedance1p5ProSize::Portrait9x16 => CometVideoSize::Portrait9x16,
DoubaoSeedance1p5ProSize::UltraWide21x9 => CometVideoSize::UltraWide21x9,
DoubaoSeedance1p5ProSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance1p5ProRequest {
prompt: "a paper boat in a storm drain".to_string(),
duration_seconds: Some(12),
size: Some(DoubaoSeedance1p5ProSize::Portrait9x16),
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-1-5-pro".to_string()),
("prompt", "a paper boat in a storm drain".to_string()),
("seconds", "12".to_string()),
("size", "9:16".to_string()),
]);
assert!(raw.input_reference_images.is_empty());
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(3)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(4)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(12)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(13)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateDoubaoSeedance1p5ProRequest {
GenerateDoubaoSeedance1p5ProRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
}
}
}
@@ -1,471 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 2.0 supports 4-15 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateDoubaoSeedance2p0Args<'a> {
pub request: GenerateDoubaoSeedance2p0Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance2p0Request {
/// Reference attached images in the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// 4-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` defaults to 16:9 (when a resolution is set).
pub aspect_ratio: Option<DoubaoSeedance2p0AspectRatio>,
/// `None` defaults to 720p (when an aspect ratio is set).
pub resolution: Option<DoubaoSeedance2p0Resolution>,
/// Reference images (JPEG, PNG, WebP) for image-to-video.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0AspectRatio {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
}
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0Resolution {
FourEightyP,
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Seedance 2.0 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|--------|
// | 480p | 0.063 |
// | 720p | 0.1368 |
// | 1080p | 0.3366 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateDoubaoSeedance2p0Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let dollars_per_second: f64 = match self.resolution {
Some(DoubaoSeedance2p0Resolution::FourEightyP) => 0.063,
// Default resolution (None) is 720p.
Some(DoubaoSeedance2p0Resolution::SevenTwentyP) | None => 0.1368,
Some(DoubaoSeedance2p0Resolution::TenEightyP) => 0.3366,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
(f64::from(seconds) * dollars_per_second * 100.0).round() as u64
}
}
// ── Response ──
pub struct GenerateDoubaoSeedance2p0Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_2p0(
args: GenerateDoubaoSeedance2p0Args<'_>,
) -> Result<GenerateDoubaoSeedance2p0Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance2p0Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance2p0Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 2.0 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.resolve_size(),
input_reference_images: self.input_reference_images.clone(),
})
}
/// The wire `size` is a single exact-dimensions field determined by the
/// (aspect ratio, resolution) pair. Dimensions mirror CometAPI's own
/// playground options for `doubao-seedance-2-0`. When neither knob is
/// set, `size` is omitted and the API picks its default.
fn resolve_size(&self) -> Option<CometVideoSize> {
if self.aspect_ratio.is_none() && self.resolution.is_none() {
return None;
}
let aspect_ratio = self.aspect_ratio.unwrap_or(DoubaoSeedance2p0AspectRatio::Landscape16x9);
let resolution = self.resolution.unwrap_or(DoubaoSeedance2p0Resolution::SevenTwentyP);
use DoubaoSeedance2p0AspectRatio as Ratio;
use DoubaoSeedance2p0Resolution as Res;
let (width, height) = match (resolution, aspect_ratio) {
(Res::FourEightyP, Ratio::Landscape16x9) => (864, 496),
(Res::FourEightyP, Ratio::Standard4x3) => (752, 560),
(Res::FourEightyP, Ratio::Square1x1) => (640, 640),
(Res::FourEightyP, Ratio::Portrait3x4) => (560, 752),
(Res::FourEightyP, Ratio::Portrait9x16) => (496, 864),
(Res::FourEightyP, Ratio::UltraWide21x9) => (992, 432),
(Res::SevenTwentyP, Ratio::Landscape16x9) => (1280, 720),
(Res::SevenTwentyP, Ratio::Standard4x3) => (1112, 834),
(Res::SevenTwentyP, Ratio::Square1x1) => (960, 960),
(Res::SevenTwentyP, Ratio::Portrait3x4) => (834, 1112),
(Res::SevenTwentyP, Ratio::Portrait9x16) => (720, 1280),
(Res::SevenTwentyP, Ratio::UltraWide21x9) => (1470, 630),
(Res::TenEightyP, Ratio::Landscape16x9) => (1920, 1080),
(Res::TenEightyP, Ratio::Standard4x3) => (1664, 1248),
(Res::TenEightyP, Ratio::Square1x1) => (1440, 1440),
(Res::TenEightyP, Ratio::Portrait3x4) => (1248, 1664),
(Res::TenEightyP, Ratio::Portrait9x16) => (1080, 1920),
(Res::TenEightyP, Ratio::UltraWide21x9) => (2206, 946),
};
Some(CometVideoSize::Exact { width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
use DoubaoSeedance2p0AspectRatio as Ratio;
use DoubaoSeedance2p0Resolution as Res;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance2p0Request {
prompt: "animate [Image 1]".to_string(),
duration_seconds: Some(8),
aspect_ratio: Some(Ratio::UltraWide21x9),
resolution: Some(Res::TenEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-2-0".to_string()),
("prompt", "animate [Image 1]".to_string()),
("seconds", "8".to_string()),
("size", "2206x946".to_string()),
]);
}
#[test]
fn size_matrix_matches_cometapi_playground() {
// (resolution, ratio) -> exact size, as offered by CometAPI's own
// playground dropdown for doubao-seedance-2-0.
let cases = [
(Res::FourEightyP, Ratio::Landscape16x9, "864x496"),
(Res::FourEightyP, Ratio::Standard4x3, "752x560"),
(Res::FourEightyP, Ratio::Square1x1, "640x640"),
(Res::FourEightyP, Ratio::Portrait3x4, "560x752"),
(Res::FourEightyP, Ratio::Portrait9x16, "496x864"),
(Res::FourEightyP, Ratio::UltraWide21x9, "992x432"),
(Res::SevenTwentyP, Ratio::Landscape16x9, "1280x720"),
(Res::SevenTwentyP, Ratio::Standard4x3, "1112x834"),
(Res::SevenTwentyP, Ratio::Square1x1, "960x960"),
(Res::SevenTwentyP, Ratio::Portrait3x4, "834x1112"),
(Res::SevenTwentyP, Ratio::Portrait9x16, "720x1280"),
(Res::SevenTwentyP, Ratio::UltraWide21x9, "1470x630"),
(Res::TenEightyP, Ratio::Landscape16x9, "1920x1080"),
(Res::TenEightyP, Ratio::Standard4x3, "1664x1248"),
(Res::TenEightyP, Ratio::Square1x1, "1440x1440"),
(Res::TenEightyP, Ratio::Portrait3x4, "1248x1664"),
(Res::TenEightyP, Ratio::Portrait9x16, "1080x1920"),
(Res::TenEightyP, Ratio::UltraWide21x9, "2206x946"),
];
for (resolution, aspect_ratio, expected) in cases {
let request = base_request(Some(aspect_ratio), Some(resolution));
let size = request.resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), expected,
"({resolution:?}, {aspect_ratio:?}) should map to {expected}");
}
}
#[test]
fn size_defaults() {
// Neither knob set: omit size, let the API default.
assert!(base_request(None, None).resolve_size().is_none());
// Only ratio set: resolution defaults to 720p.
let size = base_request(Some(Ratio::Portrait9x16), None).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "720x1280");
// Only resolution set: ratio defaults to 16:9.
let size = base_request(None, Some(Res::TenEightyP)).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "1920x1080");
}
#[test]
fn duration_bounds() {
let mut request = base_request(None, None);
for (seconds, ok) in [(Some(3), false), (Some(4), true), (Some(15), true), (Some(16), false), (None, true)] {
request.duration_seconds = seconds;
assert_eq!(request.to_create_video_request().is_ok(), ok, "seconds: {seconds:?}");
}
}
#[test]
fn cost_estimates() {
// 5 seconds at 480p: 5 × $0.063 = $0.315 -> 32¢
let mut request = base_request(None, Some(Res::FourEightyP));
request.duration_seconds = Some(5);
assert_eq!(request.estimate_cost_in_usd_cents(), 32);
// 10 seconds at 1080p: 10 × $0.3366 = $3.366 -> 337¢
request.resolution = Some(Res::TenEightyP);
request.duration_seconds = Some(10);
assert_eq!(request.estimate_cost_in_usd_cents(), 337);
// Defaults (5 seconds, 720p): 5 × $0.1368 = $0.684 -> 68¢
request.resolution = None;
request.duration_seconds = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 68);
}
fn base_request(
aspect_ratio: Option<DoubaoSeedance2p0AspectRatio>,
resolution: Option<DoubaoSeedance2p0Resolution>,
) -> GenerateDoubaoSeedance2p0Request {
GenerateDoubaoSeedance2p0Request {
prompt: "ok".to_string(),
duration_seconds: None,
aspect_ratio,
resolution,
input_reference_images: vec![],
}
}
// ─────────────────────────────────────────────────────────────────────
// ⚠️ EXPENSIVE LIVE TESTS ⚠️
//
// Each test below enqueues a REAL 10-second Seedance 2.0 generation with
// four reference images and polls it to completion. These INCUR REAL
// COSTS (per CometAPI's per-second pricing):
//
// 480p: 10s × $0.063 ≈ $0.63
// 720p: 10s × $0.1368 ≈ $1.37
// 1080p: 10s × $0.3366 ≈ $3.37
//
// They also take a while — generations have been observed to run well
// past 10 minutes. Run one at a time, manually:
//
// cargo test -p comet_api_client live_generate_480p -- --ignored --nocapture
// ─────────────────────────────────────────────────────────────────────
mod live_tests {
use std::time::Duration;
use test_data::web::image_urls::{
ERNEST_GHOST_TREX_IMAGE_URL,
FOREST_BACKDROP_IMAGE_URL,
GHOST_IMAGE_URL,
GRASSY_HILL_TRANSPARENT_IMAGE_URL,
JUNO_AT_LAKE_IMAGE_URL,
MOUNTAIN_TREE_IMAGE_URL,
SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL,
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL,
TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
TREX_SKELETON_IMAGE_URL,
WHITE_HOUSE_SUNSET_IMAGE_URL,
WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL,
};
use super::*;
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
use crate::test_utils::load_api_key;
/// ⚠️ EXPENSIVE: real 10s 480p generation (~$0.63) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_480p_with_references() {
let input_reference_images = download_reference_images(&[
ERNEST_GHOST_TREX_IMAGE_URL,
GHOST_IMAGE_URL,
TREX_SKELETON_IMAGE_URL,
FOREST_BACKDROP_IMAGE_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "The frightened man from [Image 1] runs through the dark forest \
from [Image 4], chased by the glowing ghost from [Image 2]. The t-rex \
skeleton from [Image 3] rises out of the leaves behind them. Spooky, \
comedic 90s horror movie tone."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::FourEightyP),
input_reference_images,
}).await;
}
/// ⚠️ EXPENSIVE: real 10s 720p generation (~$1.37) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_720p_with_references() {
let input_reference_images = download_reference_images(&[
JUNO_AT_LAKE_IMAGE_URL,
TALL_MOCHI_WITH_GLASSES_IMAGE_URL,
TALL_CORGI_SHIBA_TREASURE_OCEAN_URL,
WIDE_CORGI_SHIBA_TREASURE_OCEAN_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "The dog from [Image 1] and the bespectacled dog from [Image 2] \
join the corgi and shiba from [Image 3] on the beach, digging up the \
treasure chest from [Image 4] as waves roll in. Joyful, sunny \
adventure-film tone."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::SevenTwentyP),
input_reference_images,
}).await;
}
/// ⚠️ EXPENSIVE: real 10s 1080p generation (~$3.37) + a long poll.
#[ignore]
#[tokio::test]
async fn live_generate_1080p_with_references() {
let input_reference_images = download_reference_images(&[
GRASSY_HILL_TRANSPARENT_IMAGE_URL,
MOUNTAIN_TREE_IMAGE_URL,
SUPER_WIDE_FALL_MOUNTAINS_IMAGE_URL,
WHITE_HOUSE_SUNSET_IMAGE_URL,
]).await;
run_generation_to_completion(GenerateDoubaoSeedance2p0Request {
prompt: "A cinematic drone shot glides over the grassy hill from \
[Image 1], past the lone tree on the mountainside from [Image 2], \
across the sweeping autumn mountain range from [Image 3], and settles \
on the white house at sunset from [Image 4]. Golden hour, slow and \
steady camera movement."
.to_string(),
duration_seconds: Some(10),
aspect_ratio: Some(DoubaoSeedance2p0AspectRatio::Landscape16x9),
resolution: Some(DoubaoSeedance2p0Resolution::TenEightyP),
input_reference_images,
}).await;
}
// ── Helpers ──
async fn download_reference_images(urls: &[&str]) -> Vec<CometInputReferenceImage> {
let mut images = Vec::with_capacity(urls.len());
for url in urls {
images.push(download_reference_image(url).await);
}
images
}
async fn download_reference_image(url: &str) -> CometInputReferenceImage {
let response = reqwest::get(url).await.expect("image download should succeed");
assert!(response.status().is_success(), "image download failed: {} ({})", url, response.status());
let filename = url.rsplit('/').next().expect("url should have a path").to_string();
let maybe_content_type = if filename.ends_with(".png") {
Some("image/png".to_string())
} else {
Some("image/jpeg".to_string())
};
let file_bytes = response.bytes().await.expect("image body should download").to_vec();
println!("Downloaded reference: {} ({} bytes)", filename, file_bytes.len());
CometInputReferenceImage {
file_bytes,
filename,
maybe_content_type,
}
}
/// Enqueue the generation, poll until terminal, and assert success.
async fn run_generation_to_completion(request: GenerateDoubaoSeedance2p0Request) {
// NB: Generations have been observed to run well past 10 minutes.
const POLL_INTERVAL: Duration = Duration::from_secs(15);
const MAX_POLLS: u32 = 120; // ~30 minutes
let api_key = load_api_key();
println!("Estimated cost: {}¢", request.estimate_cost_in_usd_cents());
let created = generate_doubao_seedance_2p0(GenerateDoubaoSeedance2p0Args {
request,
api_key: &api_key,
}).await.expect("generate_doubao_seedance_2p0 should succeed");
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
for poll in 1..=MAX_POLLS {
tokio::time::sleep(POLL_INTERVAL).await;
let task = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: &created.task_id,
}).await.expect("get_video_task should succeed");
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
if task.status.is_terminal() {
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
let video_url = task.maybe_video_url.expect("completed task should have a video url");
println!("Video URL: {}", video_url);
return;
}
}
panic!("Task {} did not reach a terminal state in time", created.task_id);
}
}
}
@@ -1,278 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Seedance 2.0 Fast supports 4-15 second durations.
const MIN_DURATION_SECONDS: u8 = 4;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateDoubaoSeedance2p0FastArgs<'a> {
pub request: GenerateDoubaoSeedance2p0FastRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateDoubaoSeedance2p0FastRequest {
/// Reference attached images in the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// 4-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` defaults to 16:9 (when a resolution is set).
pub aspect_ratio: Option<DoubaoSeedance2p0FastAspectRatio>,
/// `None` defaults to 720p (when an aspect ratio is set).
pub resolution: Option<DoubaoSeedance2p0FastResolution>,
/// Reference images (JPEG, PNG, WebP) for image-to-video.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0FastAspectRatio {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
UltraWide21x9,
}
/// NB: 1080p is available for all Seedance models EXCEPT 2.0 Fast, so this
/// enum deliberately has no 1080p variant.
#[derive(Debug, Clone, Copy)]
pub enum DoubaoSeedance2p0FastResolution {
FourEightyP,
SevenTwentyP,
}
// ── Pricing ──
//
// Seedance 2.0 Fast per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|--------|
// | 480p | 0.0504 |
// | 720p | 0.108 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateDoubaoSeedance2p0FastRequest {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let dollars_per_second: f64 = match self.resolution {
Some(DoubaoSeedance2p0FastResolution::FourEightyP) => 0.0504,
// Default resolution (None) is 720p.
Some(DoubaoSeedance2p0FastResolution::SevenTwentyP) | None => 0.108,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
(f64::from(seconds) * dollars_per_second * 100.0).round() as u64
}
}
// ── Response ──
pub struct GenerateDoubaoSeedance2p0FastResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_doubao_seedance_2p0_fast(
args: GenerateDoubaoSeedance2p0FastArgs<'_>,
) -> Result<GenerateDoubaoSeedance2p0FastResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateDoubaoSeedance2p0FastResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateDoubaoSeedance2p0FastRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Seedance 2.0 Fast supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.resolve_size(),
input_reference_images: self.input_reference_images.clone(),
})
}
/// The wire `size` is a single exact-dimensions field determined by the
/// (aspect ratio, resolution) pair. Dimensions mirror CometAPI's own
/// playground options for the Seedance 2.0 family. When neither knob is
/// set, `size` is omitted and the API picks its default.
fn resolve_size(&self) -> Option<CometVideoSize> {
if self.aspect_ratio.is_none() && self.resolution.is_none() {
return None;
}
let aspect_ratio = self.aspect_ratio.unwrap_or(DoubaoSeedance2p0FastAspectRatio::Landscape16x9);
let resolution = self.resolution.unwrap_or(DoubaoSeedance2p0FastResolution::SevenTwentyP);
use DoubaoSeedance2p0FastAspectRatio as Ratio;
use DoubaoSeedance2p0FastResolution as Res;
let (width, height) = match (resolution, aspect_ratio) {
(Res::FourEightyP, Ratio::Landscape16x9) => (864, 496),
(Res::FourEightyP, Ratio::Standard4x3) => (752, 560),
(Res::FourEightyP, Ratio::Square1x1) => (640, 640),
(Res::FourEightyP, Ratio::Portrait3x4) => (560, 752),
(Res::FourEightyP, Ratio::Portrait9x16) => (496, 864),
(Res::FourEightyP, Ratio::UltraWide21x9) => (992, 432),
(Res::SevenTwentyP, Ratio::Landscape16x9) => (1280, 720),
(Res::SevenTwentyP, Ratio::Standard4x3) => (1112, 834),
(Res::SevenTwentyP, Ratio::Square1x1) => (960, 960),
(Res::SevenTwentyP, Ratio::Portrait3x4) => (834, 1112),
(Res::SevenTwentyP, Ratio::Portrait9x16) => (720, 1280),
(Res::SevenTwentyP, Ratio::UltraWide21x9) => (1470, 630),
};
Some(CometVideoSize::Exact { width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
use DoubaoSeedance2p0FastAspectRatio as Ratio;
use DoubaoSeedance2p0FastResolution as Res;
#[test]
fn maps_to_wire_request() {
let request = GenerateDoubaoSeedance2p0FastRequest {
prompt: "a corgi surfing".to_string(),
duration_seconds: Some(4),
aspect_ratio: Some(Ratio::Landscape16x9),
resolution: Some(Res::FourEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "doubao-seedance-2-0-fast".to_string()),
("prompt", "a corgi surfing".to_string()),
("seconds", "4".to_string()),
("size", "864x496".to_string()),
]);
}
#[test]
fn size_matrix_matches_cometapi_playground() {
// (resolution, ratio) -> exact size, as offered by CometAPI's own
// playground dropdown for the Seedance 2.0 family. No 1080p row: Fast
// doesn't support it (enforced by the resolution enum).
let cases = [
(Res::FourEightyP, Ratio::Landscape16x9, "864x496"),
(Res::FourEightyP, Ratio::Standard4x3, "752x560"),
(Res::FourEightyP, Ratio::Square1x1, "640x640"),
(Res::FourEightyP, Ratio::Portrait3x4, "560x752"),
(Res::FourEightyP, Ratio::Portrait9x16, "496x864"),
(Res::FourEightyP, Ratio::UltraWide21x9, "992x432"),
(Res::SevenTwentyP, Ratio::Landscape16x9, "1280x720"),
(Res::SevenTwentyP, Ratio::Standard4x3, "1112x834"),
(Res::SevenTwentyP, Ratio::Square1x1, "960x960"),
(Res::SevenTwentyP, Ratio::Portrait3x4, "834x1112"),
(Res::SevenTwentyP, Ratio::Portrait9x16, "720x1280"),
(Res::SevenTwentyP, Ratio::UltraWide21x9, "1470x630"),
];
for (resolution, aspect_ratio, expected) in cases {
let request = base_request(Some(aspect_ratio), Some(resolution));
let size = request.resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), expected,
"({resolution:?}, {aspect_ratio:?}) should map to {expected}");
}
}
#[test]
fn size_defaults() {
// Neither knob set: omit size, let the API default.
assert!(base_request(None, None).resolve_size().is_none());
// Only ratio set: resolution defaults to 720p.
let size = base_request(Some(Ratio::Square1x1), None).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "960x960");
// Only resolution set: ratio defaults to 16:9.
let size = base_request(None, Some(Res::FourEightyP)).resolve_size().expect("should resolve");
assert_eq!(size.as_api_string(), "864x496");
}
#[test]
fn duration_bounds() {
let mut request = base_request(None, None);
for (seconds, ok) in [(Some(3), false), (Some(4), true), (Some(15), true), (Some(16), false), (None, true)] {
request.duration_seconds = seconds;
assert_eq!(request.to_create_video_request().is_ok(), ok, "seconds: {seconds:?}");
}
}
#[test]
fn cost_estimates() {
// 5 seconds at 480p: 5 × $0.0504 = $0.252 -> 25¢
let mut request = base_request(None, Some(Res::FourEightyP));
request.duration_seconds = Some(5);
assert_eq!(request.estimate_cost_in_usd_cents(), 25);
// 15 seconds at 720p: 15 × $0.108 = $1.62 -> 162¢
request.resolution = Some(Res::SevenTwentyP);
request.duration_seconds = Some(15);
assert_eq!(request.estimate_cost_in_usd_cents(), 162);
// Defaults (5 seconds, 720p): 5 × $0.108 = $0.54 -> 54¢
request.resolution = None;
request.duration_seconds = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 54);
}
fn base_request(
aspect_ratio: Option<DoubaoSeedance2p0FastAspectRatio>,
resolution: Option<DoubaoSeedance2p0FastResolution>,
) -> GenerateDoubaoSeedance2p0FastRequest {
GenerateDoubaoSeedance2p0FastRequest {
prompt: "ok".to_string(),
duration_seconds: None,
aspect_ratio,
resolution,
input_reference_images: vec![],
}
}
}
@@ -1,162 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Vidu Q3 supports 1-16 second durations.
const MIN_DURATION_SECONDS: u8 = 1;
const MAX_DURATION_SECONDS: u8 = 16;
// ── Args ──
pub struct GenerateViduQ3Args<'a> {
pub request: GenerateViduQ3Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateViduQ3Request {
pub prompt: String,
/// 1-16 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<ViduQ3Size>,
/// Reference images for image-to-video / start-end-to-video workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Vidu Q3 renders at 540p/720p/1080p (24 FPS). The API's `size` field takes
/// an aspect ratio preset or exact dimensions (eg. 960x528).
#[derive(Debug, Clone, Copy)]
pub enum ViduQ3Size {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
Exact { width: u32, height: u32 },
}
// ── Pricing ──
//
// Vidu Q3 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |-------------|---------|
// | 360p/540p | 0.056 |
// | 720p/1080p | 0.1232 |
//
// Total cost = price per second × video duration (seconds).
// NB: Not modeled as an estimator because the `size` field is an aspect
// ratio in the common case, which doesn't determine the billed resolution.
// ── Response ──
pub struct GenerateViduQ3Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_vidu_q3(
args: GenerateViduQ3Args<'_>,
) -> Result<GenerateViduQ3Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateViduQ3Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateViduQ3Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Vidu Q3 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::ViduQ3,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: ViduQ3Size) -> CometVideoSize {
match size {
ViduQ3Size::Landscape16x9 => CometVideoSize::Landscape16x9,
ViduQ3Size::Standard4x3 => CometVideoSize::Standard4x3,
ViduQ3Size::Square1x1 => CometVideoSize::Square,
ViduQ3Size::Portrait3x4 => CometVideoSize::Portrait3x4,
ViduQ3Size::Portrait9x16 => CometVideoSize::Portrait9x16,
ViduQ3Size::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateViduQ3Request {
prompt: "a lighthouse in fog".to_string(),
duration_seconds: Some(16),
size: Some(ViduQ3Size::Exact { width: 960, height: 528 }),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "viduq3".to_string()),
("prompt", "a lighthouse in fog".to_string()),
("seconds", "16".to_string()),
("size", "960x528".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(0)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(1)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(17)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateViduQ3Request {
GenerateViduQ3Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,162 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Vidu Q3 Turbo supports 1-16 second durations.
const MIN_DURATION_SECONDS: u8 = 1;
const MAX_DURATION_SECONDS: u8 = 16;
// ── Args ──
pub struct GenerateViduQ3TurboArgs<'a> {
pub request: GenerateViduQ3TurboRequest,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateViduQ3TurboRequest {
pub prompt: String,
/// 1-16 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
pub size: Option<ViduQ3TurboSize>,
/// Reference images for image-to-video / start-end-to-video workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Vidu Q3 Turbo renders at 540p/720p/1080p (24 FPS). The API's `size` field
/// takes an aspect ratio preset or exact dimensions (eg. 960x528).
#[derive(Debug, Clone, Copy)]
pub enum ViduQ3TurboSize {
Landscape16x9,
Standard4x3,
Square1x1,
Portrait3x4,
Portrait9x16,
Exact { width: u32, height: u32 },
}
// ── Pricing ──
//
// Vidu Q3 Turbo per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |-------------|---------|
// | 360p/540p | 0.028 |
// | 720p/1080p | 0.0616 |
//
// Total cost = price per second × video duration (seconds).
// NB: Not modeled as an estimator because the `size` field is an aspect
// ratio in the common case, which doesn't determine the billed resolution.
// ── Response ──
pub struct GenerateViduQ3TurboResponse {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_vidu_q3_turbo(
args: GenerateViduQ3TurboArgs<'_>,
) -> Result<GenerateViduQ3TurboResponse, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateViduQ3TurboResponse {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateViduQ3TurboRequest {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Vidu Q3 Turbo supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::ViduQ3Turbo,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: ViduQ3TurboSize) -> CometVideoSize {
match size {
ViduQ3TurboSize::Landscape16x9 => CometVideoSize::Landscape16x9,
ViduQ3TurboSize::Standard4x3 => CometVideoSize::Standard4x3,
ViduQ3TurboSize::Square1x1 => CometVideoSize::Square,
ViduQ3TurboSize::Portrait3x4 => CometVideoSize::Portrait3x4,
ViduQ3TurboSize::Portrait9x16 => CometVideoSize::Portrait9x16,
ViduQ3TurboSize::Exact { width, height } => CometVideoSize::Exact { width, height },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateViduQ3TurboRequest {
prompt: "raindrops on a window".to_string(),
duration_seconds: Some(1),
size: Some(ViduQ3TurboSize::Portrait9x16),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "viduq3-turbo".to_string()),
("prompt", "raindrops on a window".to_string()),
("seconds", "1".to_string()),
("size", "9:16".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(0)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(1)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(17)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateViduQ3TurboRequest {
GenerateViduQ3TurboRequest {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,216 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Wan 2.6 supports 2-15 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
/// Wan 2.6 accepts up to 5 references.
const MAX_REFERENCE_IMAGES: usize = 5;
// ── Args ──
pub struct GenerateWan2p6Args<'a> {
pub request: GenerateWan2p6Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateWan2p6Request {
pub prompt: String,
/// 2-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` uses the API default (720p).
pub size: Option<Wan2p6Size>,
/// Reference images for image-to-video and reference-to-video workflows.
/// Wan 2.6 accepts up to 5 references.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Wan 2.6 renders at 720p or 1080p.
#[derive(Debug, Clone, Copy)]
pub enum Wan2p6Size {
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Wan 2.6 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|-------|
// | 720p | 0.08 |
// | 1080p | 0.12 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateWan2p6Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let cents_per_second: u64 = match self.size {
Some(Wan2p6Size::TenEightyP) => 12,
// Default resolution (None) is 720p.
Some(Wan2p6Size::SevenTwentyP) | None => 8,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
u64::from(seconds) * cents_per_second
}
}
// ── Response ──
pub struct GenerateWan2p6Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_wan_2p6(
args: GenerateWan2p6Args<'_>,
) -> Result<GenerateWan2p6Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateWan2p6Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateWan2p6Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Wan 2.6 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
if self.input_reference_images.len() > MAX_REFERENCE_IMAGES {
return Err(CometClientError::InvalidRequestField {
field: "input_reference_images",
raw_value: self.input_reference_images.len().to_string(),
reason: format!("Wan 2.6 accepts at most {MAX_REFERENCE_IMAGES} references"),
});
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::Wan2p6,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: Wan2p6Size) -> CometVideoSize {
match size {
Wan2p6Size::SevenTwentyP => CometVideoSize::Exact { width: 1280, height: 720 },
Wan2p6Size::TenEightyP => CometVideoSize::Exact { width: 1920, height: 1080 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateWan2p6Request {
prompt: "a steam train crossing a viaduct".to_string(),
duration_seconds: Some(15),
size: Some(Wan2p6Size::TenEightyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "wan2.6".to_string()),
("prompt", "a steam train crossing a viaduct".to_string()),
("seconds", "15".to_string()),
("size", "1920x1080".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(15)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
#[test]
fn too_many_references_rejected() {
let mut request = request_with_seconds(Some(5));
request.input_reference_images = (0..6)
.map(|i| CometInputReferenceImage {
file_bytes: vec![0u8],
filename: format!("ref_{i}.png"),
maybe_content_type: Some("image/png".to_string()),
})
.collect();
let error = request.to_create_video_request().expect_err("should reject 6 references");
assert!(matches!(error, CometClientError::InvalidRequestField { field: "input_reference_images", .. }));
request.input_reference_images.truncate(5);
assert!(request.to_create_video_request().is_ok());
}
#[test]
fn cost_estimates() {
// 5 seconds at 720p: 5 × 8¢ = 40¢
let mut request = request_with_seconds(Some(5));
request.size = Some(Wan2p6Size::SevenTwentyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
// 10 seconds at 1080p: 10 × 12¢ = 120¢
request.duration_seconds = Some(10);
request.size = Some(Wan2p6Size::TenEightyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 120);
// Defaults (5 seconds, 720p): 40¢
request.duration_seconds = None;
request.size = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateWan2p6Request {
GenerateWan2p6Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,186 +0,0 @@
use crate::creds::comet_api_key::CometApiKey;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::requests::create_video::create_video::{
create_video, CometInputReferenceImage, CometVideoModelRaw, CometVideoSize,
CreateVideoArgs, CreateVideoRequest,
};
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Wan 2.7 supports 2-15 second durations.
const MIN_DURATION_SECONDS: u8 = 2;
const MAX_DURATION_SECONDS: u8 = 15;
/// The API default duration when `seconds` is omitted.
const DEFAULT_DURATION_SECONDS: u8 = 5;
// ── Args ──
pub struct GenerateWan2p7Args<'a> {
pub request: GenerateWan2p7Request,
pub api_key: &'a CometApiKey,
}
// ── Request ──
#[derive(Clone, Debug)]
pub struct GenerateWan2p7Request {
pub prompt: String,
/// 2-15 seconds. `None` uses the API default (5).
pub duration_seconds: Option<u8>,
/// `None` uses the API default (720p).
pub size: Option<Wan2p7Size>,
/// Reference images for image-to-video and first/last-frame workflows.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
// ── Enums ──
/// Wan 2.7 renders at 720p or 1080p.
#[derive(Debug, Clone, Copy)]
pub enum Wan2p7Size {
SevenTwentyP,
TenEightyP,
}
// ── Pricing ──
//
// Wan 2.7 per-second USD pricing (via CometAPI):
//
// | Resolution | $/sec |
// |------------|-------|
// | 720p | 0.08 |
// | 1080p | 0.12 |
//
// Total cost = price per second × video duration (seconds).
impl GenerateWan2p7Request {
/// Estimate the USD cost in cents for this generation request.
pub fn estimate_cost_in_usd_cents(&self) -> u64 {
let cents_per_second: u64 = match self.size {
Some(Wan2p7Size::TenEightyP) => 12,
// Default resolution (None) is 720p.
Some(Wan2p7Size::SevenTwentyP) | None => 8,
};
let seconds = self.duration_seconds.unwrap_or(DEFAULT_DURATION_SECONDS);
u64::from(seconds) * cents_per_second
}
}
// ── Response ──
pub struct GenerateWan2p7Response {
/// Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
pub status: CometVideoTaskStatus,
}
// ── Entry point ──
pub async fn generate_wan_2p7(
args: GenerateWan2p7Args<'_>,
) -> Result<GenerateWan2p7Response, CometError> {
let raw_request = args.request.to_create_video_request()?;
let result = create_video(CreateVideoArgs {
api_key: args.api_key,
request: raw_request,
}).await?;
Ok(GenerateWan2p7Response {
task_id: result.task_id,
status: result.status,
})
}
impl GenerateWan2p7Request {
/// Validate and lower to the generic wire request.
pub fn to_create_video_request(&self) -> Result<CreateVideoRequest, CometClientError> {
if let Some(seconds) = self.duration_seconds {
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(CometClientError::InvalidRequestField {
field: "duration_seconds",
raw_value: seconds.to_string(),
reason: format!("Wan 2.7 supports {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} second durations"),
});
}
}
Ok(CreateVideoRequest {
model: CometVideoModelRaw::Wan2p7,
prompt: self.prompt.clone(),
maybe_seconds: self.duration_seconds,
maybe_size: self.size.map(map_size),
input_reference_images: self.input_reference_images.clone(),
})
}
}
fn map_size(size: Wan2p7Size) -> CometVideoSize {
match size {
Wan2p7Size::SevenTwentyP => CometVideoSize::Exact { width: 1280, height: 720 },
Wan2p7Size::TenEightyP => CometVideoSize::Exact { width: 1920, height: 1080 },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_to_wire_request() {
let request = GenerateWan2p7Request {
prompt: "a koi pond at golden hour".to_string(),
duration_seconds: Some(5),
size: Some(Wan2p7Size::SevenTwentyP),
input_reference_images: vec![],
};
let raw = request.to_create_video_request().expect("should validate");
assert_eq!(raw.text_form_fields(), vec![
("model", "wan2.7".to_string()),
("prompt", "a koi pond at golden hour".to_string()),
("seconds", "5".to_string()),
("size", "1280x720".to_string()),
]);
}
#[test]
fn duration_bounds() {
assert!(request_with_seconds(Some(1)).to_create_video_request().is_err());
assert!(request_with_seconds(Some(2)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(15)).to_create_video_request().is_ok());
assert!(request_with_seconds(Some(16)).to_create_video_request().is_err());
assert!(request_with_seconds(None).to_create_video_request().is_ok());
}
#[test]
fn cost_estimates() {
// 5 seconds at 720p: 5 × 8¢ = 40¢
let mut request = request_with_seconds(Some(5));
request.size = Some(Wan2p7Size::SevenTwentyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
// 10 seconds at 1080p: 10 × 12¢ = 120¢
request.duration_seconds = Some(10);
request.size = Some(Wan2p7Size::TenEightyP);
assert_eq!(request.estimate_cost_in_usd_cents(), 120);
// Defaults (5 seconds, 720p): 40¢
request.duration_seconds = None;
request.size = None;
assert_eq!(request.estimate_cost_in_usd_cents(), 40);
}
fn request_with_seconds(duration_seconds: Option<u8>) -> GenerateWan2p7Request {
GenerateWan2p7Request {
prompt: "ok".to_string(),
duration_seconds,
size: None,
input_reference_images: vec![],
}
}
}
@@ -1,13 +0,0 @@
//! Concrete, strongly-typed per-model bindings over the generic
//! [`crate::requests::create_video`] call. Each module encodes one model's
//! constraints (duration range, size options, image-input support) in the
//! type system and validates before hitting the wire.
pub mod generate_doubao_seedance_1p0_pro;
pub mod generate_doubao_seedance_1p5_pro;
pub mod generate_doubao_seedance_2p0;
pub mod generate_doubao_seedance_2p0_fast;
pub mod generate_vidu_q3;
pub mod generate_vidu_q3_turbo;
pub mod generate_wan_2p6;
pub mod generate_wan_2p7;
@@ -1,21 +0,0 @@
//! comet_api_client
//!
//! HTTP client for CometAPI (https://www.cometapi.com/), which proxies a
//! variety of third-party generative models. We use it for Doubao Seedance 2.0
//! video generation ("Seedance 2.0" and "Seedance 2.0 Fast").
//!
//! Flow: enqueue with [`requests::create_video`], then poll with
//! [`requests::get_video_task`] until the task reaches a terminal status.
//!
//! NB: There is deliberately no webhook module. CometAPI documents callbacks
//! as provider-specific pass-through events with no standardized payload, and
//! the Seedance video endpoint has no documented callback parameter. Comet's
//! own guidance is to "keep polling as the source of truth".
pub mod creds;
pub mod error;
pub mod generate;
pub mod requests;
#[cfg(test)]
mod test_utils;
@@ -1,2 +0,0 @@
/// Base URL for the CometAPI HTTP API.
pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com";
@@ -1,439 +0,0 @@
use std::fmt;
use log::info;
use reqwest::multipart::{Form, Part};
use crate::creds::comet_api_key::CometApiKey;
use crate::error::categorize_comet_error::categorize_create_video_error;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::requests::comet_host::COMET_API_BASE_URL;
use crate::requests::create_video::request_types::CreateVideoRawResponse;
use crate::requests::video_task_status::CometVideoTaskStatus;
// ── Public args ──
/// Top-level argument to [`create_video`]. Borrows the API key separately
/// from the request body so callers can log/save [`CreateVideoRequest`]
/// without leaking the credential.
pub struct CreateVideoArgs<'a> {
pub api_key: &'a CometApiKey,
pub request: CreateVideoRequest,
}
/// The material part of a video creation request (`POST /v1/videos`).
///
/// This is the generic, model-agnostic wire shape. Per-model duration and
/// size constraints are NOT enforced here — use the strongly-typed bindings
/// in [`crate::generate::video`] which validate before delegating to this.
#[derive(Clone)]
pub struct CreateVideoRequest {
/// Which video model fulfils the request.
pub model: CometVideoModelRaw,
/// Text description of the desired video. Reference attached images in
/// the prompt as `[Image 1]`, `[Image 2]`, etc.
pub prompt: String,
/// Video duration in seconds. Valid ranges are model-specific. Default: 5.
pub maybe_seconds: Option<u8>,
/// Output size: an aspect-ratio preset or exact dimensions.
pub maybe_size: Option<CometVideoSize>,
/// Optional reference images (JPEG, PNG, WebP) for image-to-video.
/// Each is sent as a repeated `input_reference` multipart field.
/// Not all models support image input.
pub input_reference_images: Vec<CometInputReferenceImage>,
}
/// The video models CometAPI fronts on `POST /v1/videos`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CometVideoModelRaw {
/// Seedance 2.0
DoubaoSeedance2p0,
/// Seedance 2.0 Fast
DoubaoSeedance2p0Fast,
/// Seedance 1.5 Pro (text-only)
DoubaoSeedance1p5Pro,
/// Seedance 1.0 Pro (text-only)
DoubaoSeedance1p0Pro,
/// Vidu Q3
ViduQ3,
/// Vidu Q3 Turbo
ViduQ3Turbo,
/// Alibaba Wan 2.7
Wan2p7,
/// Alibaba Wan 2.6
Wan2p6,
}
/// Output video size: a ratio preset or exact pixel dimensions.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CometVideoSize {
/// 16:9 (landscape)
Landscape16x9,
/// 4:3
Standard4x3,
/// 1:1 (square)
Square,
/// 3:4
Portrait3x4,
/// 9:16 (portrait)
Portrait9x16,
/// 21:9 (ultra-wide)
UltraWide21x9,
/// Exact dimensions, eg. 1280x720.
Exact { width: u32, height: u32 },
}
/// A reference image attached to the request as a binary multipart part.
#[derive(Clone)]
pub struct CometInputReferenceImage {
/// Raw bytes of the image (JPEG, PNG, or WebP). Excluded from `Debug`
/// output — a megabyte-scale blob shouldn't end up in a log line.
pub file_bytes: Vec<u8>,
/// Filename for the multipart `Content-Disposition` header.
pub filename: String,
/// Optional MIME type, eg. "image/png". If `None`, reqwest uses
/// `application/octet-stream`.
pub maybe_content_type: Option<String>,
}
// ── Public response ──
#[derive(Clone, Debug)]
pub struct CreateVideoSuccess {
/// Task identifier. Poll `GET /v1/videos/{task_id}` until terminal.
pub task_id: String,
/// Initial lifecycle status, typically queued.
pub status: CometVideoTaskStatus,
/// The model that will fulfil the task, as echoed by the API.
pub maybe_model: Option<String>,
/// 0-100 completion percentage.
pub maybe_progress: Option<u8>,
/// Unix timestamp (seconds).
pub maybe_created_at: Option<i64>,
}
/// Enqueue a Doubao Seedance 2.0 video generation task.
///
/// CometAPI's video creation endpoint takes `multipart/form-data`. The task
/// id in the response is then polled with
/// [`crate::requests::get_video_task::get_video_task::get_video_task`].
pub async fn create_video(args: CreateVideoArgs<'_>) -> Result<CreateVideoSuccess, CometError> {
let request = &args.request;
request.validate()?;
let url = format!("{COMET_API_BASE_URL}/v1/videos");
let mut form = Form::new();
for (field, value) in request.text_form_fields() {
form = form.text(field, value);
}
for image in &request.input_reference_images {
let mut part = Part::bytes(image.file_bytes.clone())
.file_name(image.filename.clone());
if let Some(content_type) = &image.maybe_content_type {
part = part.mime_str(content_type)
.map_err(|err| CometClientError::InvalidRequestField {
field: "input_reference_images.maybe_content_type",
raw_value: content_type.clone(),
reason: format!("invalid mime type: {err}"),
})?;
}
form = form.part("input_reference", part);
}
let client = reqwest::Client::builder()
.build()
.map_err(CometClientError::ReqwestClientError)?;
let response = client.post(&url)
.bearer_auth(args.api_key.as_str())
.multipart(form)
.send()
.await
.map_err(CometGenericApiError::ReqwestError)?;
let status_code = response.status();
let body = response.text()
.await
.map_err(CometGenericApiError::ReqwestError)?;
if !status_code.is_success() {
return Err(categorize_create_video_error(status_code, body));
}
let raw: CreateVideoRawResponse = serde_json::from_str(&body)
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
info!("Comet video task enqueued: {} (status: {})", raw.id, raw.status);
Ok(CreateVideoSuccess {
task_id: raw.id,
status: raw.status,
maybe_model: raw.model,
maybe_progress: raw.progress,
maybe_created_at: raw.created_at,
})
}
impl CreateVideoRequest {
/// The text fields of the multipart form, in wire order. Split out from
/// [`create_video`] so the form shape is testable without HTTP.
pub fn text_form_fields(&self) -> Vec<(&'static str, String)> {
let mut fields = vec![
("model", self.model.as_api_str().to_string()),
("prompt", self.prompt.clone()),
];
if let Some(seconds) = self.maybe_seconds {
fields.push(("seconds", seconds.to_string()));
}
if let Some(size) = self.maybe_size {
fields.push(("size", size.as_api_string()));
}
fields
}
fn validate(&self) -> Result<(), CometClientError> {
if self.prompt.trim().is_empty() {
return Err(CometClientError::InvalidRequestField {
field: "prompt",
raw_value: self.prompt.clone(),
reason: "prompt must not be empty".to_string(),
});
}
Ok(())
}
}
impl CometVideoModelRaw {
pub fn as_api_str(&self) -> &'static str {
match self {
Self::DoubaoSeedance2p0 => "doubao-seedance-2-0",
Self::DoubaoSeedance2p0Fast => "doubao-seedance-2-0-fast",
Self::DoubaoSeedance1p5Pro => "doubao-seedance-1-5-pro",
Self::DoubaoSeedance1p0Pro => "doubao-seedance-1-0-pro",
Self::ViduQ3 => "viduq3",
Self::ViduQ3Turbo => "viduq3-turbo",
Self::Wan2p7 => "wan2.7",
Self::Wan2p6 => "wan2.6",
}
}
}
impl CometVideoSize {
pub fn as_api_string(&self) -> String {
match self {
Self::Landscape16x9 => "16:9".to_string(),
Self::Standard4x3 => "4:3".to_string(),
Self::Square => "1:1".to_string(),
Self::Portrait3x4 => "3:4".to_string(),
Self::Portrait9x16 => "9:16".to_string(),
Self::UltraWide21x9 => "21:9".to_string(),
Self::Exact { width, height } => format!("{width}x{height}"),
}
}
}
impl fmt::Debug for CreateVideoRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CreateVideoRequest")
.field("model", &self.model)
.field("prompt", &self.prompt)
.field("maybe_seconds", &self.maybe_seconds)
.field("maybe_size", &self.maybe_size)
.field("input_reference_images", &self.input_reference_images)
.finish()
}
}
impl fmt::Debug for CometInputReferenceImage {
// Print byte-length, not raw bytes — derived Debug on Vec<u8> would dump
// the entire image into the log line.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CometInputReferenceImage")
.field("file_bytes_len", &self.file_bytes.len())
.field("filename", &self.filename)
.field("maybe_content_type", &self.maybe_content_type)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
mod shape_tests {
use super::*;
#[test]
fn model_api_strings() {
assert_eq!(CometVideoModelRaw::DoubaoSeedance2p0.as_api_str(), "doubao-seedance-2-0");
assert_eq!(CometVideoModelRaw::DoubaoSeedance2p0Fast.as_api_str(), "doubao-seedance-2-0-fast");
assert_eq!(CometVideoModelRaw::DoubaoSeedance1p5Pro.as_api_str(), "doubao-seedance-1-5-pro");
assert_eq!(CometVideoModelRaw::DoubaoSeedance1p0Pro.as_api_str(), "doubao-seedance-1-0-pro");
assert_eq!(CometVideoModelRaw::ViduQ3.as_api_str(), "viduq3");
assert_eq!(CometVideoModelRaw::ViduQ3Turbo.as_api_str(), "viduq3-turbo");
assert_eq!(CometVideoModelRaw::Wan2p7.as_api_str(), "wan2.7");
assert_eq!(CometVideoModelRaw::Wan2p6.as_api_str(), "wan2.6");
}
#[test]
fn size_api_strings() {
assert_eq!(CometVideoSize::Landscape16x9.as_api_string(), "16:9");
assert_eq!(CometVideoSize::Standard4x3.as_api_string(), "4:3");
assert_eq!(CometVideoSize::Square.as_api_string(), "1:1");
assert_eq!(CometVideoSize::Portrait3x4.as_api_string(), "3:4");
assert_eq!(CometVideoSize::Portrait9x16.as_api_string(), "9:16");
assert_eq!(CometVideoSize::UltraWide21x9.as_api_string(), "21:9");
assert_eq!(CometVideoSize::Exact { width: 1280, height: 720 }.as_api_string(), "1280x720");
}
#[test]
fn minimal_form_fields() {
let request = CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a field".to_string(),
maybe_seconds: None,
maybe_size: None,
input_reference_images: vec![],
};
assert_eq!(request.text_form_fields(), vec![
("model", "doubao-seedance-2-0-fast".to_string()),
("prompt", "a corgi running through a field".to_string()),
]);
}
#[test]
fn full_form_fields() {
let request = CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: "make [Image 1] come alive".to_string(),
maybe_seconds: Some(10),
maybe_size: Some(CometVideoSize::Portrait9x16),
input_reference_images: vec![],
};
assert_eq!(request.text_form_fields(), vec![
("model", "doubao-seedance-2-0".to_string()),
("prompt", "make [Image 1] come alive".to_string()),
("seconds", "10".to_string()),
("size", "9:16".to_string()),
]);
}
}
mod validation_tests {
use super::*;
#[test]
fn empty_prompt_is_rejected() {
let request = request_with_prompt_and_seconds(" ", None);
let error = request.validate().expect_err("should reject empty prompt");
assert!(matches!(error, CometClientError::InvalidRequestField { field: "prompt", .. }));
}
// NB: Duration ranges are model-specific and validated by the concrete
// bindings in `generate::video`; the generic layer accepts any value.
fn request_with_prompt_and_seconds(prompt: &str, maybe_seconds: Option<u8>) -> CreateVideoRequest {
CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0,
prompt: prompt.to_string(),
maybe_seconds,
maybe_size: None,
input_reference_images: vec![],
}
}
}
mod live_tests {
use super::*;
use crate::test_utils::load_api_key;
/// Live test: enqueues a REAL generation and INCURS COSTS.
/// Run manually with:
/// cargo test -p comet_api_client live_create_video -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_create_video() {
let api_key = load_api_key();
let result = create_video(CreateVideoArgs {
api_key: &api_key,
request: CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
maybe_seconds: Some(4),
maybe_size: Some(CometVideoSize::Landscape16x9),
input_reference_images: vec![],
},
}).await.expect("create_video should succeed");
println!("Enqueued task: {} (status: {})", result.task_id, result.status);
assert!(!result.task_id.is_empty());
}
/// Live test: enqueues a REAL generation and polls it to completion.
/// INCURS COSTS and takes a few minutes. Run manually with:
/// cargo test -p comet_api_client live_create_and_poll_video -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_create_and_poll_video() {
use std::time::Duration;
use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs};
// NB: Comet's docs claim most tasks finish in 1-3 minutes, but tasks
// have been observed to sit at `in_progress` well past 10 minutes.
const POLL_INTERVAL: Duration = Duration::from_secs(15);
const MAX_POLLS: u32 = 120; // ~30 minutes
let api_key = load_api_key();
let created = create_video(CreateVideoArgs {
api_key: &api_key,
request: CreateVideoRequest {
model: CometVideoModelRaw::DoubaoSeedance2p0Fast,
prompt: "a corgi running through a sunny meadow, cinematic".to_string(),
maybe_seconds: Some(4),
maybe_size: Some(CometVideoSize::Landscape16x9),
input_reference_images: vec![],
},
}).await.expect("create_video should succeed");
println!("Enqueued task: {} (status: {})", created.task_id, created.status);
for poll in 1..=MAX_POLLS {
tokio::time::sleep(POLL_INTERVAL).await;
let task = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: &created.task_id,
}).await.expect("get_video_task should succeed");
println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress);
if task.status.is_terminal() {
assert!(task.status.is_success(), "task should complete successfully: {:?}", task);
let video_url = task.maybe_video_url.expect("completed task should have a video url");
println!("Video URL: {}", video_url);
return;
}
}
panic!("Task {} did not reach a terminal state in time", created.task_id);
}
}
}
@@ -1,2 +0,0 @@
pub mod create_video;
pub mod request_types;
@@ -1,77 +0,0 @@
use serde_derive::Deserialize;
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Over-the-wire response from `POST /v1/videos`.
///
/// The docs show both `id` and `task_id` carrying the same task identifier;
/// `task_id` is treated as an optional mirror of `id`.
#[derive(Clone, Debug, Deserialize)]
pub struct CreateVideoRawResponse {
/// Task identifier, eg. "task_abc123". Use this with the poll endpoint.
pub id: String,
/// Mirror of `id`.
pub task_id: Option<String>,
/// Always "video".
pub object: Option<String>,
/// The model that will fulfil the task, eg. "doubao-seedance-2-0".
pub model: Option<String>,
/// Initial lifecycle status, typically "queued".
pub status: CometVideoTaskStatus,
/// 0-100 completion percentage.
pub progress: Option<u8>,
/// Unix timestamp (seconds).
pub created_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Documented response shape from
/// https://apidoc.cometapi.com/api/video/seedance/create.md
const REAL_CREATE_RESPONSE: &str = r#"
{
"id": "task_abc123",
"task_id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "queued",
"progress": 0,
"created_at": 1776681149
}
"#;
#[test]
fn parse_real_create_response() {
let response: CreateVideoRawResponse = serde_json::from_str(REAL_CREATE_RESPONSE)
.expect("should parse");
assert_eq!(response.id, "task_abc123");
assert_eq!(response.task_id.as_deref(), Some("task_abc123"));
assert_eq!(response.object.as_deref(), Some("video"));
assert_eq!(response.model.as_deref(), Some("doubao-seedance-2-0"));
assert_eq!(response.status, CometVideoTaskStatus::Queued);
assert_eq!(response.progress, Some(0));
assert_eq!(response.created_at, Some(1776681149));
}
#[test]
fn parse_minimal_response() {
// Only `id` and `status` are load-bearing; everything else is optional
// so schema drift doesn't break enqueuing.
let response: CreateVideoRawResponse = serde_json::from_str(
r#"{"id":"task_xyz","status":"queued"}"#,
).expect("should parse");
assert_eq!(response.id, "task_xyz");
assert_eq!(response.status, CometVideoTaskStatus::Queued);
assert_eq!(response.task_id, None);
}
}
@@ -1,184 +0,0 @@
use log::info;
use crate::creds::comet_api_key::CometApiKey;
use crate::error::categorize_comet_error::categorize_get_video_task_error;
use crate::error::comet_client_error::CometClientError;
use crate::error::comet_error::CometError;
use crate::error::comet_generic_api_error::CometGenericApiError;
use crate::requests::comet_host::COMET_API_BASE_URL;
use crate::requests::get_video_task::request_types::VideoTaskRawResponse;
use crate::requests::video_task_status::CometVideoTaskStatus;
// ── Public args ──
pub struct GetVideoTaskArgs<'a> {
pub api_key: &'a CometApiKey,
/// The task id returned by the creation endpoint, eg. "task_abc123".
pub task_id: &'a str,
}
// ── Public response ──
/// Snapshot of a video task's state.
///
/// Poll every 10-20 seconds until [`CometVideoTaskStatus::is_terminal`];
/// most tasks finish within 1-3 minutes.
#[derive(Clone, Debug)]
pub struct CometVideoTask {
pub task_id: String,
pub status: CometVideoTaskStatus,
/// The Seedance model used, as echoed by the API.
pub maybe_model: Option<String>,
/// 0-100 completion percentage.
pub maybe_progress: Option<u8>,
/// Signed download URL, present only when the task completed. The
/// signature is time-limited — download or re-host the file promptly.
pub maybe_video_url: Option<String>,
/// Unix timestamp (seconds).
pub maybe_created_at: Option<i64>,
/// Unix timestamp (seconds) when the task finished.
pub maybe_completed_at: Option<i64>,
}
/// Fetch the current state of a video task (`GET /v1/videos/{id}`).
pub async fn get_video_task(args: GetVideoTaskArgs<'_>) -> Result<CometVideoTask, CometError> {
let url = format!("{COMET_API_BASE_URL}/v1/videos/{}", args.task_id);
let client = reqwest::Client::builder()
.build()
.map_err(CometClientError::ReqwestClientError)?;
let response = client.get(&url)
.bearer_auth(args.api_key.as_str())
.send()
.await
.map_err(CometGenericApiError::ReqwestError)?;
let status_code = response.status();
let body = response.text()
.await
.map_err(CometGenericApiError::ReqwestError)?;
if !status_code.is_success() {
return Err(categorize_get_video_task_error(status_code, body, args.task_id));
}
let raw: VideoTaskRawResponse = serde_json::from_str(&body)
.map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?;
info!("Comet video task {}: status {} (progress: {:?})", raw.id, raw.status, raw.progress);
Ok(CometVideoTask {
task_id: raw.id,
status: raw.status,
maybe_model: raw.model,
maybe_progress: raw.progress,
maybe_video_url: raw.video_url,
maybe_created_at: raw.created_at,
maybe_completed_at: raw.completed_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::comet_specific_api_error::CometSpecificApiError;
use crate::requests::get_video_content::get_video_content::{
probe_video_failure_reason, ProbeVideoFailureReasonArgs,
};
use crate::test_utils::load_api_key;
/// Historical task ids from real generations, kept for diagnosing how
/// CometAPI reports terminal states. Several of these failed content
/// moderation ("VIOLATION").
const KNOWN_TASK_IDS: &[&str] = &[
"task_ZISEWaXgmyqXWiesd287qTyAZLapbwz8",
"task_ZvwV2nhp08ng4krT16CPxtHZ2SgABElj",
"task_GZPVhcnIZKLJBqsCGTBvMiUFN9uDnwJv",
"task_iGnAQ5lwqhMltxhN5k9yATAjs7AgjV39",
"task_hGhShys2Wi2gPf2qUkuQ38VKIytWAw1d",
"task_t84NmrcT3k8s6Il4xJeKtfxXipA3n8RQ",
"task_6OhdGVFZjHNtOKKx8Ewz37mENaVIhC3X",
];
/// Live test: makes a REAL network request (no generation cost — the task
/// id is bogus, so this exercises auth + the TaskNotFound error path).
/// Run manually with:
/// cargo test -p comet_api_client live_get_video_task -- --ignored --nocapture
#[ignore]
#[tokio::test]
async fn live_get_video_task_unknown_id() {
let api_key = load_api_key();
let result = get_video_task(GetVideoTaskArgs {
api_key: &api_key,
task_id: "task_does_not_exist_artcraft_test",
}).await;
match result {
Err(CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, .. })) => {
assert_eq!(task_id, "task_does_not_exist_artcraft_test");
}
other => panic!("Expected TaskNotFound, got: {:?}", other),
}
}
/// Live diagnostic: polls the known historical tasks and prints their full
/// state, including the failure reason recovered from the content
/// endpoint for failed tasks. Free (read-only GETs, no generation cost),
/// but hits the real API. Run manually with:
/// cargo test -p comet_api_client live_poll_known_task_ids -- --ignored --nocapture
///
/// NB: The poll endpoint reports failures as a bare `status: "failed"`.
/// The ONLY way to learn WHY is `GET /v1/videos/{id}/content`, whose error
/// message leaks the underlying provider status (eg. "VIOLATION" for
/// content-moderation rejections — these run to progress 100 and then
/// fail when the finished output is moderated).
#[ignore]
#[tokio::test]
async fn live_poll_known_task_ids() {
let api_key = load_api_key();
for task_id in KNOWN_TASK_IDS {
println!("===== {task_id}");
let task = match get_video_task(GetVideoTaskArgs { api_key: &api_key, task_id }).await {
Ok(task) => task,
Err(err) => {
println!(" poll error: {err}");
continue;
}
};
println!(" status: {}", task.status);
println!(" model: {}", task.maybe_model.as_deref().unwrap_or("?"));
println!(" progress: {:?}", task.maybe_progress);
println!(" created_at: {:?}", task.maybe_created_at);
println!(" completed_at: {:?}", task.maybe_completed_at);
println!(" video_url: {}", task.maybe_video_url.as_deref().unwrap_or("(none)"));
if task.status.is_failure() {
match probe_video_failure_reason(ProbeVideoFailureReasonArgs { api_key: &api_key, task_id }).await {
Ok(Some(reason)) => {
println!(" failure reason (via /content):");
println!(" raw message: {}", reason.raw_message);
println!(" underlying status: {}", reason.maybe_underlying_status.as_deref().unwrap_or("(unparsed)"));
println!(" content violation: {}", reason.is_content_violation());
println!(" error type: {}", reason.maybe_error_type.as_deref().unwrap_or("(none)"));
println!(" request id: {}", reason.maybe_request_id.as_deref().unwrap_or("(none)"));
}
Ok(None) => println!(" failure probe: content unexpectedly downloadable"),
Err(err) => println!(" failure probe error: {err}"),
}
}
println!();
}
}
}
@@ -1,2 +0,0 @@
pub mod get_video_task;
pub mod request_types;
@@ -1,113 +0,0 @@
use serde_derive::Deserialize;
use crate::requests::video_task_status::CometVideoTaskStatus;
/// Over-the-wire response from `GET /v1/videos/{id}`.
#[derive(Clone, Debug, Deserialize)]
pub struct VideoTaskRawResponse {
/// Task identifier.
pub id: String,
/// Always "video".
pub object: Option<String>,
/// The Seedance model used, eg. "doubao-seedance-2-0".
pub model: Option<String>,
/// Lifecycle status.
pub status: CometVideoTaskStatus,
/// 0-100 completion percentage.
pub progress: Option<u8>,
/// Signed download URL. Present only when `status` is "completed".
/// NB: the signature is time-limited — download or re-host the file
/// before it expires.
pub video_url: Option<String>,
/// Unix timestamp (seconds).
pub created_at: Option<i64>,
/// Unix timestamp (seconds) when the task finished.
pub completed_at: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
// Documented response shapes from
// https://apidoc.cometapi.com/api/video/seedance/query.md
const REAL_IN_PROGRESS_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "in_progress",
"progress": 30,
"created_at": 1777385418,
"completed_at": 1777385485
}
"#;
const REAL_COMPLETED_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "completed",
"progress": 100,
"created_at": 1777385418,
"completed_at": 1777385526,
"video_url": "https://example.com/seedance-output.mp4"
}
"#;
const REAL_FAILED_RESPONSE: &str = r#"
{
"id": "task_abc123",
"object": "video",
"model": "doubao-seedance-2-0",
"status": "failed",
"progress": 0,
"created_at": 1777385418,
"completed_at": 1777385526
}
"#;
#[test]
fn parse_real_in_progress_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_IN_PROGRESS_RESPONSE)
.expect("should parse");
assert_eq!(response.id, "task_abc123");
assert_eq!(response.status, CometVideoTaskStatus::InProgress);
assert_eq!(response.progress, Some(30));
assert_eq!(response.video_url, None);
assert!(!response.status.is_terminal());
}
#[test]
fn parse_real_completed_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_COMPLETED_RESPONSE)
.expect("should parse");
assert_eq!(response.status, CometVideoTaskStatus::Completed);
assert_eq!(response.progress, Some(100));
assert_eq!(response.video_url.as_deref(), Some("https://example.com/seedance-output.mp4"));
assert_eq!(response.completed_at, Some(1777385526));
assert!(response.status.is_success());
}
#[test]
fn parse_real_failed_response() {
let response: VideoTaskRawResponse = serde_json::from_str(REAL_FAILED_RESPONSE)
.expect("should parse");
assert_eq!(response.status, CometVideoTaskStatus::Failed);
assert_eq!(response.video_url, None);
assert!(response.status.is_terminal());
assert!(response.status.is_failure());
}
}
@@ -1,5 +0,0 @@
pub mod comet_host;
pub mod create_video;
pub mod get_video_content;
pub mod get_video_task;
pub mod video_task_status;
@@ -1,132 +0,0 @@
use std::fmt;
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
/// Lifecycle status of a CometAPI video task.
///
/// The `Unknown(String)` catch-all future-proofs against CometAPI adding new
/// states — callers should treat unknown states as still-running unless
/// proven otherwise.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CometVideoTaskStatus {
Queued,
InProgress,
Completed,
Failed,
Error,
Unknown(String),
}
impl CometVideoTaskStatus {
pub fn to_str(&self) -> &str {
match self {
Self::Queued => "queued",
Self::InProgress => "in_progress",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Error => "error",
Self::Unknown(value) => value,
}
}
pub fn from_str(value: &str) -> Self {
match value {
"queued" => Self::Queued,
"in_progress" => Self::InProgress,
"completed" => Self::Completed,
"failed" => Self::Failed,
"error" => Self::Error,
other => Self::Unknown(other.to_string()),
}
}
/// Whether the task has reached a terminal state (stop polling).
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Error)
}
pub fn is_success(&self) -> bool {
matches!(self, Self::Completed)
}
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed | Self::Error)
}
}
impl fmt::Display for CometVideoTaskStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_str())
}
}
impl<'de> Deserialize<'de> for CometVideoTaskStatus {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Ok(Self::from_str(&value))
}
}
impl Serialize for CometVideoTaskStatus {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.to_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_states() {
let cases = [
("\"queued\"", CometVideoTaskStatus::Queued),
("\"in_progress\"", CometVideoTaskStatus::InProgress),
("\"completed\"", CometVideoTaskStatus::Completed),
("\"failed\"", CometVideoTaskStatus::Failed),
("\"error\"", CometVideoTaskStatus::Error),
];
for (json, expected) in cases {
let status: CometVideoTaskStatus = serde_json::from_str(json).expect("should parse");
assert_eq!(status, expected);
}
}
#[test]
fn deserialize_unknown_state_is_future_proof() {
let status: CometVideoTaskStatus = serde_json::from_str("\"hyper_queued\"").expect("should parse");
assert_eq!(status, CometVideoTaskStatus::Unknown("hyper_queued".to_string()));
assert!(!status.is_terminal());
}
#[test]
fn round_trip() {
for status in [
CometVideoTaskStatus::Queued,
CometVideoTaskStatus::InProgress,
CometVideoTaskStatus::Completed,
CometVideoTaskStatus::Failed,
CometVideoTaskStatus::Error,
CometVideoTaskStatus::Unknown("new_state".to_string()),
] {
let json = serde_json::to_string(&status).expect("should serialize");
let parsed: CometVideoTaskStatus = serde_json::from_str(&json).expect("should parse");
assert_eq!(parsed, status);
}
}
#[test]
fn terminal_states() {
assert!(!CometVideoTaskStatus::Queued.is_terminal());
assert!(!CometVideoTaskStatus::InProgress.is_terminal());
assert!(CometVideoTaskStatus::Completed.is_terminal());
assert!(CometVideoTaskStatus::Failed.is_terminal());
assert!(CometVideoTaskStatus::Error.is_terminal());
assert!(CometVideoTaskStatus::Completed.is_success());
assert!(!CometVideoTaskStatus::Completed.is_failure());
assert!(CometVideoTaskStatus::Failed.is_failure());
assert!(CometVideoTaskStatus::Error.is_failure());
}
}
@@ -1,11 +0,0 @@
use std::fs::read_to_string;
use crate::creds::comet_api_key::CometApiKey;
const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/comet_api_key.txt";
pub fn load_api_key() -> CometApiKey {
let secret = read_to_string(API_KEY_PATH)
.expect("Failed to read Comet API key file");
CometApiKey::from_str(&secret)
}
@@ -16,10 +16,8 @@ use tokens::tokens::prompts::PromptToken;
/// and the rest of the typed response shape.
#[derive(Debug, Clone)]
pub struct FolderMediaFileRow {
/// `media_files.id` — used as the pagination cursor. The list is sorted
/// by this (descending) rather than by folder-membership order so the
/// frontend's group-by-date view doesn't interleave across pages.
pub media_file_id: u64,
/// `folder_media_files.id` — used as the pagination cursor.
pub membership_id: u64,
/// When the media file was added to the folder.
pub added_to_folder_at: DateTime<Utc>,
@@ -73,8 +71,7 @@ where
/// for the file's own metadata and LEFT JOINs `media_files` again on
/// `maybe_cover_image_media_file_token` so the cover-image bucket fields
/// come back in a single round-trip. Filters out media files that are
/// soft-deleted on the media-file side. Newest media files first
/// (`media_files.id` descending — NOT folder-membership order).
/// soft-deleted on the media-file side. Most-recently-added first.
///
/// The caller is expected to have already authorized the folder access.
pub async fn list_folder_media_files<'e, 'c: 'e, E>(
@@ -90,7 +87,7 @@ where
sqlx::query!(
r#"
SELECT
mf.id as `media_file_id: u64`,
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -129,8 +126,8 @@ LEFT JOIN media_files cover
WHERE fmf.folder_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
AND mf.id < ?
ORDER BY mf.id DESC
AND fmf.id < ?
ORDER BY fmf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -141,7 +138,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
media_file_id: r.media_file_id,
membership_id: r.membership_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -171,7 +168,7 @@ LIMIT ?
sqlx::query!(
r#"
SELECT
mf.id as `media_file_id: u64`,
fmf.id as `membership_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -210,7 +207,7 @@ LEFT JOIN media_files cover
WHERE fmf.folder_token = ?
AND mf.user_deleted_at IS NULL
AND mf.mod_deleted_at IS NULL
ORDER BY mf.id DESC
ORDER BY fmf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -220,7 +217,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
media_file_id: r.media_file_id,
membership_id: r.membership_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -1253,7 +1253,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
// Folders
FolderInfo,
FolderThumbnail,
FolderThumbnailVideoPreviews,
FolderMediaFileListItem,
FolderPathInfo,
FolderMediaFilesPathInfo,
@@ -3,7 +3,7 @@ use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail, FolderThumbnailVideoPreviews};
use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use mysql_queries::queries::folders::folder::folder_row::FolderRow;
use mysql_queries::queries::media_files::get::batch_get_media_file_thumbnails_by_tokens::{
@@ -122,8 +122,8 @@ fn build_folder_thumbnail(
row.maybe_public_bucket_extension.as_deref(),
);
// Reuse `MediaLinksBuilder` to produce the CDN URL, thumbnail template,
// and video previews, then pull those fields onto the compact
// Reuse `MediaLinksBuilder` to produce the CDN URL + thumbnail
// template, then pull just those two fields onto the compact
// FolderThumbnail wire shape.
let media_links = MediaLinksBuilder::from_media_path_and_env(
media_domain,
@@ -131,81 +131,11 @@ fn build_folder_thumbnail(
&bucket_path,
);
// Only video media files have previews; `MediaLinksBuilder` returns
// `None` for everything else.
let maybe_video_previews = media_links.maybe_video_previews
.map(|previews| FolderThumbnailVideoPreviews {
still: previews.still,
animated: previews.animated,
still_thumbnail_template: previews.still_thumbnail_template,
animated_thumbnail_template: previews.animated_thumbnail_template,
});
FolderThumbnail {
token: row.token,
media_class: row.media_class,
media_type: row.media_type,
cdn_url: media_links.cdn_url,
maybe_thumbnail_template: media_links.maybe_thumbnail_template,
maybe_video_previews,
}
}
#[cfg(test)]
mod tests {
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use super::*;
const PROD_CDN: &str = "https://cdn-2.fakeyou.com";
const OBJECT_HASH: &str = "t6cnyw4g3e8k7carkk2bvrt6nd3fycjv";
#[test]
fn mp4_video_gets_video_previews() {
let thumbnail = build_thumbnail_for_extension(MediaFileClass::Video, MediaFileType::Mp4, ".mp4");
assert_eq!(thumbnail.maybe_thumbnail_template, None);
let previews = thumbnail.maybe_video_previews.expect("video should have previews");
assert_eq!(
previews.still.as_str(),
format!("{PROD_CDN}/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.jpg"));
assert_eq!(
previews.animated.as_str(),
format!("{PROD_CDN}/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.gif"));
assert_eq!(
previews.still_thumbnail_template,
format!("{PROD_CDN}/cdn-cgi/image/width={{WIDTH}},quality=95/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.jpg"));
assert_eq!(
previews.animated_thumbnail_template,
format!("{PROD_CDN}/cdn-cgi/image/width={{WIDTH}},quality=95/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.mp4-thumb.gif"));
}
#[test]
fn png_image_gets_thumbnail_template_but_no_video_previews() {
let thumbnail = build_thumbnail_for_extension(MediaFileClass::Image, MediaFileType::Png, ".png");
assert_eq!(
thumbnail.maybe_thumbnail_template,
Some(format!("{PROD_CDN}/cdn-cgi/image/width={{WIDTH}},quality=95/media/t/6/c/n/y/{OBJECT_HASH}/storyteller_{OBJECT_HASH}.png")));
assert!(thumbnail.maybe_video_previews.is_none());
}
fn build_thumbnail_for_extension(
media_class: MediaFileClass,
media_type: MediaFileType,
extension: &str,
) -> FolderThumbnail {
let row = MediaFileThumbnailRow {
token: MediaFileToken::new_from_str("m_test"),
media_class,
media_type,
public_bucket_directory_hash: OBJECT_HASH.to_string(),
maybe_public_bucket_prefix: Some("storyteller_".to_string()),
maybe_public_bucket_extension: Some(extension.to_string()),
};
build_folder_thumbnail(row, MediaDomain::FakeYou, ServerEnvironment::Production)
}
}
@@ -172,7 +172,7 @@ pub async fn list_folder_media_files_handler(
})?;
let maybe_cursor = rows.last().map(|last| {
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.membership_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
@@ -39,7 +39,6 @@ import { MobileBottomNav } from "../components/sidebar/mobile-bottom-nav";
import { TopBar } from "../components/topbar/topbar";
import { SignupCtaModal } from "../components/signup-cta-modal";
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
import { GlobalActionReminder } from "../components/global-action-reminder";
import { useSession } from "../lib/session";
function ScrollToTop() {
@@ -164,10 +163,7 @@ export function App() {
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/library" element={<Library />} />
<Route path="/library/folders" element={<Library />} />
{/* `:slug` is a media-class filter (images/videos/meshes) OR a
folder token (prefixed `folder_`); the page disambiguates. */}
<Route path="/library/:slug" element={<Library />} />
<Route path="/library/:filter" element={<Library />} />
<Route path="/referrals" element={<Referrals />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
@@ -192,7 +188,6 @@ export function App() {
<ToastContainer />
<SignupCtaModal />
<InsufficientCreditsModal />
<GlobalActionReminder />
</>
);
}
@@ -1,34 +0,0 @@
import { useSignals } from "@preact/signals-react/runtime";
import {
ActionReminderModal,
isActionReminderOpen,
actionReminderProps,
} from "@storyteller/ui-action-reminder-modal";
/**
* Global host for `showActionReminder()` (the signal-driven confirm dialog used
* by shared components like the gallery tiles). Mounted once at the app root so
* any caller can pop a confirm without wiring its own modal.
*/
export function GlobalActionReminder() {
useSignals();
const props = actionReminderProps.value;
if (!props) return null;
return (
<ActionReminderModal
isOpen={isActionReminderOpen.value}
onClose={props.onClose}
reminderType={props.reminderType}
onPrimaryAction={props.onPrimaryAction}
title={props.title}
message={props.message}
primaryActionText={props.primaryActionText}
secondaryActionText={props.secondaryActionText}
onSecondaryAction={props.onSecondaryAction}
isLoading={props.isLoading}
openAiLogo={props.openAiLogo}
primaryActionIcon={props.primaryActionIcon}
primaryActionBtnClassName={props.primaryActionBtnClassName}
/>
);
}
@@ -8,6 +8,7 @@ import {
faCube,
faFilm,
faWandMagicSparkles,
faGrid2,
faGraduationCap,
faNewspaper,
faCircleQuestion,
@@ -35,7 +36,6 @@ import {
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { MARKETING_SITE, SOCIAL_LINKS } from "../../config/links";
import { useSceneCacheStore } from "../../pages/pagescene/scene-cache-store";
import { LibraryFoldersNav } from "./library-folders-nav";
type NavItem = {
label: string;
@@ -79,6 +79,10 @@ function useCreateItems(): NavItem[] {
);
}
const ASSETS_ITEMS: NavItem[] = [
{ label: "Library", href: "/library", icon: faGrid2 },
];
const REFERRALS_ITEM: NavItem = {
label: "Referrals",
href: "/referrals",
@@ -138,9 +142,9 @@ function NavMenuItem({
const inner = (
<>
<FontAwesomeIcon icon={item.icon} />
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
<span>{item.label}</span>
{item.badge && (
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
<span className="ml-auto rounded-sm bg-amber-500 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden">
{item.badge}
</span>
)}
@@ -253,7 +257,12 @@ export function AppSidebar() {
pathname={pathname}
onClick={handleNavClick}
/>
<LibraryFoldersNav pathname={pathname} onNavClick={handleNavClick} />
<NavSection
label="Assets"
items={ASSETS_ITEMS}
pathname={pathname}
onClick={handleNavClick}
/>
{hasReferralsFlag && (
<NavSection
label="Invite"
@@ -1,150 +0,0 @@
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBorderAll,
faFolder,
faPlus,
faStar,
} from "@fortawesome/pro-solid-svg-icons";
import { compareFolders } from "@storyteller/ui-gallery-modal";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
import { useLibraryFoldersStore } from "../../pages/library/library-folders-store";
/**
* The "Library" sidebar section: Unsorted + Folders entries, plus the user's
* root folders while in the library area. Folder rows carry `data-folder-id`
* (drag-drop target) and right-click opens the page's folder context menu.
*/
export function LibraryFoldersNav({
pathname,
onNavClick,
}: {
pathname: string;
onNavClick: () => void;
}) {
const navigate = useNavigate();
const onFolders =
pathname === "/library/folders" ||
pathname.startsWith("/library/folder_");
const onUnsorted =
!onFolders &&
(pathname === "/library" || pathname.startsWith("/library/"));
const inLibraryArea = onUnsorted || onFolders;
const folders = useLibraryFoldersStore((s) => s.folders);
const activeFolderId = useLibraryFoldersStore((s) => s.activeFolderId);
const openNewFolderModal = useLibraryFoldersStore((s) => s.openNewFolderModal);
const setContextMenu = useLibraryFoldersStore((s) => s.setContextMenu);
const rootFolders = useMemo(
() => folders.filter((f) => !f.parentId).sort(compareFolders),
[folders],
);
// Highlight whichever root branch the active folder lives in.
const activeRootId = useMemo(() => {
if (!activeFolderId) return null;
const byId = new Map(folders.map((f) => [f.id, f]));
const seen = new Set<string>();
let cursor = byId.get(activeFolderId);
while (cursor && cursor.parentId && !seen.has(cursor.id)) {
seen.add(cursor.id);
cursor = byId.get(cursor.parentId);
}
return cursor?.id ?? null;
}, [folders, activeFolderId]);
const goToFolder = (id: string) => {
navigate(`/library/${id}`);
onNavClick();
};
return (
<SidebarGroup>
<div className="flex items-center justify-between">
<SidebarGroupLabel>Assets</SidebarGroupLabel>
{inLibraryArea && (
<button
type="button"
onClick={() => openNewFolderModal(null)}
aria-label="New folder"
className="mr-1 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<FontAwesomeIcon icon={faPlus} className="text-xs" />
</button>
)}
</div>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={onUnsorted} tooltip="Library">
<Link to="/library" onClick={onNavClick}>
<FontAwesomeIcon icon={faBorderAll} />
<span>Library</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={onFolders && !activeFolderId}
tooltip="Folders"
>
<Link to="/library/folders" onClick={onNavClick}>
<FontAwesomeIcon icon={faFolder} />
<span>Folders</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{inLibraryArea &&
rootFolders.map((folder) => (
<SidebarMenuItem
key={folder.id}
className="group-data-[collapsible=icon]:hidden"
>
<SidebarMenuButton
isActive={activeRootId === folder.id}
tooltip={folder.name}
data-folder-id={folder.id}
onClick={() => goToFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({
folderId: folder.id,
x: e.clientX,
y: e.clientY,
});
}}
className="pl-5 [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-sidebar-foreground"
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "" : "text-primary"}
style={
folder.colorCode ? { color: folder.colorCode } : undefined
}
/>
<span className="truncate">{folder.name}</span>
{folder.hasStar && (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-[10px] text-amber-400"
/>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -11,6 +11,7 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
{ label: "Create", href: "/" },
{ label: "Background Change" },
],
"/library": [{ label: "Library" }],
"/media": [{ label: "Library", href: "/library" }, { label: "Media" }],
"/pricing": [{ label: "Pricing" }],
"/support": [{ label: "Support" }],
@@ -30,17 +31,6 @@ const ROUTE_CRUMBS: Record<string, Crumb[]> = {
};
function resolveCrumbs(pathname: string): Crumb[] {
// Library has sub-tabs (Unsorted / Folders) that live under /library/* rather
// than being separate top-level pages.
if (pathname === "/library" || pathname.startsWith("/library/")) {
const onFolders =
pathname === "/library/folders" ||
pathname.startsWith("/library/folder_");
return [
{ label: "Library", href: "/library" },
{ label: onFolders ? "Folders" : "All Assets" },
];
}
if (ROUTE_CRUMBS[pathname]) return ROUTE_CRUMBS[pathname];
const matchedKey = Object.keys(ROUTE_CRUMBS).find(
(k) => k !== "/" && pathname.startsWith(k + "/"),
@@ -5,7 +5,6 @@ import {
faVideo,
faWandMagicSparkles,
faCube,
faFilm,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -19,7 +18,6 @@ type AppCard = {
accent: string;
iconBg: string;
iconColor: string;
badge?: string;
};
const APPS: AppCard[] = [
@@ -59,16 +57,6 @@ const APPS: AppCard[] = [
iconBg: "bg-emerald-500/25 border-emerald-400/30",
iconColor: "text-emerald-300",
},
{
label: "Edit Video",
description: "Trim, arrange, and edit clips on a timeline.",
href: "/video-editor",
icon: faFilm,
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/25 border-rose-400/30",
iconColor: "text-rose-300",
badge: "BETA",
},
];
export function Home() {
@@ -103,16 +91,9 @@ export function Home() {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-base font-semibold text-white truncate">
{app.label}
</h3>
{app.badge && (
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
{app.badge}
</span>
)}
</div>
<h3 className="text-base font-semibold text-white">
{app.label}
</h3>
<FontAwesomeIcon
icon={faArrowRight}
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
@@ -1,576 +0,0 @@
import { create } from "zustand";
import {
FoldersApi,
MediaFilesApi,
type FolderInfo,
type FolderMediaFileListItem,
} from "@storyteller/api";
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
import {
mapFolderInfo,
galleryItemToCollageUrl,
mergeCollageUrls,
} from "@storyteller/ui-gallery-modal";
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import { toast } from "../../components/toast/toast";
// ── Bulk selection store ────────────────────────────────────────────────────
// Kept in its own module store (not page state) so each gallery tile can
// subscribe to just its own membership: a marquee/selection commit re-renders
// only the tiles whose checked state flipped, never the whole library page.
interface LibrarySelectionState {
ids: Set<string>;
toggle: (id: string) => void;
setIds: (ids: Set<string>) => void;
removeIds: (ids: string[]) => void;
clear: () => void;
}
export const useLibrarySelectionStore = create<LibrarySelectionState>(
(set) => ({
ids: new Set<string>(),
toggle: (id) =>
set((s) => {
const next = new Set(s.ids);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { ids: next };
}),
setIds: (ids) => set({ ids }),
removeIds: (ids) =>
set((s) => {
if (!ids.some((id) => s.ids.has(id))) return s;
const next = new Set(s.ids);
ids.forEach((id) => next.delete(id));
return { ids: next };
}),
clear: () =>
set((s) => (s.ids.size === 0 ? s : { ids: new Set<string>() })),
}),
);
// ── Types ──────────────────────────────────────────────────────────────────
/** Folder shape the UI navigates + renders by. */
export interface UiFolder {
id: string;
name: string;
parentId: string | null;
hasStar?: boolean;
colorCode?: string | null;
coverUrl?: string | null;
collageUrls?: string[];
}
interface LibraryFoldersState {
folders: UiFolder[];
foldersLoaded: boolean;
activeFolderId: string | null;
/** Resolved media items per folder, cached so reopening is instant. */
folderMediaItems: Record<string, GalleryItem[]>;
folderContentLoading: boolean;
/** Bottom spinner while paginating the open folder's media. */
folderLoadingMore: boolean;
/** Whether the open folder has more media pages to load. */
folderHasMore: Record<string, boolean>;
// Dialog state — rendered by the library page, triggered from sidebar or page.
newFolderModal: { open: boolean; parentId: string | null };
renameTarget: string | null;
contextMenu: { folderId: string; x: number; y: number } | null;
loadFolders: () => Promise<void>;
setActiveFolder: (id: string | null) => void;
loadFolderMedia: (folderId: string, reset?: boolean) => Promise<void>;
createFolder: (name: string, parentId: string | null) => Promise<void>;
renameFolder: (folderId: string, name: string) => Promise<void>;
setFolderStar: (folderId: string, hasStar: boolean) => Promise<void>;
setFolderColor: (folderId: string, colorCode: string | null) => Promise<void>;
deleteFolder: (folderId: string) => Promise<void>;
addMediaToFolder: (
itemIds: string[],
folderId: string,
known: GalleryItem[],
) => Promise<void>;
moveMediaToFolder: (
itemIds: string[],
sourceFolderId: string,
targetFolderId: string,
known: GalleryItem[],
) => Promise<void>;
removeMediaFromFolder: (
itemIds: string[],
folderId: string,
) => Promise<void>;
openNewFolderModal: (parentId: string | null) => void;
closeNewFolderModal: () => void;
setRenameTarget: (folderId: string | null) => void;
setContextMenu: (
menu: { folderId: string; x: number; y: number } | null,
) => void;
}
// ── Singletons + mappers ────────────────────────────────────────────────────
const foldersApi = new FoldersApi();
const mediaFilesApi = new MediaFilesApi();
// Folder media is paginated via cursor; one scroll page at a time.
const FOLDER_PAGE_SIZE = 60;
// Non-reactive per-folder cursor + in-flight guard (singleton store).
const folderCursors: Record<string, string | undefined> = {};
const folderInFlight: Record<string, boolean> = {};
const getLabel = (item: any): string => {
if (item.maybe_title) return item.maybe_title;
switch (item.media_class) {
case "image":
return "Image Generation";
case "video":
return "Video Generation";
case "dimensional":
return "3D Mesh";
default:
return "Generation";
}
};
/** Map a raw user-media list row (origin_category shape) → GalleryItem (root library). */
export function mapRawToGalleryItem(item: any): GalleryItem {
const thumbnail =
item.media_class === "dimensional"
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template,
fullImage: item.media_links?.cdn_url ?? null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
isUpload: item.origin_category === "upload",
batchImageToken: item.maybe_batch_token,
};
}
/** Map a `FolderMediaFileListItem` → GalleryItem (folder view; carries media_links inline). */
function mapFolderListItemToGalleryItem(
item: FolderMediaFileListItem,
): GalleryItem {
const thumbnail =
item.media_class === "dimensional"
? (item.cover_image?.maybe_cover_image_public_bucket_url ?? null)
: getMediaThumbnail(item.media_links, item.media_class, {
size: THUMBNAIL_SIZES.LARGE,
});
return {
id: item.token,
label: getLabel(item),
thumbnail,
thumbnailUrlTemplate: item.media_links?.maybe_thumbnail_template ?? undefined,
fullImage: item.media_links?.cdn_url ?? null,
createdAt: item.created_at,
mediaClass: item.media_class || "image",
isUpload: !!item.is_user_upload,
batchImageToken: item.maybe_batch_token ?? undefined,
};
}
// Shared API-folder → UI-folder mapper (UiFolder ≡ GalleryFolder; coalesce
// parentId so the optional GalleryFolder field satisfies UiFolder).
const mapFolder = (f: FolderInfo): UiFolder => {
const g = mapFolderInfo(f);
return { ...g, parentId: g.parentId ?? null };
};
const errMsg = (err: unknown) =>
err instanceof Error ? err.message : String(err);
// NB: only `collageUrls` is touched — `coverUrl` is the user's *custom* cover
// (`maybe_custom_cover_thumbnail`) and must never be derived from the collage,
// or the chip would render one full-bleed image instead of the 2×2 grid.
/** Prepend added items' still-thumbnails to a folder's auto collage (optimistic). */
const withBumpedCollage = (
folders: UiFolder[],
folderId: string,
items: GalleryItem[],
): UiFolder[] =>
folders.map((f) =>
f.id === folderId
? {
...f,
collageUrls: mergeCollageUrls(
f.collageUrls,
items.map(galleryItemToCollageUrl),
),
}
: f,
);
/** Drop removed items' thumbnails from a folder's auto collage. */
const withDroppedCollage = (
folders: UiFolder[],
folderId: string,
removeUrls: Set<string>,
): UiFolder[] =>
folders.map((f) =>
f.id === folderId
? { ...f, collageUrls: (f.collageUrls ?? []).filter((u) => !removeUrls.has(u)) }
: f,
);
const collageUrlSet = (items: GalleryItem[]): Set<string> =>
new Set(
items.map(galleryItemToCollageUrl).filter((u): u is string => !!u),
);
// ── Store ───────────────────────────────────────────────────────────────────
export const useLibraryFoldersStore = create<LibraryFoldersState>(
(set, get) => ({
folders: [],
foldersLoaded: false,
activeFolderId: null,
folderMediaItems: {},
folderContentLoading: false,
folderLoadingMore: false,
folderHasMore: {},
newFolderModal: { open: false, parentId: null },
renameTarget: null,
contextMenu: null,
loadFolders: async () => {
try {
const all: FolderInfo[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < 50; page++) {
const res = await foldersApi.ListAllFolders({ cursor });
if (!res.success || !res.data) break;
all.push(...res.data);
const next = res.pagination?.maybe_cursor;
if (!next) break;
cursor = next ?? undefined;
}
set({ folders: all.map(mapFolder), foldersLoaded: true });
} catch (err) {
console.error("Failed to load folders:", err);
set({ foldersLoaded: true });
}
},
setActiveFolder: (id) => {
set({ activeFolderId: id, contextMenu: null });
if (id) get().loadFolderMedia(id, true);
},
// Resolve a folder's media one cursor page at a time. `reset` starts over.
loadFolderMedia: async (folderId, reset = false) => {
if (folderInFlight[folderId]) return;
if (!reset && get().folderHasMore[folderId] === false) return;
folderInFlight[folderId] = true;
if (reset) {
folderCursors[folderId] = undefined;
set((s) => ({
folderContentLoading: true,
folderHasMore: { ...s.folderHasMore, [folderId]: true },
}));
} else {
set({ folderLoadingMore: true });
}
try {
const listRes = await foldersApi.ListFolderMediaFiles({
folderToken: folderId,
query: {
cursor: reset ? undefined : folderCursors[folderId],
limit: FOLDER_PAGE_SIZE,
},
});
if (!listRes.success || !listRes.data) return;
const nextCursor = listRes.pagination?.maybe_cursor ?? undefined;
folderCursors[folderId] = nextCursor;
// The list item carries media_links/cover, so map directly — no batch-get.
const ordered = listRes.data.map(mapFolderListItemToGalleryItem);
set((s) => {
const existing = reset ? [] : (s.folderMediaItems[folderId] ?? []);
const seen = new Set(existing.map((i) => i.id));
const merged = [
...existing,
...ordered.filter((i) => !seen.has(i.id)),
];
return {
folderMediaItems: { ...s.folderMediaItems, [folderId]: merged },
folderHasMore: { ...s.folderHasMore, [folderId]: !!nextCursor },
};
});
} catch (err) {
console.error("Failed to load folder media:", err);
} finally {
folderInFlight[folderId] = false;
set({ folderContentLoading: false, folderLoadingMore: false });
}
},
createFolder: async (name, parentId) => {
const trimmed = name.trim();
if (!trimmed) return;
try {
const res = await foldersApi.CreateFolder({
name: trimmed,
maybe_parent_folder_token: parentId,
});
if (res.success && res.data) {
set((s) => ({ folders: [...s.folders, mapFolder(res.data!)] }));
} else {
toast.error(res.errorMessage || "Failed to create folder.");
}
} catch (err) {
toast.error(`Failed to create folder: ${errMsg(err)}`);
}
},
renameFolder: async (folderId, name) => {
const trimmed = name.trim();
if (!trimmed) return;
set((s) => ({
folders: s.folders.map((f) =>
f.id === folderId ? { ...f, name: trimmed } : f,
),
}));
try {
const res = await foldersApi.RenameFolder({
folderToken: folderId,
newName: trimmed,
});
if (!res.success) {
toast.error(res.errorMessage || "Failed to rename folder.");
get().loadFolders();
}
} catch (err) {
toast.error(`Failed to rename folder: ${errMsg(err)}`);
get().loadFolders();
}
},
setFolderStar: async (folderId, hasStar) => {
set((s) => ({
folders: s.folders.map((f) =>
f.id === folderId ? { ...f, hasStar } : f,
),
}));
try {
const res = await foldersApi.SetStar({ folderToken: folderId, hasStar });
if (!res.success) {
toast.error(res.errorMessage || "Failed to update folder.");
get().loadFolders();
}
} catch (err) {
toast.error(`Failed to update folder: ${errMsg(err)}`);
get().loadFolders();
}
},
setFolderColor: async (folderId, colorCode) => {
set((s) => ({
folders: s.folders.map((f) =>
f.id === folderId ? { ...f, colorCode } : f,
),
}));
try {
const res = await foldersApi.SetColorCode({
folderToken: folderId,
colorCode,
});
if (!res.success) {
toast.error(res.errorMessage || "Failed to update folder color.");
get().loadFolders();
}
} catch (err) {
toast.error(`Failed to update folder color: ${errMsg(err)}`);
get().loadFolders();
}
},
deleteFolder: async (folderId) => {
const folder = get().folders.find((f) => f.id === folderId);
// Optimistic: drop the folder and reparent its direct children to root
// (mirrors the server orphaning them), and forget its media cache.
set((s) => {
const nextMedia = { ...s.folderMediaItems };
delete nextMedia[folderId];
return {
folders: s.folders
.filter((f) => f.id !== folderId)
.map((f) => (f.parentId === folderId ? { ...f, parentId: null } : f)),
folderMediaItems: nextMedia,
activeFolderId:
s.activeFolderId === folderId
? (folder?.parentId ?? null)
: s.activeFolderId,
};
});
try {
const res = await foldersApi.DeleteFolder({ folderToken: folderId });
if (!res.success) {
toast.error(res.errorMessage || "Failed to delete folder.");
}
} catch (err) {
toast.error(`Failed to delete folder: ${errMsg(err)}`);
}
get().loadFolders();
},
addMediaToFolder: async (itemIds, folderId, known) => {
if (itemIds.length === 0) return;
const knownById = new Map(known.map((it) => [it.id, it] as const));
const addedItems = itemIds
.map((id) => knownById.get(id))
.filter((it): it is GalleryItem => !!it);
set((s) => {
const folders = withBumpedCollage(s.folders, folderId, addedItems);
const existing = s.folderMediaItems[folderId];
if (!existing) return { folders }; // media not loaded → fetched on open
const seen = new Set(existing.map((i) => i.id));
const fresh = addedItems.filter((i) => !seen.has(i.id));
return {
folders,
folderMediaItems: fresh.length
? { ...s.folderMediaItems, [folderId]: [...fresh, ...existing] }
: s.folderMediaItems,
};
});
try {
const res = await foldersApi.AddMediaFiles({
folderToken: folderId,
mediaFileTokens: itemIds,
});
if (res.success) {
const name =
get().folders.find((f) => f.id === folderId)?.name ?? "folder";
toast.success(
`Added ${itemIds.length} item${itemIds.length === 1 ? "" : "s"} to ${name}`,
);
} else {
toast.error(res.errorMessage || "Failed to add to folder.");
}
} catch (err) {
toast.error(`Failed to add to folder: ${errMsg(err)}`);
}
},
moveMediaToFolder: async (itemIds, source, target, known) => {
if (itemIds.length === 0) return;
const knownById = new Map(known.map((it) => [it.id, it] as const));
const movedItems = itemIds
.map((id) => knownById.get(id))
.filter((it): it is GalleryItem => !!it);
const idSet = new Set(itemIds);
const movedUrls = collageUrlSet(movedItems);
set((s) => {
const fmi = { ...s.folderMediaItems };
if (fmi[source]) {
fmi[source] = fmi[source].filter((it) => !idSet.has(it.id));
}
if (fmi[target]) {
const seen = new Set(fmi[target].map((i) => i.id));
fmi[target] = [
...movedItems.filter((i) => !seen.has(i.id)),
...fmi[target],
];
}
let folders = withBumpedCollage(s.folders, target, movedItems);
folders = withDroppedCollage(folders, source, movedUrls);
return { folderMediaItems: fmi, folders };
});
try {
const res = await foldersApi.MoveMediaFiles({
folderToken: target,
sourceFolderToken: source,
mediaFileTokens: itemIds,
});
if (res.success) {
const name =
get().folders.find((f) => f.id === target)?.name ?? "folder";
toast.success(
`Moved ${itemIds.length} item${itemIds.length === 1 ? "" : "s"} to ${name}`,
);
} else {
toast.error(res.errorMessage || "Failed to move items.");
get().loadFolders();
}
} catch (err) {
toast.error(`Failed to move items: ${errMsg(err)}`);
get().loadFolders();
}
},
removeMediaFromFolder: async (itemIds, folderId) => {
if (itemIds.length === 0) return;
const idSet = new Set(itemIds);
const existing = get().folderMediaItems[folderId] ?? [];
const removedUrls = collageUrlSet(
existing.filter((it) => idSet.has(it.id)),
);
set((s) => ({
folderMediaItems: s.folderMediaItems[folderId]
? {
...s.folderMediaItems,
[folderId]: s.folderMediaItems[folderId].filter(
(it) => !idSet.has(it.id),
),
}
: s.folderMediaItems,
folders: withDroppedCollage(s.folders, folderId, removedUrls),
}));
try {
const res = await foldersApi.RemoveMediaFiles({
folderToken: folderId,
mediaFileTokens: itemIds,
});
if (!res.success) {
toast.error(res.errorMessage || "Failed to remove from folder.");
get().loadFolders();
}
} catch (err) {
toast.error(`Failed to remove from folder: ${errMsg(err)}`);
get().loadFolders();
}
},
openNewFolderModal: (parentId) =>
set({ newFolderModal: { open: true, parentId }, contextMenu: null }),
closeNewFolderModal: () =>
set({ newFolderModal: { open: false, parentId: null } }),
setRenameTarget: (folderId) => set({ renameTarget: folderId, contextMenu: null }),
setContextMenu: (menu) => set({ contextMenu: menu }),
}),
);
/** Delete a media file and drop it from any cached folder views. */
export async function deleteLibraryMedia(mediaToken: string): Promise<boolean> {
try {
const res = await mediaFilesApi.DeleteMediaFileByToken({
mediaFileToken: mediaToken,
asMod: false,
});
if (res.success) {
useLibraryFoldersStore.setState((s) => {
const next: Record<string, GalleryItem[]> = {};
for (const [k, items] of Object.entries(s.folderMediaItems)) {
next[k] = items.filter((it) => it.id !== mediaToken);
}
return { folderMediaItems: next };
});
}
return res.success;
} catch (err) {
console.error("Failed to delete media:", err);
return false;
}
}
File diff suppressed because it is too large Load Diff
@@ -37,7 +37,6 @@ module.exports = {
white: colors.white,
gray: colors.gray,
black: colors.black,
rose: colors.rose,
red: { ...colors.red, DEFAULT: "#D33242" },
blue: { ...colors.blue, DEFAULT: "#2E70FF" },
orange: colors.orange,
-2
View File
@@ -25,8 +25,6 @@ export * from "./lib/GetCdnOrigin.js";
export * from "./lib/GalleryModalApi.js";
export * from "./lib/OmniGenApi.js";
export * from "./lib/CharactersApi.js";
export * from "./lib/FoldersApi.js";
export * from "./lib/models/Character.js";
export * from "./lib/models/Folder.js";
export * from "./lib/config/StorytellerApiHostStore.js";
export * from "./lib/enums/EIntermediateFile.js";
-16
View File
@@ -158,22 +158,6 @@ export class ApiManager {
});
}
protected put<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "PUT",
query,
body,
});
}
protected delete<B, T>({
endpoint,
query,
-400
View File
@@ -1,400 +0,0 @@
import { ApiManager, ApiResponse } from "./ApiManager.js";
import { FolderInfo, FolderMediaFileListItem } from "./models/Folder.js";
// ─── Request types ────────────────────────────────────────────────────────────
export interface CreateFolderRequest {
name: string;
/** Omit (or null) to create a root-level folder. */
maybe_parent_folder_token?: string | null;
maybe_color_code?: string | null;
}
/** Cursor-based paging shared by `list_all`, subfolders, and folder media files. */
export interface ListFoldersQuery {
cursor?: string;
limit?: number;
}
/** Opaque cursor returned by the paged list endpoints; pass back as `cursor`. */
export interface FolderCursor {
maybe_cursor?: string | null;
}
export interface MoveMediaFilesResult {
accepted_media_file_tokens: string[];
added_to_destination_count: number;
removed_from_source_count: number;
}
// ─── API ───────────────────────────────────────────────────────────────────────
/**
* Client for the `/v1/folders/*` endpoints. Folders nest arbitrarily (a folder
* lives under at most one parent); media files may belong to zero or more
* folders. All bulk endpoints are idempotent server-side. Paging is cursor
* based — pass the previous response's `maybe_cursor` back as `cursor`.
*/
export class FoldersApi extends ApiManager {
// ── Create / read / delete ────────────────────────────────────────────────
/** Create a folder owned by the logged-in user, optionally under a parent. */
public CreateFolder(
params: CreateFolderRequest,
): Promise<ApiResponse<FolderInfo>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/create`;
return this.post<
CreateFolderRequest,
{ success: boolean; folder: FolderInfo; message?: string }
>({ endpoint, body: params })
.then((response) => ({
success: response.success ?? false,
data: response.folder,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/** Fetch a single folder owned by the logged-in user. */
public GetFolder({
folderToken,
}: {
folderToken: string;
}): Promise<ApiResponse<FolderInfo>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/folder/${folderToken}`;
return this.get<{ success: boolean; folder: FolderInfo }>({ endpoint })
.then((response) => ({ success: response.success, data: response.folder }))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/**
* Soft-delete a folder. Its children keep their parent pointer and become
* "orphaned" (surfaced via `list_all`'s `is_orphaned` flag); media files stay
* in the library and in any other folders.
*/
public DeleteFolder({
folderToken,
}: {
folderToken: string;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/folder/${folderToken}`;
return this.delete<null, { success: boolean; message?: string }>({
endpoint,
})
.then((response) => ({
success: response.success ?? false,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/** List every live folder owned by the logged-in user (newest first). */
public ListAllFolders(
query: ListFoldersQuery = {},
): Promise<ApiResponse<FolderInfo[], FolderCursor>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/list_all`;
return this.get<{
success: boolean;
folders: FolderInfo[];
maybe_cursor?: string | null;
}>({ endpoint, query: toCursorQuery(query) })
.then((response) => ({
success: response.success,
data: response.folders ?? [],
pagination: { maybe_cursor: response.maybe_cursor },
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// ── Folder mutations ──────────────────────────────────────────────────────
/** Set or clear the folder color (hex, named color, or any string; null clears). */
public SetColorCode({
folderToken,
colorCode,
}: {
folderToken: string;
colorCode: string | null;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/folder/${folderToken}/color_code`;
return this.put<
{ maybe_color_code: string | null },
{ success: boolean; message?: string }
>({ endpoint, body: { maybe_color_code: colorCode } })
.then((response) => ({
success: response.success ?? false,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/**
* Set or clear the folder's custom cover image (null clears). The server
* resolves the supplied media file to a usable cover token (image/video used
* directly, otherwise the file's own cover image); the resolved token is
* returned as `data`.
*/
public SetCoverImage({
folderToken,
mediaFileToken,
}: {
folderToken: string;
mediaFileToken: string | null;
}): Promise<ApiResponse<string | null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/folder/${folderToken}/cover_image`;
return this.put<
{ maybe_media_file_token: string | null },
{
success: boolean;
maybe_resolved_cover_media_file_token?: string | null;
message?: string;
}
>({ endpoint, body: { maybe_media_file_token: mediaFileToken } })
.then((response) => ({
success: response.success ?? false,
data: response.maybe_resolved_cover_media_file_token ?? null,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/** Rename a folder. */
public RenameFolder({
folderToken,
newName,
}: {
folderToken: string;
newName: string;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/folder/${folderToken}/rename`;
return this.put<
{ new_name: string },
{ success: boolean; message?: string }
>({ endpoint, body: { new_name: newName } })
.then((response) => ({
success: response.success ?? false,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/** Star or unstar a folder. */
public SetStar({
folderToken,
hasStar,
}: {
folderToken: string;
hasStar: boolean;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/folder/${folderToken}/star`;
return this.put<
{ has_star: boolean },
{ success: boolean; message?: string }
>({ endpoint, body: { has_star: hasStar } })
.then((response) => ({
success: response.success ?? false,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// ── Subfolders ────────────────────────────────────────────────────────────
/** List the direct subfolders of a parent folder (paginated). */
public ListSubfolders({
folderToken,
query = {},
}: {
folderToken: string;
query?: ListFoldersQuery;
}): Promise<ApiResponse<FolderInfo[], FolderCursor>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/subfolders/${folderToken}`;
return this.get<{
success: boolean;
subfolders: FolderInfo[];
maybe_cursor?: string | null;
}>({ endpoint, query: toCursorQuery(query) })
.then((response) => ({
success: response.success,
data: response.subfolders ?? [],
pagination: { maybe_cursor: response.maybe_cursor },
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/**
* Reparent folders under `folderToken`. A folder lives in exactly one parent,
* so this "moves" each into the target. Idempotent; returns the tokens that
* stuck (`accepted_subfolder_tokens`).
*/
public AddSubfolders({
folderToken,
subfolderTokens,
}: {
folderToken: string;
subfolderTokens: string[];
}): Promise<ApiResponse<string[]>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/subfolders/${folderToken}/bulk_add`;
return this.post<
{ subfolder_tokens: string[] },
{
success: boolean;
accepted_subfolder_tokens?: string[];
message?: string;
}
>({ endpoint, body: { subfolder_tokens: subfolderTokens } })
.then((response) => ({
success: response.success ?? false,
data: response.accepted_subfolder_tokens ?? [],
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/** Unparent folders from `folderToken` (they become root-level). Idempotent. */
public RemoveSubfolders({
folderToken,
subfolderTokens,
}: {
folderToken: string;
subfolderTokens: string[];
}): Promise<ApiResponse<number>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/subfolders/${folderToken}/bulk_remove`;
return this.post<
{ subfolder_tokens: string[] },
{ success: boolean; removed_count?: number; message?: string }
>({ endpoint, body: { subfolder_tokens: subfolderTokens } })
.then((response) => ({
success: response.success ?? false,
data: response.removed_count ?? 0,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// ── Media files in a folder ───────────────────────────────────────────────
/** List the media files inside a folder (paginated, newest first). */
public ListFolderMediaFiles({
folderToken,
query = {},
}: {
folderToken: string;
query?: ListFoldersQuery;
}): Promise<ApiResponse<FolderMediaFileListItem[], FolderCursor>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/media_files/${folderToken}`;
return this.get<{
success: boolean;
media_files: FolderMediaFileListItem[];
maybe_cursor?: string | null;
}>({ endpoint, query: toCursorQuery(query) })
.then((response) => ({
success: response.success,
data: response.media_files ?? [],
pagination: { maybe_cursor: response.maybe_cursor },
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/**
* Add media files to a folder. Idempotent (INSERT IGNORE); tokens that don't
* exist or are soft-deleted are silently skipped — returns the accepted tokens.
*/
public AddMediaFiles({
folderToken,
mediaFileTokens,
}: {
folderToken: string;
mediaFileTokens: string[];
}): Promise<ApiResponse<string[]>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/media_files/${folderToken}/bulk_add`;
return this.post<
{ media_file_tokens: string[] },
{
success: boolean;
accepted_media_file_tokens?: string[];
message?: string;
}
>({ endpoint, body: { media_file_tokens: mediaFileTokens } })
.then((response) => ({
success: response.success ?? false,
data: response.accepted_media_file_tokens ?? [],
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/**
* Atomically move media files from `sourceFolderToken` to `folderToken`
* (destination) in a single transaction. Idempotent on both sides.
*/
public MoveMediaFiles({
folderToken,
sourceFolderToken,
mediaFileTokens,
}: {
folderToken: string;
sourceFolderToken: string;
mediaFileTokens: string[];
}): Promise<ApiResponse<MoveMediaFilesResult>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/media_files/${folderToken}/bulk_move`;
return this.post<
{ media_file_tokens: string[]; source_folder: string },
{
success: boolean;
accepted_media_file_tokens?: string[];
added_to_destination_count?: number;
removed_from_source_count?: number;
message?: string;
}
>({
endpoint,
body: {
media_file_tokens: mediaFileTokens,
source_folder: sourceFolderToken,
},
})
.then((response) => ({
success: response.success ?? false,
data: {
accepted_media_file_tokens: response.accepted_media_file_tokens ?? [],
added_to_destination_count: response.added_to_destination_count ?? 0,
removed_from_source_count: response.removed_from_source_count ?? 0,
},
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
/**
* Remove media files from a folder. Hard-deletes the membership rows only;
* the files stay in the library and in any other folders. Idempotent.
*/
public RemoveMediaFiles({
folderToken,
mediaFileTokens,
}: {
folderToken: string;
mediaFileTokens: string[];
}): Promise<ApiResponse<number>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/folders/media_files/${folderToken}/bulk_remove`;
return this.post<
{ media_file_tokens: string[] },
{ success: boolean; removed_count?: number; message?: string }
>({ endpoint, body: { media_file_tokens: mediaFileTokens } })
.then((response) => ({
success: response.success ?? false,
data: response.removed_count ?? 0,
errorMessage: response.message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
}
/** Flatten the cursor query into the index-signed record `get()` expects. */
const toCursorQuery = (
query: ListFoldersQuery,
): Record<string, string | number | undefined> => ({
cursor: query.cursor,
limit: query.limit,
});
@@ -1,97 +0,0 @@
/**
* Rich CDN links for a media file (full asset URL, resize template, and
* animated video preview when applicable). Shared by the folder media-file
* list items. The `maybe_thumbnail_template` carries `{WIDTH}`/`{HEIGHT}`
* placeholders for `getThumbnailUrl`.
*/
export interface MediaLinks {
cdn_url: string;
maybe_thumbnail_template?: string | null;
maybe_video_previews?: {
animated?: string | null;
cdn_url?: string | null;
maybe_thumbnail_template?: string | null;
} | null;
}
/**
* Cover-image details for a media file. For files without their own cover the
* backend still returns a deterministic default-cover spec the UI uses for
* placeholders.
*/
export interface MediaFileCoverImageDetails {
maybe_cover_image_public_bucket_url?: string | null;
maybe_cover_image_public_bucket_path?: string | null;
default_cover?: {
image_index: number;
color_index: number;
};
}
/**
* A compact thumbnail descriptor for a media file referenced by a folder
* (one of the `last_media_*` slots or the custom cover). `cdn_url` is the
* direct asset link; `maybe_thumbnail_template` is the resize template
* (`{WIDTH}`/`{HEIGHT}`) when the media class supports it.
*/
export interface FolderThumbnail {
token: string;
media_class: string;
media_type: string;
cdn_url: string;
maybe_thumbnail_template?: string | null;
}
/**
* Canonical wire shape for a folder — used by single-folder GETs, create
* responses, the `list_all` rows, and subfolder list rows. Folders nest via
* `maybe_parent_folder_token` (null = root).
*/
export interface FolderInfo {
token: string;
name: string;
owner_user_token: string;
/** Parent folder token, or null for a root-level folder. */
maybe_parent_folder_token?: string | null;
/**
* The (up to four) most-recent media files in the folder, slot order.
* Deleted slots are skipped, so 0–4 entries. Used for an auto cover collage.
*/
last_media_thumbnails: FolderThumbnail[];
/** User-chosen cover image — when present, use it as the primary thumbnail. */
maybe_custom_cover_thumbnail?: FolderThumbnail | null;
/** Hex code, named color, or any string the user picked. Theme-aware UI. */
maybe_color_code?: string | null;
has_star: boolean;
/** True when the parent pointer is set but the referenced parent is missing/soft-deleted. */
is_orphaned: boolean;
created_at: string;
updated_at: string;
}
/**
* One media file as it appears inside a folder. Carries rich `media_links`
* (and cover details), so callers can render thumbnails directly without a
* second batch-get round trip.
*/
export interface FolderMediaFileListItem {
token: string;
media_class: string;
media_type: string;
maybe_prompt_token?: string | null;
maybe_batch_token?: string | null;
media_links: MediaLinks;
cover_image?: MediaFileCoverImageDetails;
maybe_title?: string | null;
maybe_original_filename?: string | null;
maybe_frame_width?: number | null;
maybe_frame_height?: number | null;
maybe_duration_millis?: number | null;
creator_set_visibility?: string;
is_user_upload?: boolean;
is_intermediate_system_file?: boolean;
created_at: string;
updated_at: string;
/** When the media file was added to the folder. */
added_to_folder_at: string;
}
@@ -1,12 +1,4 @@
export * from "./lib/gallery-modal";
export * from "./lib/galleryModalSignals";
export * from "./lib/GalleryDragComponent";
export * from "./lib/GalleryDraggableItem";
export * from "./lib/GalleryItemMenuItems";
export * from "./lib/GalleryFolderChip";
export * from "./lib/FolderColorRow";
export * from "./lib/FolderNameDialog";
export * from "./lib/folderUtils";
export * from "./lib/folderMapping";
export * from "./lib/promptFolderDrop";
export * from "./lib/galleryDnd";
@@ -1,66 +0,0 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { FOLDER_COLOR_PRESETS } from "./folderUtils";
interface FolderColorRowProps {
colorCode?: string | null;
onSetColor: (color: string | null) => void;
}
/**
* Preset color swatches + a clear option + a native custom-color picker, shared
* by the desktop and webapp folder context menus.
*/
export const FolderColorRow: React.FC<FolderColorRowProps> = ({
colorCode,
onSetColor,
}) => (
<div className="flex flex-wrap items-center gap-1.5 px-2 py-2">
{FOLDER_COLOR_PRESETS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => onSetColor(c)}
style={{ backgroundColor: c }}
className={twMerge(
"h-5 w-5 rounded-full border border-black/20 transition-transform hover:scale-110",
colorCode === c &&
"ring-2 ring-white ring-offset-1 ring-offset-ui-panel",
)}
/>
))}
{/* Clear color */}
<button
type="button"
aria-label="Clear color"
onClick={() => onSetColor(null)}
className={twMerge(
"flex h-5 w-5 items-center justify-center rounded-full border border-ui-panel-border text-base-fg/50 hover:text-base-fg",
!colorCode && "ring-2 ring-white ring-offset-1 ring-offset-ui-panel",
)}
>
<FontAwesomeIcon icon={faXmark} className="text-[10px]" />
</button>
{/* Custom hex (native picker) */}
<label
title="Custom color"
className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-ui-panel-border"
style={{
background:
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
}}
>
<input
type="color"
value={
colorCode && /^#[0-9a-fA-F]{6}$/.test(colorCode) ? colorCode : "#888888"
}
onChange={(e) => onSetColor(e.target.value)}
className="absolute -inset-1 h-[150%] w-[150%] cursor-pointer opacity-0"
/>
</label>
</div>
);
@@ -1,87 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
interface FolderNameDialogProps {
isOpen: boolean;
title: string;
/** Small grey line under the title (e.g. "in My Library"). */
subtitle?: string;
initialValue?: string;
confirmLabel: string;
onConfirm: (name: string) => void;
onClose: () => void;
}
/**
* Name-input dialog (New folder / Rename folder) built on the shared `Modal`
* so it gets the same fade/scale animation, backdrop, stacking, and ESC handling
* as every other modal. Used by both the desktop gallery modal and the webapp.
*/
export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
isOpen,
title,
subtitle,
initialValue = "",
confirmLabel,
onConfirm,
onClose,
}) => {
const [value, setValue] = useState(initialValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isOpen) return;
setValue(initialValue);
const t = setTimeout(() => inputRef.current?.select(), 60);
return () => clearTimeout(t);
}, [isOpen, initialValue]);
const submit = () => {
const name = value.trim();
if (name) onConfirm(name);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
accessibleTitle={title}
className="w-80 max-w-[90vw]"
>
<div className="space-y-3">
{subtitle && (
<p className="-mt-2 text-xs text-base-fg/40">{subtitle}</p>
)}
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
className="w-full rounded-md border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
variant="action"
onClick={onClose}
className="px-3 py-1 text-sm"
>
Cancel
</Button>
<Button
onClick={submit}
disabled={!value.trim()}
className="px-3 py-1 text-sm"
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
);
};
@@ -1,19 +1,18 @@
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import React, { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck, faEllipsis, faUpload } from "@fortawesome/pro-solid-svg-icons";
import {
faCheck,
faEllipsis,
faPencil,
faTrashCan,
faUpload,
} from "@fortawesome/pro-solid-svg-icons";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { twMerge } from "tailwind-merge";
import galleryDnd from "./galleryDnd";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryItem } from "./gallery-modal";
import { PopoverMenu } from "@storyteller/ui-popover";
import { GalleryItemMenuItems } from "./GalleryItemMenuItems";
import {
showActionReminder,
isActionReminderOpen,
@@ -22,20 +21,6 @@ import { toast } from "@storyteller/ui-toaster";
type ModalMode = "select" | "view";
export interface GalleryFolder {
id: string;
name: string;
/** Parent folder id, or null/undefined for a root-level folder. */
parentId?: string | null;
hasStar?: boolean;
/** Arbitrary color string (hex / named). Applied via inline style, never a class. */
colorCode?: string | null;
/** Resolved url of the user-chosen cover image, when set. */
coverUrl?: string | null;
/** Up to four resolved thumbnail urls for an auto cover collage. */
collageUrls?: string[];
}
interface GalleryDraggableItemProps {
item: GalleryItem;
mode: ModalMode;
@@ -53,12 +38,6 @@ interface GalleryDraggableItemProps {
bulkSelected?: boolean;
onBulkSelectToggle?: () => void;
bulkSelectionMode?: boolean;
getBulkDragItems?: () => GalleryItem[];
folders?: GalleryFolder[];
onAddToFolder?: (itemIds: string[], folderId: string) => void;
onCreateFolderFromMenu?: () => void;
/** Remove from the folder currently being viewed. Presence = inside a folder. */
onRemoveFromFolder?: (itemIds: string[]) => void;
}
export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
@@ -77,16 +56,9 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
bulkSelected = false,
onBulkSelectToggle,
bulkSelectionMode = false,
getBulkDragItems,
folders = [],
onAddToFolder,
onCreateFolderFromMenu,
onRemoveFromFolder,
}) => {
const imgRef = useRef<HTMLImageElement>(null);
const dragStarted = useRef(false);
// Right-click context menu position (null = closed).
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
// For freshly-completed videos the backend may still be generating the
// preview GIF, so the thumbnail URL 404s for a while. Show a spinner and
@@ -158,39 +130,14 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
});
};
// Right-click context menu: clamp into the viewport + close on Escape.
const ctxPanelRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!ctxMenu || !ctxPanelRef.current) return;
const el = ctxPanelRef.current;
const rect = el.getBoundingClientRect();
const x =
ctxMenu.x + rect.width > window.innerWidth
? Math.max(8, window.innerWidth - rect.width - 8)
: ctxMenu.x;
const y =
ctxMenu.y + rect.height > window.innerHeight
? Math.max(8, window.innerHeight - rect.height - 8)
: ctxMenu.y;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
}, [ctxMenu]);
useEffect(() => {
if (!ctxMenu) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setCtxMenu(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [ctxMenu]);
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
// Left button only — right/middle click must not trigger the click→lightbox
// (right-click opens the context menu via onContextMenu instead).
if (event.button !== 0) return;
// No drag tracking on touch: a finger drag should scroll the page, and a
// tap still clicks via the button's onClick.
if (event.pointerType === "touch") return;
// Disable dragging for video items, but allow clicks in select mode
if (item.mediaClass === "video" && mode !== "select") return;
// In bulk selection mode, skip drag — clicks toggle selection
if (bulkSelectionMode) {
dragStarted.current = false;
return;
}
dragStarted.current = false;
const moveListener = (moveEvent: PointerEvent) => {
const dx = moveEvent.pageX - event.pageX;
@@ -198,37 +145,41 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
if (!dragStarted.current && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
dragStarted.current = true;
if (galleryDnd && !disableTooltipAndBadge) {
const bulkItems =
bulkSelectionMode && bulkSelected && getBulkDragItems
? getBulkDragItems()
: undefined;
galleryDnd.onPointerDown(event, item, bulkItems);
galleryDnd.onPointerDown(event, item);
}
window.removeEventListener("pointermove", moveListener);
}
};
window.addEventListener("pointermove", moveListener);
const upListener = () => {
// Cleanup only — the click itself is handled once by handleButtonClick
// (multiple firing paths used to double-toggle bulk selection).
window.removeEventListener("pointermove", moveListener);
window.removeEventListener("pointerup", upListener);
if (!dragStarted.current) {
onClick();
}
};
window.addEventListener("pointerup", upListener);
};
// Single click path for the whole tile surface: open the lightbox, or toggle
// selection when bulk-selecting / in select mode (handled by the host's onClick).
const handlePointerUp = (event: React.PointerEvent) => {
// In bulk selection mode, let only handleButtonClick fire onClick
// to avoid double-toggling the selection
if (bulkSelectionMode) return;
if (
!dragStarted.current &&
(mode === "select" || !disableTooltipAndBadge)
) {
onClick();
}
};
const handleButtonClick = (event: React.MouseEvent) => {
if (event.button !== 0) return;
if (dragStarted.current) return;
const globalDrag = galleryDnd.getDragState();
if (globalDrag.isDragging) return;
if (mode === "select" || !disableTooltipAndBadge || bulkSelectionMode) {
onClick();
}
};
// Shared button content — avoids duplicating the image/thumbnail rendering
const showTooltip = !disableTooltipAndBadge && !bulkSelectionMode;
const itemButton = (
@@ -242,13 +193,14 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
mode === "select"
mode === "select" || item.mediaClass === "video" || bulkSelectionMode
? "cursor-pointer"
: disableTooltipAndBadge && !bulkSelectionMode
: disableTooltipAndBadge
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onClick={handleButtonClick}
aria-label={item.label}
>
@@ -296,20 +248,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
);
return (
<div
className="group relative w-full aspect-square"
data-media-id={item.id}
onContextMenu={(e) => {
if (mode === "select") return;
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY });
}}
>
<div className="group relative w-full aspect-square">
{/* dropdown menu */}
{mode !== "select" && (
<div
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100 transition-opacity duration-75"
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-75"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
@@ -323,62 +266,51 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="w-max min-w-44 p-1"
panelClassName="min-w-28 p-1"
closeOnUnhover
>
{(close) => (
<GalleryItemMenuItems
item={item}
folders={folders}
onOpen={onClick}
onEditClicked={onEditClicked}
onAddToFolder={onAddToFolder}
onCreateFolderFromMenu={onCreateFolderFromMenu}
onRemoveFromFolder={onRemoveFromFolder}
onDelete={onDelete ? handleDelete : undefined}
close={close}
/>
<div className="flex flex-col">
{item.mediaClass === "image" && (
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
onClick={async (e) => {
e.stopPropagation();
if (onEditClicked && (item.fullImage || item.thumbnail)) {
await onEditClicked(
item.fullImage || item.thumbnail!,
item.id,
);
}
close();
}}
>
<FontAwesomeIcon icon={faPencil} className="text-base-fg" />
<span>Edit image</span>
</button>
)}
{onDelete && (
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
close();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
<span className="text-red">Delete</span>
</button>
)}
</div>
)}
</PopoverMenu>
</div>
)}
{/* Right-click context menu (portaled to body, positioned at cursor) */}
{ctxMenu &&
createPortal(
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => setCtxMenu(null)}
onContextMenu={(e) => {
e.preventDefault();
setCtxMenu(null);
}}
/>
<div
ref={ctxPanelRef}
className="fixed z-[9999] w-max min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
>
<GalleryItemMenuItems
item={item}
folders={folders}
onOpen={onClick}
onEditClicked={onEditClicked}
onAddToFolder={onAddToFolder}
onCreateFolderFromMenu={onCreateFolderFromMenu}
onRemoveFromFolder={onRemoveFromFolder}
onDelete={onDelete ? handleDelete : undefined}
close={() => setCtxMenu(null)}
/>
</div>
</>,
document.body,
)}
{/* legacy delete button placeholder (kept conditional below) */}
{/* Bulk selection checkbox — top-left (only when a bulk handler is wired) */}
{mode !== "select" && onBulkSelectToggle && (
{/* Bulk selection checkbox — top-left */}
{mode !== "select" && (
<div
className={twMerge(
"absolute left-2 top-2 z-30 flex h-5 w-5 items-center justify-center rounded border-2 cursor-pointer transition-all duration-100",
@@ -387,7 +319,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
: "border-white/60 bg-black/40 hover:border-white",
bulkSelectionMode
? "opacity-100"
: "opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100",
: "opacity-0 group-hover:opacity-100",
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -423,14 +355,16 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
className="-mt-3 bg-ui-controls text-base-fg border border-ui-panel-border"
content={
<div className="flex flex-col items-center text-xs whitespace-nowrap">
<span>
<span className="font-bold">Drag</span>
<span className="opacity-50">
{item.mediaClass === "dimensional"
? " to add to scene"
: " to add"}
{item.mediaClass !== "video" && (
<span>
<span className="font-bold">Drag</span>
<span className="opacity-50">
{item.mediaClass === "dimensional"
? " to add to scene"
: " to add"}
</span>
</span>
</span>
)}
<span>
<span className="font-bold">Click</span>
<span className="opacity-50"> to view</span>
@@ -1,180 +0,0 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFolder, faEllipsis, faStar } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { GalleryFolder } from "./GalleryDraggableItem";
interface GalleryFolderChipProps {
folder: GalleryFolder;
/** Direct subfolder count, shown as a subtitle. */
childCount: number;
onOpen: (folderId: string) => void;
/**
* Opens the shared folder context menu (rename / delete / color / star /
* new subfolder). When absent (e.g. select-mode pickers) the ellipsis button
* is hidden and right-click does nothing.
*/
onContextMenu?: (folderId: string, x: number, y: number) => void;
}
/** A folder thumbnail that removes itself on error so a fallback can show. */
const ChipImg = ({ src, className }: { src: string; className: string }) => {
const [failed, setFailed] = useState(false);
if (failed)
return <div className={twMerge(className, "bg-ui-controls/30")} />;
return (
<img
src={src}
alt=""
draggable={false}
className={className}
onError={() => setFailed(true)}
/>
);
};
/**
* A folder tile rendered inside the gallery grid, sized like a gallery item
* (aspect-square). Shows, in priority: a custom cover, an auto 2×2 collage of
* recent media, or a folder icon. Carries `data-folder-id` so pointer drag-drop
* can drop media onto it; the ellipsis / right-click opens the shared menu.
*/
export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
folder,
childCount,
onOpen,
onContextMenu,
}) => {
const { colorCode, hasStar, name } = folder;
const coverUrl = folder.coverUrl;
const collageUrls = folder.collageUrls ?? [];
const hasCover = !!coverUrl;
const hasCollage = !hasCover && collageUrls.length > 0;
const hasArt = hasCover || hasCollage;
const openMenuAt = (target: HTMLElement) => {
const rect = target.getBoundingClientRect();
onContextMenu?.(folder.id, rect.right, rect.bottom);
};
return (
<button
type="button"
data-folder-id={folder.id}
onClick={() => onOpen(folder.id)}
onContextMenu={(e) => {
e.preventDefault();
onContextMenu?.(folder.id, e.clientX, e.clientY);
}}
style={colorCode ? { borderColor: colorCode } : undefined}
className={twMerge(
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-colors cursor-pointer hover:border-primary/60",
"[&.folder-drag-over]:border-primary/60 [&.folder-drag-over]:bg-primary/20",
)}
aria-label={name}
>
{/* ── Background art ── */}
{hasCover ? (
<ChipImg
src={coverUrl!}
className="absolute inset-0 h-full w-full object-cover"
/>
) : hasCollage && collageUrls.length === 1 ? (
<ChipImg
src={collageUrls[0]}
className="absolute inset-0 h-full w-full object-cover"
/>
) : hasCollage ? (
<div className="absolute inset-0 grid grid-cols-2 grid-rows-2 gap-px bg-black/20">
{Array.from({ length: 4 }).map((_, i) =>
collageUrls[i] ? (
<ChipImg
key={i}
src={collageUrls[i]}
className="h-full w-full object-cover"
/>
) : (
<div key={i} className="bg-ui-controls/30" />
),
)}
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<FontAwesomeIcon
icon={faFolder}
className={colorCode ? "text-4xl" : "text-4xl text-primary"}
style={colorCode ? { color: colorCode } : undefined}
/>
</div>
)}
{/* Legibility scrim for the label over cover/collage art */}
{hasArt && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/75 to-transparent" />
)}
{/* Star (favorite) */}
{hasStar && (
<FontAwesomeIcon
icon={faStar}
className="absolute left-2 top-2 text-sm text-amber-400 drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]"
/>
)}
{/* Name + subfolder count */}
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-0.5 p-2 text-center">
<div className="flex items-center gap-1.5 min-w-0 max-w-full">
{/* Folder marker (with the folder color) so a covered/collaged tile
still reads as a folder. */}
{hasArt && (
<FontAwesomeIcon
icon={faFolder}
className={twMerge(
"flex-shrink-0 text-sm drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]",
colorCode ? "" : "text-primary",
)}
style={colorCode ? { color: colorCode } : undefined}
/>
)}
<span
className={twMerge(
"line-clamp-2 break-words text-left text-sm font-medium",
hasArt
? "text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]"
: "text-base-fg/90",
)}
>
{name}
</span>
</div>
{childCount > 0 && (
<span
className={twMerge(
"text-left text-[10px]",
hasArt ? "text-white/70" : "text-base-fg/40",
)}
>
{childCount} folder{childCount === 1 ? "" : "s"}
</span>
)}
</div>
{/* Options menu — hidden when no menu handler is wired (select mode) */}
{onContextMenu && (
<span
role="button"
tabIndex={-1}
aria-label="Folder options"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
openMenuAt(e.currentTarget as HTMLElement);
}}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white opacity-0 transition-opacity hover:bg-black/60 group-hover/chip:opacity-100 [@media(pointer:coarse)]:opacity-100"
>
<FontAwesomeIcon icon={faEllipsis} className="text-sm" />
</span>
)}
</button>
);
};
@@ -1,249 +0,0 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faEye,
faPencil,
faTrashCan,
faFolderPlus,
faFolderMinus,
faChevronRight,
faFolder,
faPlus,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { GalleryItem } from "./gallery-modal";
import { GalleryFolder } from "./GalleryDraggableItem";
// Touch-first devices have no hover and narrow viewports — the folder submenu
// expands inline (accordion) instead of flying out past the screen edge.
const COARSE_POINTER =
typeof window !== "undefined" &&
window.matchMedia?.("(pointer: coarse)").matches === true;
export interface GalleryItemMenuItemsProps {
item: GalleryItem;
folders: GalleryFolder[];
/** Open the lightbox (shown as "Open"). Omitted by the hover menu. */
onOpen?: () => void;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
onAddToFolder?: (itemIds: string[], folderId: string) => void;
onCreateFolderFromMenu?: () => void;
/** Remove from the folder being viewed. Presence = we're inside a folder. */
onRemoveFromFolder?: (itemIds: string[]) => void;
/** Pre-wired delete (confirm flow lives in the host tile). */
onDelete?: () => void;
/** Closes the containing menu. */
close: () => void;
}
const ROW =
"flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm whitespace-nowrap";
/**
* The gallery-tile menu body (Open / Edit / Add to Folder ▸ / Remove from
* folder / Delete), shared by the hover ellipsis popover and the right-click
* context menu. Each item renders only when its handler is provided.
*/
export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
item,
folders,
onOpen,
onEditClicked,
onAddToFolder,
onCreateFolderFromMenu,
onRemoveFromFolder,
onDelete,
close,
}) => {
const [folderSubmenuOpen, setFolderSubmenuOpen] = useState(false);
return (
<div className="flex flex-col">
{onOpen && (
<button
type="button"
className={ROW}
onClick={(e) => {
e.stopPropagation();
close();
onOpen();
}}
>
<FontAwesomeIcon icon={faEye} className="text-base-fg w-4" />
<span>
{item.mediaClass === "video"
? "View video"
: item.mediaClass === "dimensional"
? "View 3D"
: "View image"}
</span>
</button>
)}
{item.mediaClass === "image" && onEditClicked && (
<button
type="button"
className={ROW}
onClick={async (e) => {
e.stopPropagation();
if (item.fullImage || item.thumbnail) {
await onEditClicked(item.fullImage || item.thumbnail!, item.id);
}
close();
}}
>
<FontAwesomeIcon icon={faPencil} className="text-base-fg w-4" />
<span>Edit image</span>
</button>
)}
{/* Add to Folder — with submenu */}
{onAddToFolder && (
<div
className="relative"
onMouseEnter={
COARSE_POINTER ? undefined : () => setFolderSubmenuOpen(true)
}
onMouseLeave={
COARSE_POINTER ? undefined : () => setFolderSubmenuOpen(false)
}
>
<button
type="button"
className="flex w-full items-center justify-between gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
setFolderSubmenuOpen((v) => !v);
}}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolderPlus} className="text-base-fg w-4" />
<span>Add to Folder</span>
</div>
<FontAwesomeIcon
icon={faChevronRight}
className={twMerge(
"text-[10px] text-base-fg/50 transition-transform",
COARSE_POINTER && folderSubmenuOpen && "rotate-90",
)}
/>
</button>
{folderSubmenuOpen &&
(COARSE_POINTER ? (
// Touch: expand inline (accordion) — a fly-out would overflow
// the screen edge on narrow viewports.
<div className="mb-1 max-h-48 overflow-y-auto rounded-md bg-ui-controls/20 p-1">
<FolderList
item={item}
folders={folders}
onAddToFolder={onAddToFolder}
onCreateFolderFromMenu={onCreateFolderFromMenu}
closeAll={() => {
setFolderSubmenuOpen(false);
close();
}}
/>
</div>
) : (
<div className="absolute left-full top-0 -ml-1 pl-2 z-50">
<div className="max-h-64 overflow-y-auto w-max min-w-36 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl">
<FolderList
item={item}
folders={folders}
onAddToFolder={onAddToFolder}
onCreateFolderFromMenu={onCreateFolderFromMenu}
closeAll={() => {
setFolderSubmenuOpen(false);
close();
}}
/>
</div>
</div>
))}
</div>
)}
{onRemoveFromFolder && (
<button
type="button"
className={ROW}
onClick={(e) => {
e.stopPropagation();
onRemoveFromFolder([item.id]);
close();
}}
>
<FontAwesomeIcon icon={faFolderMinus} className="text-base-fg w-4" />
<span>Remove from folder</span>
</button>
)}
{onDelete && (
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
close();
onDelete();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="text-red w-4" />
<span className="text-red">Delete</span>
</button>
)}
</div>
);
};
/** The folder target list inside "Add to Folder" (shared by the fly-out and the inline accordion). */
const FolderList = ({
item,
folders,
onAddToFolder,
onCreateFolderFromMenu,
closeAll,
}: {
item: GalleryItem;
folders: GalleryFolder[];
onAddToFolder?: (itemIds: string[], folderId: string) => void;
onCreateFolderFromMenu?: () => void;
closeAll: () => void;
}) => (
<>
{folders.map((folder) => (
<button
key={folder.id}
type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
onClick={(e) => {
e.stopPropagation();
onAddToFolder?.([item.id], folder.id);
closeAll();
}}
>
<FontAwesomeIcon
icon={faFolder}
className={folder.colorCode ? "text-xs" : "text-primary text-xs"}
style={folder.colorCode ? { color: folder.colorCode } : undefined}
/>
<span className="truncate">{folder.name}</span>
</button>
))}
{folders.length > 0 && (
<div className="mx-1.5 my-1 border-t border-ui-panel-border" />
)}
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 rounded-md hover:bg-ui-controls/60 text-base-fg/70 text-sm whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
closeAll();
onCreateFolderFromMenu?.();
}}
>
<FontAwesomeIcon icon={faPlus} className="text-xs w-4" />
<span>New Folder</span>
</button>
</>
);
@@ -1,60 +0,0 @@
import type { FolderInfo } from "@storyteller/api";
import { getThumbnailUrl, THUMBNAIL_SIZES } from "@storyteller/common";
import type { GalleryFolder } from "./GalleryDraggableItem";
import type { GalleryItem } from "./gallery-modal";
import { resolveFolderThumb } from "./folderUtils";
/**
* The single API-folder → UI-folder mapper, shared by the desktop modal and the
* webapp store (both `GalleryFolder` and the webapp's `UiFolder` are satisfied
* by this shape; `parentId` is normalized to `string | null`). Orphaned folders
* (parent soft-deleted) surface at root so they stay reachable.
*/
export const mapFolderInfo = (f: FolderInfo): GalleryFolder => ({
id: f.token,
name: f.name,
parentId: f.is_orphaned ? null : (f.maybe_parent_folder_token ?? null),
hasStar: f.has_star,
colorCode: f.maybe_color_code ?? null,
// LARGE (512px): cover is full-bleed and collage cells render large enough
// (esp. on retina / low column counts) that smaller sizes look blurry.
coverUrl: resolveFolderThumb(
f.maybe_custom_cover_thumbnail,
THUMBNAIL_SIZES.LARGE,
),
collageUrls: (f.last_media_thumbnails ?? [])
.slice(0, 4)
.map((t) => resolveFolderThumb(t, THUMBNAIL_SIZES.LARGE))
.filter((u): u is string => !!u),
});
/**
* Still thumbnail url for a folder cover/collage tile (an `<img>`), derived from
* an already-resolved `GalleryItem`. Prefers the still resize template; for video
* `item.thumbnail` holds the animated gif (fine in an `<img>`); never the raw
* video file. Used for the optimistic cover update on add/move.
*/
export const galleryItemToCollageUrl = (item: GalleryItem): string | null =>
getThumbnailUrl(item.thumbnailUrlTemplate, {
width: THUMBNAIL_SIZES.LARGE,
}) ??
item.thumbnail ??
null;
/** Prepend new collage urls (dropping nulls), dedupe against existing, cap to `cap`. */
export const mergeCollageUrls = (
existing: string[] | undefined,
newUrls: (string | null)[],
cap = 4,
): string[] => {
const fresh = newUrls.filter((u): u is string => !!u);
const seen = new Set(fresh);
const merged = [...fresh];
for (const u of existing ?? []) {
if (!seen.has(u)) {
seen.add(u);
merged.push(u);
}
}
return merged.slice(0, cap);
};
@@ -1,47 +0,0 @@
import { getThumbnailUrl } from "@storyteller/common";
/** Minimal shape both the modal's `GalleryFolder` and the webapp's `UiFolder` satisfy. */
interface SortableFolder {
name: string;
hasStar?: boolean;
}
/** Sort order for folder lists: starred first, then alphabetical (case-insensitive). */
export const compareFolders = (a: SortableFolder, b: SortableFolder): number =>
Number(!!b.hasStar) - Number(!!a.hasStar) ||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
/** Preset folder colors shown in the color picker (shared by both context menus). */
export const FOLDER_COLOR_PRESETS: string[] = [
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#06b6d4", // cyan
"#3b82f6", // blue
"#a855f7", // purple
"#ec4899", // pink
];
/** A folder thumbnail descriptor from the API (cdn_url + optional resize template). */
interface FolderThumbLike {
cdn_url: string;
maybe_thumbnail_template?: string | null;
media_class?: string;
}
/**
* Resolve a folder thumbnail to a usable image url: the resize template if present,
* otherwise the raw cdn url — except for video, whose `cdn_url` is the video file
* itself (unrenderable in an `<img>`), so we return null rather than a broken tile.
*/
export const resolveFolderThumb = (
thumb: FolderThumbLike | null | undefined,
width: number,
): string | null => {
if (!thumb) return null;
const fromTemplate = getThumbnailUrl(thumb.maybe_thumbnail_template, { width });
if (fromTemplate) return fromTemplate;
if (thumb.media_class === "video") return null;
return thumb.cdn_url;
};
File diff suppressed because it is too large Load Diff
@@ -6,89 +6,33 @@ import {
interface DragState {
item: GalleryItem | null;
items: GalleryItem[];
isDragging: boolean;
startX: number;
startY: number;
currX: number;
currY: number;
modalHidden: boolean;
}
const dragState: DragState = {
item: null,
items: [],
isDragging: false,
startX: 0,
startY: 0,
currX: 0,
currY: 0,
modalHidden: false,
};
const dragThreshold = 5;
// ── Drag preview (floating chip that follows cursor) ─────────────────────────
let dragPreviewEl: HTMLDivElement | null = null;
function createDragPreview(count: number) {
removeDragPreview();
if (count <= 1) return;
const el = document.createElement("div");
el.textContent = String(count);
el.style.cssText = `
position: fixed;
z-index: 99999;
pointer-events: none;
min-width: 22px;
height: 22px;
border-radius: 11px;
background: var(--primary, #ffb500);
color: #000;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
line-height: 1;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
`;
document.body.appendChild(el);
dragPreviewEl = el;
}
function updateDragPreviewPosition(x: number, y: number) {
if (!dragPreviewEl) return;
dragPreviewEl.style.left = `${x + 12}px`;
dragPreviewEl.style.top = `${y - 28}px`;
}
function removeDragPreview() {
if (dragPreviewEl) {
dragPreviewEl.remove();
dragPreviewEl = null;
}
}
// ── Drag lifecycle ───────────────────────────────────────────────────────────
function onPointerDown(
event: React.PointerEvent,
item: GalleryItem,
bulkItems?: GalleryItem[],
) {
function onPointerDown(event: React.PointerEvent, item: GalleryItem) {
if (event.button !== 0) return;
dragState.item = item;
dragState.items = bulkItems && bulkItems.length > 0 ? bulkItems : [item];
dragState.startX = event.pageX;
dragState.startY = event.pageY;
dragState.currX = event.pageX;
dragState.currY = event.pageY;
dragState.isDragging = false;
dragState.modalHidden = false;
galleryModalVisibleDuringDrag.value = false;
document.body.style.cursor = "grabbing";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
@@ -103,63 +47,24 @@ function onPointerMove(event: PointerEvent) {
(Math.abs(deltaX) > dragThreshold || Math.abs(deltaY) > dragThreshold)
) {
dragState.isDragging = true;
createDragPreview(dragState.items.length);
}
dragState.currX = event.pageX;
dragState.currY = event.pageY;
if (dragState.isDragging) {
updateDragPreviewPosition(event.clientX, event.clientY);
}
if (dragState.isDragging && !dragState.modalHidden) {
// Check if cursor left the modal area - if so, hide modal for scene drop
const modalEl = document.querySelector("[data-gallery-modal]");
if (modalEl) {
const rect = modalEl.getBoundingClientRect();
const isOutside =
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom;
if (isOutside) {
dragState.modalHidden = true;
galleryModalVisibleDuringDrag.value = false;
}
}
// Update folder hover indicators while inside the modal
if (!dragState.modalHidden) {
const folderEl = document
.elementFromPoint(event.clientX, event.clientY)
?.closest("[data-folder-id]");
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.toggle("folder-drag-over", el === folderEl);
});
}
}
}
export const IMAGE_DROP_EVENT = "gallery-image-drop";
export const FOLDER_DROP_EVENT = "gallery-folder-drop";
export function emitImageDrop(
item: GalleryItem,
position: { x: number; y: number },
position: { x: number; y: number }
) {
window.dispatchEvent(
new CustomEvent(IMAGE_DROP_EVENT, { detail: { item, position } }),
);
}
export function emitFolderDrop(items: GalleryItem[], folderId: string) {
window.dispatchEvent(
new CustomEvent(FOLDER_DROP_EVENT, { detail: { items, folderId } }),
new CustomEvent(IMAGE_DROP_EVENT, { detail: { item, position } })
);
}
export function onImageDrop(
callback: (item: GalleryItem, position: { x: number; y: number }) => void,
callback: (item: GalleryItem, position: { x: number; y: number }) => void
) {
const handler = (e: any) => {
callback(e.detail.item, e.detail.position);
@@ -172,60 +77,18 @@ export function removeImageDropListener(handler: (e: any) => void) {
window.removeEventListener(IMAGE_DROP_EVENT, handler);
}
export function onFolderDrop(
callback: (items: GalleryItem[], folderId: string) => void,
) {
const handler = (e: any) => {
callback(e.detail.items, e.detail.folderId);
};
window.addEventListener(FOLDER_DROP_EVENT, handler);
return handler;
}
export function removeFolderDropListener(handler: (e: any) => void) {
window.removeEventListener(FOLDER_DROP_EVENT, handler);
}
function onPointerUp(event: PointerEvent) {
const wasModalHidden = dragState.modalHidden;
if (dragState.item && dragState.isDragging) {
if (!dragState.modalHidden) {
// Still inside modal - check for folder drop
const folderEl = document
.elementFromPoint(event.clientX, event.clientY)
?.closest("[data-folder-id]");
if (folderEl) {
const folderId = folderEl.getAttribute("data-folder-id")!;
emitFolderDrop(dragState.items, folderId);
}
} else {
// Modal was hidden - scene drop (existing behavior)
if (
dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional"
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
}
if (
dragState.item &&
dragState.isDragging &&
(dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional")
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
// Cleanup
removeDragPreview();
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.remove("folder-drag-over");
});
dragState.item = null;
dragState.items = [];
dragState.isDragging = false;
dragState.modalHidden = false;
if (wasModalHidden) {
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
}
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
document.body.style.cursor = "";
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
@@ -1,52 +0,0 @@
import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
export interface FolderDropPromptOptions {
count: number;
targetFolderName?: string;
onMove: () => void | Promise<void>;
onAdd: () => void | Promise<void>;
}
/**
* Ask whether to move items into a folder or add a copy. Used when the items
* come from another folder (folder → folder). "Move here" removes them from the
* source; "Add a copy" keeps them in both; the X / backdrop cancels.
*/
export function promptFolderDrop({
count,
targetFolderName,
onMove,
onAdd,
}: FolderDropPromptOptions): void {
const plural = count === 1 ? "" : "s";
const dest = targetFolderName ? ` to "${targetFolderName}"` : "";
showActionReminder({
reminderType: "default",
title: `Move or copy ${count} item${plural}?`,
message: (
<p className="text-sm text-white/70">
Move{dest} removes {count === 1 ? "it" : "them"} from the current folder.
Add a copy keeps {count === 1 ? "it" : "them"} in both.
</p>
),
primaryActionText: "Move here",
onPrimaryAction: async () => {
try {
await onMove();
} finally {
isActionReminderOpen.value = false;
}
},
secondaryActionText: "Add a copy",
onSecondaryAction: async () => {
try {
await onAdd();
} finally {
isActionReminderOpen.value = false;
}
},
});
}