Compare commits

..
Author SHA1 Message Date
Brandon Thomas 0405ea901f report more 2026-06-11 10:35:38 -04:00
Brandon Thomas 4c7bb66356 tests 2026-06-11 10:32:21 -04:00
Brandon Thomas 9e1051d16b new tests 2026-06-11 10:24:37 -04:00
Brandon Thomas 36f1dfb522 make bindings better 2026-06-11 09:56:23 -04:00
Brandon Thomas e22585de06 concrete bindings 2026-06-11 09:42:57 -04:00
Brandon Thomas edb5a81e02 first api binding 2026-06-11 09:25:52 -04:00
Brandon Thomas 957db2d07f folders - add video previews / thumbnails (#1628) 2026-06-11 08:50:23 -04:00
Brandon Thomas b82a4e1599 change sorting for media files in folders (#1627) 2026-06-10 23:32:52 -04:00
BFlat bee790b952 feat(library): marquee selection, multi-select UX, mobile polish and … (#1626)
…perf

- Add desktop-style marquee drag selection on the webapp library: drag
on blank space to select covered tiles, click blank space to deselect
all
- Multi-select: clicking anywhere on a tile toggles it (fixes a
double-fire that cancelled the toggle so only the checkbox worked);
selection persists across tabs and folder navigation
- Gallery item menus show "View image" / "View video" / "View 3D" above
Add to Folder; menu panels size to content so labels no longer wrap
- Mobile/touch: no tile dragging or marquee (finger drags scroll),
checkbox and menu buttons always visible (no hover on touch), and the
Add to Folder submenu expands inline as an accordion instead of
overflowing the screen
- Select-mode gallery modal: folder cards hide the options menu and
ignore right-click (picker is browse-only)
- Perf: bulk selection moved to a module store with per-tile
subscriptions so selection changes re-render only flipped tiles, never
the page; marquee rectangle is positioned imperatively with cached tile
rects and commits only when coverage changes; webapp date groups
virtualized via the shared LazyDateGroup for smooth scrolling on large
libraries
- Bulk selection bar extracted to its own component, fixed to the
viewport bottom and centered within the content area
2026-06-11 00:47:42 +07:00
BFlat a2d57e3685 Folders for the library modal (#1309)
- Redesigned the gallery modal with a sidebar replacing the old filter
dropdown - filters now show as a list with item counts
- Added a Folders system - create, rename, and delete folders to
organize assets
- Users can drag items onto folders in the sidebar, including
multi-selected items
- Added "Add to Folder" option in the item right-click menu with a
folder picker submenu
- Added "Add to folder" button in the bulk selection bar with a folder
picker popup
- Clicking a folder in the sidebar opens a folder view with its own
content area and empty state
- Folder names are editable - click the title in the header or
right-click a folder in the sidebar to rename
- Videos are now draggable - previously only images and 3D objects could
be dragged
- Folder data is mocked locally for now - ready for API integration
2026-06-10 22:04:26 +07:00
Brandon Thomas 9104faf806 fix frontend dev scripts (#1625) 2026-06-10 09:28:25 -04:00
64 changed files with 8295 additions and 561 deletions
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"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 ",
"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 ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "membership_id: u64",
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "88673127b54626dc38d28693e709dfaeced9a05300a26b3f3c52a17f28171c9a"
"hash": "8aca9bc976b980ea1a3b3052bc8a45dd4d8db55c20fd1aede2a4415cc1395cf3"
}
@@ -1,14 +1,14 @@
{
"db_name": "MySQL",
"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 ",
"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 ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "membership_id: u64",
"name": "media_file_id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNIQUE_KEY | UNSIGNED | AUTO_INCREMENT",
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
@@ -263,5 +263,5 @@
false
]
},
"hash": "d2a406bcf08f656fd602aa6ba1124a77d04ad874b15abafca300e8b381c7b211"
"hash": "c0ba9f9aa3184b1cb3d714173b31e530e201a6c24afd29ad534cf7f3954c7316"
}
Generated
+14
View File
@@ -2377,6 +2377,20 @@ 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,6 +10,7 @@ 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,6 +64,31 @@ pub struct FolderThumbnail {
/// Resizing template URL (with `{{width}}` / `{{height}}` placeholders)
/// when the media class supports it. `None` for media classes without
/// a thumbnail variant.
/// a thumbnail variant. (Video media files instead have video previews,
/// which, in turn, have their own thumbnail templates.)
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,
}
@@ -0,0 +1,30 @@
[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"] }
@@ -0,0 +1,17 @@
/// 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
}
}
@@ -0,0 +1 @@
pub mod comet_api_key;
@@ -0,0 +1,157 @@
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),
}
}
}
}
@@ -0,0 +1,22 @@
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),
}
}
}
@@ -0,0 +1,26 @@
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)
}
}
}
}
@@ -0,0 +1,71 @@
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)
}
}
@@ -0,0 +1,33 @@
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),
}
}
}
@@ -0,0 +1,23 @@
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),
}
}
}
@@ -0,0 +1,6 @@
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;
@@ -0,0 +1 @@
pub mod video;
@@ -0,0 +1,146 @@
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,
}
}
}
@@ -0,0 +1,146 @@
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,
}
}
}
@@ -0,0 +1,471 @@
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);
}
}
}
@@ -0,0 +1,278 @@
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![],
}
}
}
@@ -0,0 +1,162 @@
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![],
}
}
}
@@ -0,0 +1,162 @@
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![],
}
}
}
@@ -0,0 +1,216 @@
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![],
}
}
}
@@ -0,0 +1,186 @@
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![],
}
}
}
@@ -0,0 +1,13 @@
//! 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;
@@ -0,0 +1,21 @@
//! 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;
@@ -0,0 +1,2 @@
/// Base URL for the CometAPI HTTP API.
pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com";
@@ -0,0 +1,439 @@
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);
}
}
}
@@ -0,0 +1,2 @@
pub mod create_video;
pub mod request_types;
@@ -0,0 +1,77 @@
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);
}
}
@@ -0,0 +1,184 @@
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!();
}
}
}
@@ -0,0 +1,2 @@
pub mod get_video_task;
pub mod request_types;
@@ -0,0 +1,113 @@
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());
}
}
@@ -0,0 +1,5 @@
pub mod comet_host;
pub mod create_video;
pub mod get_video_content;
pub mod get_video_task;
pub mod video_task_status;
@@ -0,0 +1,132 @@
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());
}
}
@@ -0,0 +1,11 @@
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,8 +16,10 @@ use tokens::tokens::prompts::PromptToken;
/// and the rest of the typed response shape.
#[derive(Debug, Clone)]
pub struct FolderMediaFileRow {
/// `folder_media_files.id` — used as the pagination cursor.
pub membership_id: u64,
/// `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,
/// When the media file was added to the folder.
pub added_to_folder_at: DateTime<Utc>,
@@ -71,7 +73,8 @@ 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. Most-recently-added first.
/// soft-deleted on the media-file side. Newest media files first
/// (`media_files.id` descending — NOT folder-membership order).
///
/// The caller is expected to have already authorized the folder access.
pub async fn list_folder_media_files<'e, 'c: 'e, E>(
@@ -87,7 +90,7 @@ where
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
mf.id as `media_file_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -126,8 +129,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 fmf.id < ?
ORDER BY fmf.id DESC
AND mf.id < ?
ORDER BY mf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -138,7 +141,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
membership_id: r.membership_id,
media_file_id: r.media_file_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -168,7 +171,7 @@ LIMIT ?
sqlx::query!(
r#"
SELECT
fmf.id as `membership_id: u64`,
mf.id as `media_file_id: u64`,
fmf.created_at as `added_to_folder_at: DateTime<Utc>`,
mf.token as `media_file_token: MediaFileToken`,
@@ -207,7 +210,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 fmf.id DESC
ORDER BY mf.id DESC
LIMIT ?
"#,
args.folder_token.as_str(),
@@ -217,7 +220,7 @@ LIMIT ?
.await?
.into_iter()
.map(|r| FolderMediaFileRow {
membership_id: r.membership_id,
media_file_id: r.media_file_id,
added_to_folder_at: r.added_to_folder_at,
media_file_token: r.media_file_token,
media_class: r.media_class,
@@ -1253,6 +1253,7 @@ 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};
use artcraft_api_defs::folders::common::{FolderInfo, FolderThumbnail, FolderThumbnailVideoPreviews};
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, then pull just those two fields onto the compact
// Reuse `MediaLinksBuilder` to produce the CDN URL, thumbnail template,
// and video previews, then pull those fields onto the compact
// FolderThumbnail wire shape.
let media_links = MediaLinksBuilder::from_media_path_and_env(
media_domain,
@@ -131,11 +131,81 @@ 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.membership_id)
server_state.opaque_cursors.encode_last_id_cursor(CURSOR_NAME, last.media_file_id)
}).transpose().map_err(|err| {
warn!("Failed to encode cursor: {:?}", err);
CommonWebError::server_error_with_message("Failed to encode cursor")
@@ -39,6 +39,7 @@ 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() {
@@ -163,7 +164,10 @@ export function App() {
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/library" element={<Library />} />
<Route path="/library/:filter" 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="/referrals" element={<Referrals />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
@@ -188,6 +192,7 @@ export function App() {
<ToastContainer />
<SignupCtaModal />
<InsufficientCreditsModal />
<GlobalActionReminder />
</>
);
}
@@ -0,0 +1,34 @@
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,7 +8,6 @@ import {
faCube,
faFilm,
faWandMagicSparkles,
faGrid2,
faGraduationCap,
faNewspaper,
faCircleQuestion,
@@ -36,6 +35,7 @@ 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,10 +79,6 @@ function useCreateItems(): NavItem[] {
);
}
const ASSETS_ITEMS: NavItem[] = [
{ label: "Library", href: "/library", icon: faGrid2 },
];
const REFERRALS_ITEM: NavItem = {
label: "Referrals",
href: "/referrals",
@@ -142,9 +138,9 @@ function NavMenuItem({
const inner = (
<>
<FontAwesomeIcon icon={item.icon} />
<span>{item.label}</span>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<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">
<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">
{item.badge}
</span>
)}
@@ -257,12 +253,7 @@ export function AppSidebar() {
pathname={pathname}
onClick={handleNavClick}
/>
<NavSection
label="Assets"
items={ASSETS_ITEMS}
pathname={pathname}
onClick={handleNavClick}
/>
<LibraryFoldersNav pathname={pathname} onNavClick={handleNavClick} />
{hasReferralsFlag && (
<NavSection
label="Invite"
@@ -0,0 +1,150 @@
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,7 +11,6 @@ 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" }],
@@ -31,6 +30,17 @@ 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,6 +5,7 @@ import {
faVideo,
faWandMagicSparkles,
faCube,
faFilm,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -18,6 +19,7 @@ type AppCard = {
accent: string;
iconBg: string;
iconColor: string;
badge?: string;
};
const APPS: AppCard[] = [
@@ -57,6 +59,16 @@ 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() {
@@ -91,9 +103,16 @@ export function Home() {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<h3 className="text-base font-semibold text-white">
{app.label}
</h3>
<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>
<FontAwesomeIcon
icon={faArrowRight}
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
@@ -0,0 +1,576 @@
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,6 +37,7 @@ 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,6 +25,8 @@ 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,6 +158,22 @@ 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
@@ -0,0 +1,400 @@
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,
});
@@ -0,0 +1,97 @@
/**
* 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,4 +1,12 @@
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";
@@ -0,0 +1,66 @@
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>
);
@@ -0,0 +1,87 @@
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,18 +1,19 @@
import React, { useEffect, useRef, useState } from "react";
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheck,
faEllipsis,
faPencil,
faTrashCan,
faUpload,
} from "@fortawesome/pro-solid-svg-icons";
import { faCheck, faEllipsis, 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,
@@ -21,6 +22,20 @@ 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;
@@ -38,6 +53,12 @@ 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> = ({
@@ -56,9 +77,16 @@ 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
@@ -130,14 +158,39 @@ 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>) => {
// 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;
}
// 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;
dragStarted.current = false;
const moveListener = (moveEvent: PointerEvent) => {
const dx = moveEvent.pageX - event.pageX;
@@ -145,41 +198,37 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
if (!dragStarted.current && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
dragStarted.current = true;
if (galleryDnd && !disableTooltipAndBadge) {
galleryDnd.onPointerDown(event, item);
const bulkItems =
bulkSelectionMode && bulkSelected && getBulkDragItems
? getBulkDragItems()
: undefined;
galleryDnd.onPointerDown(event, item, bulkItems);
}
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);
};
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();
}
};
// 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 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 = (
@@ -193,14 +242,13 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
: disableTooltipAndBadge
? "border-transparent hover:border-primary/80"
: "border-transparent hover:border-primary",
mode === "select" || item.mediaClass === "video" || bulkSelectionMode
mode === "select"
? "cursor-pointer"
: disableTooltipAndBadge
: disableTooltipAndBadge && !bulkSelectionMode
? "cursor-pointer"
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onClick={handleButtonClick}
aria-label={item.label}
>
@@ -248,11 +296,20 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
);
return (
<div className="group relative w-full aspect-square">
<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 });
}}
>
{/* dropdown menu */}
{mode !== "select" && (
<div
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-75"
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100 transition-opacity duration-75"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
@@ -266,51 +323,62 @@ 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="min-w-28 p-1"
panelClassName="w-max min-w-44 p-1"
closeOnUnhover
>
{(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>
<GalleryItemMenuItems
item={item}
folders={folders}
onOpen={onClick}
onEditClicked={onEditClicked}
onAddToFolder={onAddToFolder}
onCreateFolderFromMenu={onCreateFolderFromMenu}
onRemoveFromFolder={onRemoveFromFolder}
onDelete={onDelete ? handleDelete : undefined}
close={close}
/>
)}
</PopoverMenu>
</div>
)}
{/* Bulk selection checkbox — top-left */}
{mode !== "select" && (
{/* 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 && (
<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",
@@ -319,7 +387,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",
: "opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100",
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -355,16 +423,14 @@ 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">
{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 className="font-bold">Drag</span>
<span className="opacity-50">
{item.mediaClass === "dimensional"
? " to add to scene"
: " to add"}
</span>
)}
</span>
<span>
<span className="font-bold">Click</span>
<span className="opacity-50"> to view</span>
@@ -0,0 +1,180 @@
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>
);
};
@@ -0,0 +1,249 @@
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>
</>
);
@@ -0,0 +1,60 @@
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);
};
@@ -0,0 +1,47 @@
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,33 +6,89 @@ 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;
function onPointerDown(event: React.PointerEvent, item: GalleryItem) {
// ── 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[],
) {
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;
galleryModalVisibleDuringDrag.value = false;
dragState.modalHidden = false;
document.body.style.cursor = "grabbing";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
@@ -47,24 +103,63 @@ 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 } })
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 } }),
);
}
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);
@@ -77,18 +172,60 @@ 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) {
if (
dragState.item &&
dragState.isDragging &&
(dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional")
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
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 });
}
}
}
// Cleanup
removeDragPreview();
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.remove("folder-drag-over");
});
dragState.item = null;
dragState.items = [];
dragState.isDragging = false;
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
dragState.modalHidden = false;
if (wasModalHidden) {
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
}
document.body.style.cursor = "";
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
@@ -0,0 +1,52 @@
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;
}
},
});
}