Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| export * from "./types"; | |
| export * from "./auth"; | |
| export * from "./billing"; | |
| export * from "./projects"; | |
| export * from "./enterprise"; | |
| import axios from "axios"; | |
| import type { VideoStyleId } from "../constants/videoStyles"; | |
| // In production, VITE_BACKEND_URL points to the Cloud Run backend. | |
| // In local dev it's empty β Vite proxy handles /api and /media routing. | |
| const viteEnv = | |
| typeof import.meta !== "undefined" ? import.meta.env : undefined; | |
| const processEnv = | |
| typeof globalThis !== "undefined" && "process" in globalThis | |
| ? (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env | |
| : undefined; | |
| export const BACKEND_URL = viteEnv?.VITE_BACKEND_URL || processEnv?.VITE_BACKEND_URL || ""; | |
| const api = axios.create({ | |
| baseURL: `${BACKEND_URL}/api`, | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| // βββ Auth interceptor βββββββββββββββββββββββββββββββββββββ | |
| api.interceptors.request.use((config) => { | |
| const token = localStorage.getItem("b2v_token"); | |
| if (token) { | |
| config.headers.Authorization = `Bearer ${token}`; | |
| } | |
| return config; | |
| }); | |
| api.interceptors.response.use( | |
| (res) => res, | |
| (err) => { | |
| if (err.response?.status === 401) { | |
| localStorage.removeItem("b2v_token"); | |
| localStorage.removeItem("b2v_user"); | |
| // Only redirect if not already on landing/login page | |
| if ( | |
| window.location.pathname !== "/" && | |
| window.location.pathname !== "/pricing" | |
| ) { | |
| window.location.href = "/"; | |
| } | |
| } | |
| return Promise.reject(err); | |
| } | |
| ); | |
| /** Public JSON client β no auth interceptor so 401 from gates (e.g. template studio) does not log the user out. */ | |
| const publicApi = axios.create({ | |
| baseURL: `${BACKEND_URL}/api`, | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }); | |
| export interface TemplateStudioAuthStatusResponse { | |
| gated: boolean; | |
| } | |
| export interface TemplateStudioAuthVerifyResponse { | |
| ok: boolean; | |
| gated: boolean; | |
| } | |
| export async function getTemplateStudioAuthStatus(): Promise<TemplateStudioAuthStatusResponse> { | |
| const res = await publicApi.get<TemplateStudioAuthStatusResponse>("/template-studio/auth/status"); | |
| return res.data; | |
| } | |
| export async function verifyTemplateStudioPassword( | |
| password: string | |
| ): Promise<TemplateStudioAuthVerifyResponse> { | |
| const res = await publicApi.post<TemplateStudioAuthVerifyResponse>("/template-studio/auth/verify", { | |
| password, | |
| }); | |
| return res.data; | |
| } | |
| // βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface UserInfo { | |
| id: number; | |
| email: string; | |
| name: string; | |
| picture: string | null; | |
| plan: string; | |
| videos_used_this_period: number; | |
| video_limit: number; | |
| can_create_video: boolean; | |
| /** Per-user, non-expirable pool of AI-assisted edits (+20 per purchased video). */ | |
| ai_edit_credits: number; | |
| /** Remaining monthly AI-edit allowance for this plan (0 on FREE). Spent before ai_edit_credits. */ | |
| ai_edit_allowance_remaining: number; | |
| custom_templates_created: number; | |
| custom_template_limit: number; | |
| can_create_custom_template: boolean; | |
| preferred_voice_emotion: string | null; | |
| survey_submitted: boolean; | |
| } | |
| export interface AuthResponse { | |
| access_token: string; | |
| token_type: string; | |
| user: UserInfo; | |
| } | |
| export interface Scene { | |
| id: number; | |
| project_id: number; | |
| order: number; | |
| title: string; | |
| narration_text: string; | |
| display_text?: string | null; | |
| visual_description: string; | |
| remotion_code: string | null; | |
| voiceover_path: string | null; | |
| duration_seconds: number; | |
| extra_hold_seconds?: number | null; | |
| bgm_volume?: number | null; | |
| preferred_layout?: string | null; | |
| /** "intro"|"content"|"outro"|"dataviz_chart"|"dataviz_table" (custom templates). */ | |
| scene_type?: string | null; | |
| created_at: string; | |
| } | |
| export interface Asset { | |
| id: number; | |
| project_id: number; | |
| asset_type: string; | |
| original_url: string | null; | |
| local_path: string; | |
| filename: string; | |
| r2_key: string | null; | |
| r2_url: string | null; | |
| excluded: boolean; | |
| created_at: string; | |
| /** VIDEO assets (stock footage) only β null on image/audio rows. */ | |
| duration_seconds?: number | null; | |
| width?: number | null; | |
| height?: number | null; | |
| source_provider?: string | null; | |
| source_id?: string | null; | |
| source_author?: string | null; | |
| source_page_url?: string | null; | |
| /** Sibling AAC file when the source had audio; null/absent = clip is silent. */ | |
| audio_variant_filename?: string | null; | |
| } | |
| export interface Project { | |
| id: number; | |
| // Owner user id β lets the editor tell owners from collaborators. | |
| user_id: number; | |
| name: string; | |
| blog_url: string | null; | |
| blog_content: string | null; | |
| status: string; | |
| template?: string; | |
| voice_gender: string; | |
| voice_accent: string; | |
| accent_color: string; | |
| bg_color: string; | |
| text_color: string; | |
| font_family?: string | null; | |
| animation_instructions: string | null; | |
| studio_unlocked: boolean; | |
| studio_port: number | null; | |
| player_port: number | null; | |
| r2_video_key: string | null; | |
| r2_video_url: string | null; | |
| logo_r2_url: string | null; | |
| logo_position: string; | |
| logo_opacity: number; | |
| logo_size: number; | |
| custom_voice_id: string | null; | |
| voice_emotion: string | null; | |
| aspect_ratio: string; | |
| video_style?: VideoStyleId; | |
| video_length?: "auto" | "short" | "medium" | "detailed" | "more_detailed"; | |
| /** ISO 639-1 content language ('en', 'es'). Null = auto-detected from the source. */ | |
| content_language?: string | null; | |
| playback_speed?: number; | |
| bgm_track_id?: string | null; | |
| bgm_volume?: number; | |
| bgm_track_url?: string | null; | |
| /** Paid + Newscast: generation pauses for stock-footage review after scripting. */ | |
| stock_footage_enabled?: boolean; | |
| /** Bulk-created: stock footage auto-approves (no review step). */ | |
| is_bulk?: boolean; | |
| captions_enabled?: boolean; | |
| caption_position?: "bottom_center" | "top_center"; | |
| caption_font_family?: string; | |
| caption_font_size?: string | number; | |
| caption_offset?: number; | |
| ai_assisted_editing_count?: number; | |
| custom_theme?: CustomTemplateTheme | null; | |
| custom_image_box_aspect_ratios?: { | |
| intro?: string | { landscape?: string; portrait?: string }; | |
| content?: (string | { landscape?: string; portrait?: string })[]; | |
| outro?: string | { landscape?: string; portrait?: string }; | |
| } | null; | |
| custom_template_missing?: boolean; | |
| brand_logo_url?: string | null; | |
| review_state?: ReviewState | null; | |
| /** True when the project has β₯1 collaborator β gates the per-scene comment button. */ | |
| is_shared?: boolean; | |
| /** | |
| * True when the project OWNER is on a paid plan. On a shared project the owner | |
| * pays, so collaborators gate Pro-only features (custom/crafted templates, paid | |
| * voices) on this rather than their own plan. | |
| */ | |
| owner_is_pro?: boolean; | |
| /** | |
| * The project OWNER's remaining per-user purchased AI-edit credits. On a shared | |
| * project a FREE collaborator draws from the owner's pool once the free | |
| * per-project allowance is spent β the UI shows this balance for collaborators. | |
| */ | |
| owner_ai_edit_credits?: number; | |
| /** The project OWNER's remaining monthly plan allowance (0 on FREE). */ | |
| owner_ai_edit_allowance_remaining?: number; | |
| /** The project OWNER's display name β used to attribute owner-scoped templates/voices in a collaborator's UI. */ | |
| owner_name?: string | null; | |
| created_at: string; | |
| updated_at: string; | |
| scenes: Scene[]; | |
| assets: Asset[]; | |
| } | |
| export interface EmbedProjectResponse extends Project { | |
| crafted_template?: CraftedTemplateDetail | null; | |
| custom_template_code?: { | |
| intro_code: string | null; | |
| outro_code: string | null; | |
| content_codes: string[] | null; | |
| } | null; | |
| layout_prop_schema?: Record<string, LayoutPropSchemaEntry> | null; | |
| } | |
| export interface ProjectListItem { | |
| id: number; | |
| name: string; | |
| blog_url: string | null; | |
| status: string; | |
| created_at: string; | |
| updated_at: string; | |
| scene_count: number; | |
| // Collaboration: acting user's role ("owner" | "editor") + owner display name. | |
| role?: string; | |
| owner_name?: string | null; | |
| } | |
| export interface ChatMessage { | |
| id: number; | |
| role: string; | |
| content: string; | |
| created_at: string; | |
| } | |
| export interface ReviewState { | |
| project_sequence: number; | |
| has_review_for_project: boolean; | |
| should_show_inline: boolean; | |
| } | |
| export interface Review { | |
| id: number; | |
| user_id: number; | |
| project_id: number; | |
| rating: number; | |
| suggestion: string | null; | |
| source: "first_project_popup" | "inline_row"; | |
| trigger_event: "delayed_popup" | "manual"; | |
| project_sequence: number; | |
| plan_at_submission: string; | |
| created_at: string; | |
| updated_at: string; | |
| } | |
| export interface SubmitProjectReviewPayload { | |
| rating: 1 | 2 | 3 | 4 | 5; | |
| suggestion?: string; | |
| source: "first_project_popup" | "inline_row"; | |
| trigger_event: "delayed_popup" | "manual"; | |
| } | |
| export interface SubmitProjectReviewResponse { | |
| review: Review; | |
| review_state: ReviewState; | |
| } | |
| export interface ChatResponse { | |
| reply: string; | |
| changes_made: string; | |
| updated_scenes: Scene[]; | |
| } | |
| export interface StudioResponse { | |
| studio_url: string; | |
| port: number; | |
| } | |
| export interface BillingStatus { | |
| plan: string; | |
| videos_used: number; | |
| video_limit: number; | |
| can_create_video: boolean; | |
| stripe_subscription_id: string | null; | |
| is_active: boolean; | |
| } | |
| export interface SubscriptionDetail { | |
| id: number; | |
| plan_name: string; | |
| plan_slug: string; | |
| status: string; | |
| stripe_subscription_id: string | null; | |
| current_period_start: string | null; | |
| current_period_end: string | null; | |
| videos_used: number; | |
| amount_paid_cents: number; | |
| canceled_at: string | null; | |
| retention_offer_eligible: boolean; | |
| scheduled_plan_slug: string | null; | |
| scheduled_plan_name: string | null; | |
| scheduled_change_at: string | null; | |
| created_at: string; | |
| } | |
| export interface RetentionOfferImpressionResponse { | |
| recorded: boolean; | |
| shown_count: number; | |
| eligible: boolean; | |
| } | |
| export interface Invoice { | |
| id: string; | |
| number: string | null; | |
| status: string | null; | |
| amount_due: number; | |
| amount_paid: number; | |
| currency: string; | |
| created: string; | |
| hosted_invoice_url: string | null; | |
| invoice_pdf: string | null; | |
| } | |
| export interface DataSummary { | |
| total_projects: number; | |
| total_videos_rendered: number; | |
| total_assets: number; | |
| account_created: string; | |
| plan: string; | |
| } | |
| export interface PlanInfo { | |
| id: number; | |
| slug: string; | |
| name: string; | |
| description: string | null; | |
| price_cents: number; | |
| currency: string; | |
| billing_interval: string; | |
| video_limit: number; | |
| includes_studio: boolean; | |
| includes_chat_editor: boolean; | |
| includes_priority_support: boolean; | |
| sort_order: number; | |
| } | |
| export interface PublicConfig { | |
| google_client_id: string; | |
| stripe_publishable_key: string; | |
| survey_promo_code: string; | |
| } | |
| // βββ Auth API βββββββββββββββββββββββββββββββββββββββββββββ | |
| export const googleLogin = (credential: string, reactivate = false, refCode?: string | null) => { | |
| const params: Record<string, unknown> = { reactivate }; | |
| if (refCode) params.ref_code = refCode; | |
| return api.post<AuthResponse>("/auth/google", { credential }, { params }); | |
| }; | |
| // Share B2V (referral/invite) disabled | |
| // export const getAffiliateStats = () => api.get<AffiliateStats>("/affiliate/stats"); | |
| // export const sendAffiliateInvites = (emails: string[]) => | |
| // api.post<{ sent: number; failed: number }>("/affiliate/invite", { emails }); | |
| // | |
| // export interface AffiliateStats { | |
| // link: string; | |
| // signups_count: number; | |
| // bonus_earned: number; | |
| // max_signups: number; | |
| // bonus_per_signup: number; | |
| // } | |
| export interface SurveyPayload { | |
| rating?: string; | |
| use_case?: string; | |
| target_audience?: string; | |
| desired_feature?: string; | |
| heard_from?: string; | |
| } | |
| export const submitSurvey = (payload: SurveyPayload) => | |
| api.post<{ submitted: boolean; promo_code: string | null }>("/affiliate/survey", payload); | |
| export const getMe = () => api.get<UserInfo>("/auth/me"); | |
| export const logoutCleanup = () => api.post("/auth/logout"); | |
| export const deleteAccount = () => api.post("/auth/delete-account"); | |
| export const getPublicConfig = () => | |
| api.get<PublicConfig>("/config/public"); | |
| // βββ Billing API ββββββββββββββββββββββββββββββββββββββββββ | |
| export type CheckoutPlan = "pro" | "standard" | "lite"; | |
| // Post-checkout win-back coupon gate. We want at most one follow-up scheduled | |
| // per browser per day, BUT only count checkouts that actually succeed β a failed | |
| // / unauthorized attempt must not "burn" the day. So we *read* the flag to decide | |
| // whether to ask the backend to schedule, and only *write* it after a 2xx. | |
| const COUPON_FOLLOWUP_KEY = "b2v_coupon_followup_date"; | |
| function couponFollowupAllowedToday(): boolean { | |
| try { | |
| const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD | |
| return localStorage.getItem(COUPON_FOLLOWUP_KEY) !== today; | |
| } catch { | |
| return true; // storage blocked β fall back to scheduling | |
| } | |
| } | |
| function markCouponFollowupScheduled(): void { | |
| try { | |
| localStorage.setItem(COUPON_FOLLOWUP_KEY, new Date().toISOString().slice(0, 10)); | |
| } catch { | |
| /* storage blocked β nothing to record */ | |
| } | |
| } | |
| export const createCheckoutSession = ( | |
| options: | |
| | { | |
| plan?: CheckoutPlan; | |
| billing_cycle?: "monthly" | "annual" | "lifetime"; | |
| apply_third_video_offer?: boolean; | |
| } | |
| | "monthly" | |
| | "annual" | |
| = "monthly" | |
| ) => { | |
| const plan = | |
| typeof options === "string" ? "pro" : (options?.plan ?? "pro"); | |
| const billing_cycle = | |
| typeof options === "string" ? options : (options?.billing_cycle ?? "monthly"); | |
| const apply_third_video_offer = | |
| typeof options === "string" ? false : (options?.apply_third_video_offer ?? false); | |
| const schedule_coupon_followup = couponFollowupAllowedToday(); | |
| return api | |
| .post<{ checkout_url: string }>("/billing/checkout", { | |
| plan, | |
| billing_cycle, | |
| apply_third_video_offer, | |
| schedule_coupon_followup, | |
| }) | |
| .then((res) => { | |
| if (schedule_coupon_followup) markCouponFollowupScheduled(); | |
| return res; | |
| }); | |
| }; | |
| export const createPerVideoCheckout = ( | |
| options?: number | { projectId?: number; quantity?: number } | |
| ) => { | |
| const projectId = | |
| typeof options === "number" ? options : options?.projectId; | |
| const quantity = | |
| typeof options === "object" && options?.quantity ? options.quantity : 1; | |
| const schedule_coupon_followup = couponFollowupAllowedToday(); | |
| return api | |
| .post<{ checkout_url: string }>("/billing/checkout-per-video", { | |
| project_id: projectId ?? null, | |
| quantity, | |
| schedule_coupon_followup, | |
| }) | |
| .then((res) => { | |
| if (schedule_coupon_followup) markCouponFollowupScheduled(); | |
| return res; | |
| }); | |
| }; | |
| export const createCustomTemplateCheckout = (quantity: number = 1) => | |
| api.post<{ checkout_url: string }>("/billing/checkout-custom-template", { | |
| quantity, | |
| }); | |
| /** One-time $300 purchase of 500 never-expiring video credits. */ | |
| export const createBulkCreditsCheckout = () => | |
| api.post<{ checkout_url: string }>("/billing/checkout-bulk-credits", {}); | |
| export const createPortalSession = () => | |
| api.post<{ portal_url: string }>("/billing/portal"); | |
| export const getBillingStatus = () => | |
| api.get<BillingStatus>("/billing/status"); | |
| export const getSubscriptionDetail = () => | |
| api.get<SubscriptionDetail | null>("/billing/subscription"); | |
| export const getInvoices = () => | |
| api.get<Invoice[]>("/billing/invoices"); | |
| export const getDataSummary = () => | |
| api.get<DataSummary>("/billing/data-summary"); | |
| export const getPlans = () => | |
| api.get<PlanInfo[]>("/billing/plans"); | |
| export const cancelSubscription = (body?: { declined_retention_offer?: boolean }) => | |
| api.post("/billing/cancel", body ?? {}); | |
| export const recordRetentionOfferImpression = () => | |
| api.post<RetentionOfferImpressionResponse>("/billing/retention-offer/impression"); | |
| export type RetentionOfferAcceptResponse = { | |
| status: "applied" | "already_applied"; | |
| message: string; | |
| }; | |
| export const acceptRetentionOffer = () => | |
| api.post<RetentionOfferAcceptResponse>("/billing/retention-offer/accept"); | |
| export const resumeSubscription = () => | |
| api.post("/billing/resume"); | |
| // βββ Project API ββββββββββββββββββββββββββββββββββββββββββ | |
| export interface TemplateMeta { | |
| id: string; | |
| name: string; | |
| description: string; | |
| /** When true, show a highlighted "New" tag on the template picker (step 2). */ | |
| new_template?: boolean; | |
| /** When true, show an amber "Popular" tag on the template picker. */ | |
| popular_template?: boolean; | |
| styles?: string[]; // DEPRECATED β was video_style filter; now replaced by `genres`. Kept for back-compat readers. | |
| genres?: string[]; // topical categorization, e.g. ["Finance", "Politics"] β drives the genre dropdown filter | |
| preview_colors?: { accent: string; bg: string; text: string }; | |
| composition_id?: string; | |
| hero_layout?: string; | |
| fallback_layout?: string; | |
| valid_layouts?: string[]; | |
| studio_only_layouts?: string[]; | |
| layouts_without_image?: string[]; | |
| layout_prop_schema?: Record<string, LayoutPropSchema>; | |
| } | |
| export type LayoutPropFieldType = | |
| | "string" | |
| | "text" | |
| | "number" | |
| | "color" | |
| | "select" | |
| | "string_array" | |
| | "object_array" | |
| | "chart_table" | |
| | "ticker_table"; | |
| export interface LayoutPropSubField { | |
| key: string; | |
| label: string; | |
| placeholder?: string; | |
| } | |
| export interface LayoutPropField { | |
| key: string; | |
| label: string; | |
| type: LayoutPropFieldType; | |
| responsive?: boolean; | |
| placeholder?: string; | |
| min?: number; | |
| max?: number; | |
| step?: number; | |
| maxItems?: number; | |
| minItems?: number; | |
| options?: Array<{ label: string; value: string }>; | |
| subFields?: LayoutPropSubField[]; | |
| } | |
| export interface LayoutSceneDefaults { | |
| title?: string; | |
| narration?: string; | |
| durationSeconds?: number; | |
| } | |
| export interface LayoutPropSchema { | |
| label?: string; | |
| description?: string; | |
| defaults?: Record<string, unknown>; | |
| /** Studio-preview-only prop samples β merged over defaults in Template Studio, | |
| NEVER merged into real renders (remotion.py reads only `defaults`). */ | |
| sample_props?: Record<string, unknown>; | |
| scene_defaults?: LayoutSceneDefaults; | |
| fields: LayoutPropField[]; | |
| } | |
| export const getTemplates = (style?: string) => | |
| api.get<TemplateMeta[]>(style ? `/templates?style=${encodeURIComponent(style)}` : "/templates"); | |
| export interface TemplateAvailabilitySignal { | |
| has_custom_templates: boolean; | |
| has_crafted_templates: boolean; | |
| } | |
| export const getTemplateAvailabilitySignal = () => | |
| api.get<TemplateAvailabilitySignal>("/projects/template-availability"); | |
| export interface CraftedTemplateSummary { | |
| id: string; | |
| name: string; | |
| description?: string; | |
| styles?: string[]; | |
| genres?: string[]; | |
| preview_colors?: { accent: string; bg: string; text: string }; | |
| composition_id?: string; | |
| hero_layout?: string; | |
| fallback_layout?: string; | |
| valid_layouts?: string[]; | |
| layouts_without_image?: string[]; | |
| layout_prop_schema?: Record<string, LayoutPropSchema>; | |
| theme?: CustomTemplateTheme; | |
| preview_image_url?: string | null; | |
| /** Source code for the marquee preview component (optional, compiled at runtime). */ | |
| preview_file?: string | null; | |
| /** Bundle-relative path to the marquee preview source. */ | |
| preview_file_rel?: string | null; | |
| /** Source for SceneEditModal field defs per layout (TS/JSON, compiled at runtime). */ | |
| layout_fields?: string | null; | |
| /** Bundle-relative path to the layout_fields source. */ | |
| layout_fields_rel?: string | null; | |
| logo_urls?: string[] | null; | |
| og_image?: string | null; | |
| template_type?: "crafted"; | |
| crafted?: boolean; | |
| } | |
| export interface CraftedTemplateItem extends CraftedTemplateSummary { | |
| intro_code?: string | null; | |
| outro_code?: string | null; | |
| content_codes?: string[] | null; | |
| content_archetype_ids?: (string | { id: string; best_for?: string[] })[] | null; | |
| frontend_files?: Record<string, string> | null; | |
| frontend_entry_rel?: string | null; | |
| frontend_layout_index_rel?: string | null; | |
| frontend_mount_id?: string | null; | |
| /** R2/CDN URLs keyed like Remotion staticFile("fonts/foo.woff2") β full URL */ | |
| public_asset_urls?: Record<string, string> | null; | |
| } | |
| export interface CraftedTemplateDetail extends CraftedTemplateItem {} | |
| export interface CraftedTemplateFetchOptions { | |
| /** | |
| * When true, ask the backend to bypass its in-process cache and re-read | |
| * `summary.json` (or the full bundle) directly from R2. Used on hard | |
| * refresh so a fresh upload to R2 is visible without a server restart. | |
| */ | |
| forceFresh?: boolean; | |
| } | |
| function craftedRequestConfig(opts?: CraftedTemplateFetchOptions) { | |
| if (!opts?.forceFresh) return undefined; | |
| return { headers: { "Cache-Control": "no-cache" } } as const; | |
| } | |
| export const listCraftedTemplates = (opts?: CraftedTemplateFetchOptions) => | |
| api.get<CraftedTemplateSummary[]>("/crafted-templates", craftedRequestConfig(opts)); | |
| export const getCraftedTemplateDetail = ( | |
| templateId: string, | |
| opts?: CraftedTemplateFetchOptions, | |
| ) => | |
| api.get<CraftedTemplateDetail>( | |
| `/crafted-templates/${encodeURIComponent(templateId)}`, | |
| craftedRequestConfig(opts), | |
| ); | |
| export interface AspectValue { | |
| portrait: number; | |
| landscape: number; | |
| } | |
| export interface SaveTemplateSourceRequest { | |
| template_id: string; | |
| layout_id: string; | |
| title_font_size?: AspectValue; | |
| description_font_size?: AspectValue; | |
| } | |
| export interface SaveTemplateSourceResponse { | |
| ok: boolean; | |
| updated_file: string; | |
| updated_files?: string[]; | |
| updated_meta_file?: string | null; | |
| layout_id: string; | |
| template_id: string; | |
| changes_applied: number; | |
| } | |
| export const saveTemplateSourceDefaults = (payload: SaveTemplateSourceRequest) => | |
| api.post<SaveTemplateSourceResponse>("/template-studio/save-source", payload); | |
| export interface ProposeTemplateAiEditRequest { | |
| template_id: string; | |
| layout_id: string; | |
| instruction: string; | |
| image_base64?: string | null; | |
| image_mime_type?: string | null; | |
| } | |
| export interface StartTemplateAiPreviewResponse { | |
| ok: boolean; | |
| session_id: string; | |
| template_id: string; | |
| layout_id: string; | |
| preview_files: string[]; | |
| versions?: string[]; | |
| active_version_id?: string; | |
| } | |
| export interface TemplateAiPreviewSessionRequest { | |
| session_id: string; | |
| } | |
| export interface SwitchTemplateAiPreviewRequest { | |
| session_id: string; | |
| version: string; | |
| } | |
| export interface ApplyTemplateAiPreviewResponse { | |
| ok: boolean; | |
| session_id: string; | |
| template_id: string; | |
| layout_id: string; | |
| updated_files: string[]; | |
| } | |
| export interface DiscardTemplateAiPreviewResponse { | |
| ok: boolean; | |
| session_id: string; | |
| } | |
| export interface ListTemplateAiVersionsRequest { | |
| template_id: string; | |
| layout_id: string; | |
| } | |
| export interface ListTemplateAiVersionsResponse { | |
| ok: boolean; | |
| session_id: string | null; | |
| template_id: string; | |
| layout_id: string; | |
| versions: string[]; | |
| active_version_id: string | null; | |
| } | |
| export const startTemplateAiPreview = (payload: ProposeTemplateAiEditRequest) => | |
| api.post<StartTemplateAiPreviewResponse>("/template-studio/ai-edit/preview", payload); | |
| export const startTemplateAiPreviewFile = (payload: { | |
| template_id: string; | |
| layout_id: string; | |
| instruction: string; | |
| image: File; | |
| }) => { | |
| const formData = new FormData(); | |
| formData.append("template_id", payload.template_id); | |
| formData.append("layout_id", payload.layout_id); | |
| formData.append("instruction", payload.instruction); | |
| formData.append("image", payload.image); | |
| return api.post<StartTemplateAiPreviewResponse>("/template-studio/ai-edit/preview-file", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| }; | |
| export const applyTemplateAiPreview = (payload: TemplateAiPreviewSessionRequest) => | |
| api.post<ApplyTemplateAiPreviewResponse>("/template-studio/ai-edit/preview-apply", payload); | |
| export const switchTemplateAiPreviewVersion = (payload: SwitchTemplateAiPreviewRequest) => | |
| api.post<{ ok: boolean; session_id: string; version: string }>( | |
| "/template-studio/ai-edit/preview-switch", | |
| payload | |
| ); | |
| export const discardTemplateAiPreview = (payload: TemplateAiPreviewSessionRequest) => | |
| api.post<DiscardTemplateAiPreviewResponse>("/template-studio/ai-edit/preview-discard", payload); | |
| export const getTemplateAiVersions = (payload: ListTemplateAiVersionsRequest) => | |
| api.post<ListTemplateAiVersionsResponse>("/template-studio/ai-edit/versions", payload); | |
| export const getFeaturedPublicTemplates = (ids: number[]) => | |
| api.get<any[]>(`/custom-templates/public/featured?ids=${ids.join(',')}`); | |
| // βββ Layout rebuild / create ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface PropDef { | |
| name: string; | |
| type: string; | |
| description: string; | |
| default?: string; | |
| } | |
| export const SUPPORTED_PROP_TYPES = [ | |
| "string", | |
| "text", | |
| "number", | |
| "boolean", | |
| "color", | |
| "imageUrl", | |
| "string_array", | |
| "object_array", | |
| ] as const; | |
| export type PropType = typeof SUPPORTED_PROP_TYPES[number]; | |
| export interface RebuildLayoutRequest { | |
| template_id: string; | |
| layout_id: string; | |
| instruction: string; | |
| extra_props: PropDef[]; | |
| image_base64?: string | null; | |
| image_mime_type?: string | null; | |
| // Optional HTML / SVG mock to drive the redesign (paste or read from a file). | |
| html_example?: string | null; | |
| svg_example?: string | null; | |
| // How the SVG should be used (e.g. "use as a full-bleed background"). | |
| svg_usage?: string | null; | |
| } | |
| export interface RebuildLayoutResponse { | |
| ok: boolean; | |
| session_id: string; | |
| template_id: string; | |
| layout_id: string; | |
| versions: string[]; | |
| active_version_id: string; | |
| updated_files: string[]; | |
| schema: object; | |
| } | |
| export const rebuildTemplateLayout = (payload: RebuildLayoutRequest) => | |
| api.post<RebuildLayoutResponse>("/template-studio/ai-layout/rebuild", payload); | |
| export const rebuildTemplateLayoutFile = (payload: { | |
| template_id: string; | |
| layout_id: string; | |
| instruction: string; | |
| extra_props: PropDef[]; | |
| image: File; | |
| html_example?: string | null; | |
| svg_example?: string | null; | |
| svg_usage?: string | null; | |
| }) => { | |
| const formData = new FormData(); | |
| formData.append("template_id", payload.template_id); | |
| formData.append("layout_id", payload.layout_id); | |
| formData.append("instruction", payload.instruction); | |
| formData.append("extra_props_json", JSON.stringify(payload.extra_props || [])); | |
| if (payload.html_example) formData.append("html_example", payload.html_example); | |
| if (payload.svg_example) formData.append("svg_example", payload.svg_example); | |
| if (payload.svg_usage) formData.append("svg_usage", payload.svg_usage); | |
| formData.append("image", payload.image); | |
| return api.post<RebuildLayoutResponse>("/template-studio/ai-layout/rebuild-file", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| }; | |
| export interface CreateLayoutRequest { | |
| template_id: string; | |
| base_layout_id: string; | |
| new_layout_id: string; | |
| layout_description: string; | |
| props: PropDef[]; | |
| image_base64?: string | null; | |
| image_mime_type?: string | null; | |
| } | |
| export interface CreateLayoutResponse { | |
| ok: boolean; | |
| session_id: string; | |
| template_id: string; | |
| new_layout_id: string; | |
| versions: string[]; | |
| active_version_id: string; | |
| created_files: string[]; | |
| schema: object; | |
| } | |
| export const createTemplateLayout = (payload: CreateLayoutRequest) => | |
| api.post<CreateLayoutResponse>("/template-studio/ai-layout/create", payload); | |
| export const createTemplateLayoutFile = (payload: { | |
| template_id: string; | |
| base_layout_id: string; | |
| new_layout_id: string; | |
| layout_description: string; | |
| props: PropDef[]; | |
| image: File; | |
| }) => { | |
| const formData = new FormData(); | |
| formData.append("template_id", payload.template_id); | |
| formData.append("base_layout_id", payload.base_layout_id); | |
| formData.append("new_layout_id", payload.new_layout_id); | |
| formData.append("layout_description", payload.layout_description); | |
| formData.append("props_json", JSON.stringify(payload.props || [])); | |
| formData.append("image", payload.image); | |
| return api.post<CreateLayoutResponse>("/template-studio/ai-layout/create-file", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| }; | |
| // The structured plan extracted from a (normalized) design doc. Passed | |
| // opaquely from POST /template/plan back into POST /template/create. | |
| export type TemplatePlan = Record<string, unknown>; | |
| export interface TemplateVerification { | |
| ok: boolean; | |
| checked: string[]; | |
| repaired: string[]; | |
| stubbed: string[]; | |
| errors: Record<string, string[]>; | |
| } | |
| // Phase 1: normalize the design doc + extract the plan (fast, no codegen). | |
| export interface PlanTemplateRequest { | |
| template_id: string; | |
| design_doc: string; | |
| overwrite?: boolean; | |
| } | |
| export interface PlanTemplateResponse { | |
| ok: boolean; | |
| template_id: string; | |
| name: string; | |
| normalized_doc: string; | |
| plan: TemplatePlan; | |
| layout_ids: string[]; | |
| hero_layout: string; | |
| fallback_layout: string; | |
| warnings: string[]; | |
| } | |
| export const planTemplateFromDoc = (payload: PlanTemplateRequest) => | |
| api.post<PlanTemplateResponse>("/template-studio/template/plan", payload, { | |
| timeout: 600000, // 10 min β two sequential Claude calls (normalize + extract) on a large doc | |
| }); | |
| // Phase 2: build the template. Supply `plan` (from planTemplateFromDoc) so | |
| // what the user previewed is exactly what gets built; `design_doc` alone is | |
| // the legacy path. | |
| export interface CreateTemplateRequest { | |
| template_id: string; | |
| design_doc?: string; | |
| normalized_doc?: string; | |
| plan?: TemplatePlan; | |
| /** With plan: subset of layout ids to generate (Studio verify step). */ | |
| keep_layout_ids?: string[]; | |
| overwrite?: boolean; | |
| } | |
| export interface CreateTemplateResponse { | |
| ok: boolean; | |
| session_id: string; | |
| template_id: string; | |
| name: string; | |
| layout_ids: string[]; | |
| hero_layout: string; | |
| fallback_layout: string; | |
| created_files: string[]; | |
| normalized_doc?: string; | |
| verification?: TemplateVerification; | |
| warnings: string[]; | |
| note?: string; | |
| } | |
| export const createTemplateFromDoc = (payload: CreateTemplateRequest) => | |
| api.post<CreateTemplateResponse>("/template-studio/template/create", payload, { | |
| timeout: 900000, // 15 min β sequential codegen + full-template typechecks | |
| }); | |
| export interface ExtractDesignDocResponse { | |
| ok: boolean; | |
| filename: string; | |
| text: string; | |
| } | |
| export const extractDesignDocFile = (file: File) => { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| return api.post<ExtractDesignDocResponse>("/template-studio/template/extract-doc", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| timeout: 120000, | |
| }); | |
| }; | |
| export const renderTemplateLayout = (payload: { | |
| template_id: string; | |
| layout_id: string; | |
| aspect_ratio?: string; | |
| duration_seconds?: number; | |
| layout_props?: Record<string, unknown>; | |
| resolution?: "1080p" | "720p"; | |
| scenes?: Array<{ | |
| id?: number; | |
| order?: number; | |
| title?: string; | |
| narration?: string; | |
| layout?: string; | |
| layoutProps?: Record<string, unknown>; | |
| durationSeconds?: number; | |
| imageUrl?: string; | |
| }>; | |
| }) => | |
| api.post<Blob>("/template-studio/render-layout", payload, { | |
| responseType: "blob", | |
| }); | |
| export interface VoicePreview { | |
| voice_id: string; | |
| name: string; | |
| preview_url: string | null; | |
| description: string; | |
| gender: string; | |
| accent: string; | |
| } | |
| export const getVoicePreviews = () => | |
| api.get<Record<string, VoicePreview>>("/voices/previews"); | |
| export interface BgmTrack { | |
| track_id: string; | |
| display_name: string; | |
| mood: string; | |
| r2_url: string; | |
| } | |
| export const getBgmTracks = () => api.get<BgmTrack[]>("/background-music/tracks"); | |
| export const createProject = ( | |
| blog_url: string, | |
| name?: string, | |
| voice_gender?: string, | |
| voice_accent?: string, | |
| accent_color?: string, | |
| bg_color?: string, | |
| text_color?: string, | |
| animation_instructions?: string, | |
| logo_position?: string, | |
| logo_opacity?: number, | |
| custom_voice_id?: string, | |
| aspect_ratio?: string, | |
| template?: string, | |
| video_style?: VideoStyleId, | |
| video_length?: "auto" | "short" | "medium" | "detailed" | "more_detailed", | |
| content_language?: string | null, | |
| voice_emotion?: string, | |
| bgm_track_id?: string | null, | |
| bgm_volume?: number, | |
| captions_enabled?: boolean, | |
| caption_position?: "bottom_center" | "top_center", | |
| /** Options that don't warrant another positional arg (already 22). */ | |
| extra?: { stock_footage_enabled?: boolean } | |
| ) => | |
| api.post<Project>("/projects", { | |
| blog_url, | |
| name, | |
| voice_gender, | |
| voice_accent, | |
| accent_color, | |
| bg_color, | |
| text_color, | |
| animation_instructions, | |
| logo_position, | |
| logo_opacity, | |
| custom_voice_id, | |
| aspect_ratio, | |
| template, | |
| video_style, | |
| video_length, | |
| content_language, | |
| voice_emotion, | |
| bgm_track_id, | |
| bgm_volume, | |
| captions_enabled, | |
| caption_position, | |
| stock_footage_enabled: extra?.stock_footage_enabled ?? false, | |
| }); | |
| /** One project config for bulk create (same shape as single create). */ | |
| export interface BulkProjectItem { | |
| blog_url: string; | |
| name?: string; | |
| template?: string; | |
| video_style?: VideoStyleId; | |
| video_length?: "auto" | "short" | "medium" | "detailed" | "more_detailed"; | |
| voice_gender?: string; | |
| voice_accent?: string; | |
| voice_emotion?: string; | |
| accent_color?: string; | |
| bg_color?: string; | |
| text_color?: string; | |
| animation_instructions?: string; | |
| logo_position?: string; | |
| logo_opacity?: number; | |
| custom_voice_id?: string; | |
| aspect_ratio?: string; | |
| content_language?: string | null; | |
| stock_footage_enabled?: boolean; | |
| captions_enabled?: boolean; | |
| caption_position?: "bottom_center" | "top_center"; | |
| } | |
| export interface BulkCreateResponse { | |
| project_ids: number[]; | |
| } | |
| /** Per-project logos: indices into the projects array and corresponding files. */ | |
| export interface BulkLogoOptions { | |
| logoIndices: number[]; | |
| logoFiles: File[]; | |
| } | |
| export const createProjectsBulk = ( | |
| projects: BulkProjectItem[], | |
| logoOptions?: BulkLogoOptions | null | |
| ) => { | |
| const formData = new FormData(); | |
| formData.append("projects", JSON.stringify(projects)); | |
| if (logoOptions && logoOptions.logoIndices.length > 0 && logoOptions.logoFiles.length === logoOptions.logoIndices.length) { | |
| formData.append("logo_indices", JSON.stringify(logoOptions.logoIndices)); | |
| logoOptions.logoFiles.forEach((f) => formData.append("logos", f)); | |
| } | |
| return api.post<BulkCreateResponse>("/projects/bulk", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| }; | |
| export const createProjectFromDocs = ( | |
| files: File[], | |
| config: { | |
| name?: string; | |
| voice_gender?: string; | |
| voice_accent?: string; | |
| voice_emotion?: string; | |
| accent_color?: string; | |
| bg_color?: string; | |
| text_color?: string; | |
| animation_instructions?: string; | |
| logo_position?: string; | |
| logo_opacity?: number; | |
| custom_voice_id?: string; | |
| aspect_ratio?: string; | |
| template?: string; | |
| video_style?: VideoStyleId; | |
| video_length?: "auto" | "short" | "medium" | "detailed" | "more_detailed"; | |
| content_language?: string | null; | |
| bgm_track_id?: string | null; | |
| bgm_volume?: number; | |
| stock_footage_enabled?: boolean; | |
| } = {} | |
| ) => { | |
| const formData = new FormData(); | |
| files.forEach((f) => formData.append("files", f)); | |
| if (config.name) formData.append("name", config.name); | |
| if (config.voice_gender) formData.append("voice_gender", config.voice_gender); | |
| if (config.voice_accent) formData.append("voice_accent", config.voice_accent); | |
| if (config.voice_emotion) formData.append("voice_emotion", config.voice_emotion); | |
| if (config.accent_color) formData.append("accent_color", config.accent_color); | |
| if (config.bg_color) formData.append("bg_color", config.bg_color); | |
| if (config.text_color) formData.append("text_color", config.text_color); | |
| if (config.animation_instructions) | |
| formData.append("animation_instructions", config.animation_instructions); | |
| if (config.logo_position) formData.append("logo_position", config.logo_position); | |
| if (config.logo_opacity !== undefined) | |
| formData.append("logo_opacity", String(config.logo_opacity)); | |
| if (config.custom_voice_id) formData.append("custom_voice_id", config.custom_voice_id); | |
| if (config.aspect_ratio) formData.append("aspect_ratio", config.aspect_ratio); | |
| if (config.content_language !== undefined && config.content_language !== null) { | |
| formData.append("content_language", config.content_language); | |
| } | |
| if (config.template) formData.append("template", config.template); | |
| if (config.video_style) formData.append("video_style", config.video_style); | |
| if (config.video_length !== undefined && config.video_length !== null) { | |
| formData.append("video_length", config.video_length); | |
| } | |
| if (config.bgm_track_id) formData.append("bgm_track_id", config.bgm_track_id); | |
| if (config.bgm_volume !== undefined && config.bgm_volume !== null) { | |
| formData.append("bgm_volume", String(config.bgm_volume)); | |
| if (config.stock_footage_enabled) | |
| formData.append("stock_footage_enabled", "true"); | |
| } | |
| return api.post<Project>("/projects/upload", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| }; | |
| export const uploadProjectDocuments = (projectId: number, files: File[]) => { | |
| const formData = new FormData(); | |
| files.forEach((f) => formData.append("files", f)); | |
| return api.post<Project>(`/projects/${projectId}/upload-documents`, formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| }; | |
| export const uploadLogo = (projectId: number, file: File) => { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| return api.post<{ logo_url: string; logo_position: string }>( | |
| `/projects/${projectId}/logo`, | |
| formData, | |
| { headers: { "Content-Type": "multipart/form-data" } } | |
| ); | |
| }; | |
| export const deleteLogo = (projectId: number) => | |
| api.delete<{ detail: string }>(`/projects/${projectId}/logo`); | |
| export interface ProjectLogoUpdate { | |
| logo_position?: string; | |
| logo_size?: number; | |
| logo_opacity?: number; | |
| } | |
| export const updateProjectLogo = ( | |
| projectId: number, | |
| data: ProjectLogoUpdate | |
| ) => api.patch<Project>(`/projects/${projectId}`, data); | |
| export const listProjects = () => | |
| api.get<ProjectListItem[]>("/projects"); | |
| export const getProject = (id: number) => | |
| api.get<Project>(`/projects/${id}`); | |
| export const submitProjectReview = ( | |
| projectId: number, | |
| data: SubmitProjectReviewPayload | |
| ) => api.post<SubmitProjectReviewResponse>(`/projects/${projectId}/review`, data); | |
| export const deleteProject = (id: number) => | |
| api.delete(`/projects/${id}`); | |
| export const toggleAssetExclusion = (projectId: number, assetId: number) => | |
| api.patch<{ id: number; excluded: boolean }>( | |
| `/projects/${projectId}/assets/${assetId}/exclude` | |
| ); | |
| export const deleteAsset = (projectId: number, assetId: number) => | |
| api.delete(`/projects/${projectId}/assets/${assetId}`); | |
| export const scrapeProject = (id: number) => | |
| api.post<Project>(`/projects/${id}/scrape`); | |
| export const generateScript = (id: number) => | |
| api.post<Project>(`/projects/${id}/generate-script`); | |
| export const generateScenes = (id: number) => | |
| api.post<Project>(`/projects/${id}/generate-scenes`); | |
| // βββ Async pipeline ββββββββββββββββββββββββββββββββββββββ | |
| export interface PipelineStatus { | |
| status: string; | |
| step: number; | |
| running: boolean; | |
| error: string | null; | |
| error_code?: string | null; | |
| /** True when the server removed the project after a failed generation (quota reverted). */ | |
| project_removed?: boolean; | |
| notice?: { | |
| code: string; | |
| message?: string; | |
| requested_video_length?: string; | |
| effective_video_length?: string; | |
| video_style?: string; | |
| } | null; | |
| stock_footage?: { | |
| current: number; | |
| total: number; | |
| scene_id: number; | |
| } | null; | |
| studio_port: number | null; | |
| } | |
| export const startGeneration = (id: number) => | |
| api.post(`/projects/${id}/generate`); | |
| export const getPipelineStatus = (id: number) => | |
| api.get<PipelineStatus>(`/projects/${id}/status`); | |
| export const bulkUpdateSceneTypography = ( | |
| projectId: number, | |
| data: { title_font_size?: number; description_font_size?: number } | |
| ) => | |
| api.put<Scene[]>( | |
| `/projects/${projectId}/bulk-update-scenes`, | |
| data | |
| ); | |
| export const updateProject = ( | |
| projectId: number, | |
| data: { | |
| accent_color?: string; | |
| bg_color?: string; | |
| text_color?: string; | |
| font_family?: string | null; | |
| aspect_ratio?: string; | |
| playback_speed?: number; | |
| bgm_track_id?: string | null; | |
| bgm_volume?: number; | |
| stock_footage_enabled?: boolean; | |
| captions_enabled?: boolean; | |
| caption_position?: "bottom_center" | "top_center"; | |
| caption_font_family?: string; | |
| caption_font_size?: number; | |
| caption_offset?: number; | |
| } | |
| ) => api.patch<Project>(`/projects/${projectId}/update-project`, data); | |
| export interface VoiceChangeStartResponse { | |
| started: boolean; | |
| total: number; | |
| } | |
| export interface VoiceChangeStatus { | |
| active: boolean; | |
| done: boolean; | |
| error: string | null; | |
| total: number; | |
| completed: number; | |
| progress: number; | |
| status: string; | |
| r2_video_url: string | null; | |
| /** Which operation is running: "voice_change" (add/change) or "delete". */ | |
| kind?: string; | |
| } | |
| /** | |
| * Change the project voice and regenerate every scene's voiceover (verbatim, in | |
| * the new voice). Counts as a new video (deducts one credit). Regeneration runs | |
| * in the background β poll getVoiceChangeStatus for scene-by-scene progress. | |
| */ | |
| export const changeProjectVoice = ( | |
| projectId: number, | |
| data: { | |
| voice_gender?: string; | |
| voice_accent?: string; | |
| custom_voice_id?: string; | |
| voice_emotion?: string; | |
| } | |
| ) => api.post<VoiceChangeStartResponse>(`/projects/${projectId}/change-voice`, data); | |
| export const getVoiceChangeStatus = (projectId: number) => | |
| api.get<VoiceChangeStatus>(`/projects/${projectId}/voice-change-status`); | |
| export interface LanguageChangeStartResponse { | |
| started: boolean; | |
| /** Scene count x 2 (translate pass + voiceover pass). */ | |
| total: number; | |
| content_language: string; | |
| } | |
| export interface LanguageChangeStatus { | |
| active: boolean; | |
| done: boolean; | |
| error: string | null; | |
| total: number; | |
| completed: number; | |
| progress: number; | |
| /** Which pass is running: "translating" then "voiceover". */ | |
| phase?: "translating" | "voiceover"; | |
| status: string; | |
| r2_video_url: string | null; | |
| kind?: string; | |
| content_language: string | null; | |
| } | |
| /** | |
| * Translate the whole project into a new language and regenerate every voiceover. | |
| * Layouts, image assignments, colors, chart data and links are preserved β only the | |
| * prose changes language. | |
| * | |
| * Any editor may trigger this, but it counts as a new video and deducts one credit | |
| * from the project OWNER. Runs in the background β poll getLanguageChangeStatus. | |
| */ | |
| export const changeProjectLanguage = (projectId: number, contentLanguage: string) => | |
| api.post<LanguageChangeStartResponse>(`/projects/${projectId}/change-language`, { | |
| content_language: contentLanguage, | |
| }); | |
| export const getLanguageChangeStatus = (projectId: number) => | |
| api.get<LanguageChangeStatus>(`/projects/${projectId}/language-change-status`); | |
| /** | |
| * Remove the project's voiceover and make the video mute. Does NOT deduct a video | |
| * credit. Runs in the background as a job β poll getVoiceChangeStatus for progress | |
| * (it reports kind="delete"). The existing render is kept; re-rendering to apply the | |
| * mute is a normal (paid) re-render. | |
| */ | |
| export const deleteProjectVoiceover = (projectId: number) => | |
| api.post<VoiceChangeStartResponse>(`/projects/${projectId}/delete-voiceover`); | |
| /** | |
| * Synthesize a short on-demand voice preview with the given voice + tuning (Advanced Options). | |
| * Returns an object URL for playback β the caller must revoke it. Paid + rate-limited server-side. | |
| */ | |
| export const previewVoice = async (params: { | |
| voice_gender?: string | null; | |
| voice_accent?: string | null; | |
| custom_voice_id?: string | null; | |
| voice_emotion?: string | null; | |
| video_style?: string | null; | |
| }): Promise<string> => { | |
| const res = await api.post("/voice/preview", params, { responseType: "blob" }); | |
| const blob = res.data as Blob; | |
| if (!blob || blob.size === 0) throw new Error("Empty audio"); | |
| return window.URL.createObjectURL(blob); | |
| }; | |
| export const updateScene = ( | |
| projectId: number, | |
| sceneId: number, | |
| data: Partial<Scene> | |
| ) => api.put<Scene>(`/projects/${projectId}/scenes/${sceneId}`, data); | |
| export const updateSceneImage = ( | |
| projectId: number, | |
| sceneId: number, | |
| imageFile: File | |
| ) => { | |
| const formData = new FormData(); | |
| formData.append("image", imageFile); | |
| return api.post<Scene>( | |
| `/projects/${projectId}/scenes/${sceneId}/image`, | |
| formData, | |
| { headers: { "Content-Type": "multipart/form-data" } } | |
| ); | |
| }; | |
| /** | |
| * Upload a user-recorded voiceover for a scene. The backend transcodes the | |
| * recording (WebM/MP4) to MP3, uploads to R2, and replaces the scene's existing | |
| * voiceover (updating voiceover_path and duration). Voice settings are unchanged. | |
| */ | |
| export const updateSceneVoiceover = ( | |
| projectId: number, | |
| sceneId: number, | |
| audioBlob: Blob | |
| ) => { | |
| const formData = new FormData(); | |
| const ext = audioBlob.type.includes("mp4") | |
| ? "mp4" | |
| : audioBlob.type.includes("ogg") | |
| ? "ogg" | |
| : "webm"; | |
| formData.append("audio", audioBlob, `recording.${ext}`); | |
| return api.post<Scene>( | |
| `/projects/${projectId}/scenes/${sceneId}/voiceover`, | |
| formData, | |
| { headers: { "Content-Type": "multipart/form-data" } } | |
| ); | |
| }; | |
| export const updateSceneImageFocus = ( | |
| projectId: number, | |
| sceneId: number, | |
| imageFocusX: number, | |
| imageFocusY: number, | |
| imageZoom?: number, | |
| /** Clip trim offset; only applied when the scene carries a stock clip. */ | |
| videoStartSeconds?: number | |
| ) => | |
| api.patch<Scene>(`/projects/${projectId}/scenes/${sceneId}/image-focus`, { | |
| image_focus_x: imageFocusX, | |
| image_focus_y: imageFocusY, | |
| ...(imageZoom !== undefined ? { image_zoom: imageZoom } : {}), | |
| ...(videoStartSeconds !== undefined | |
| ? { video_start_seconds: videoStartSeconds } | |
| : {}), | |
| }); | |
| export const moveSceneImage = ( | |
| projectId: number, | |
| fromSceneId: number, | |
| toSceneId: number | |
| ) => | |
| api.post<{ detail: string }>(`/projects/${projectId}/images/move`, { | |
| from_scene_id: fromSceneId, | |
| to_scene_id: toSceneId, | |
| }); | |
| export const swapSceneImages = ( | |
| projectId: number, | |
| firstSceneId: number, | |
| secondSceneId: number | |
| ) => | |
| api.post<{ detail: string }>(`/projects/${projectId}/images/swap`, { | |
| first_scene_id: firstSceneId, | |
| second_scene_id: secondSceneId, | |
| }); | |
| export const duplicateSceneImage = ( | |
| projectId: number, | |
| sourceSceneId: number, | |
| targetSceneId: number | |
| ) => | |
| api.post<{ detail: string }>(`/projects/${projectId}/images/duplicate`, { | |
| source_scene_id: sourceSceneId, | |
| target_scene_id: targetSceneId, | |
| }); | |
| export const assignExistingImageToScene = ( | |
| projectId: number, | |
| sceneId: number, | |
| assetId: number | |
| ) => | |
| api.post<{ detail: string }>(`/projects/${projectId}/images/assign-existing`, { | |
| scene_id: sceneId, | |
| asset_id: assetId, | |
| }); | |
| export interface GenerateSceneImageRequest { | |
| image_description: string; | |
| } | |
| export interface GenerateSceneImageResponse { | |
| image_base64: string; | |
| refined_prompt: string; | |
| } | |
| export const generateSceneImage = ( | |
| projectId: number, | |
| sceneId: number, | |
| body: GenerateSceneImageRequest | |
| ) => | |
| api.post<GenerateSceneImageResponse>( | |
| `/projects/${projectId}/scenes/${sceneId}/generate-image`, | |
| body | |
| ); | |
| /** A stock-footage search result, normalised across Pexels and Pixabay. */ | |
| export interface StockClip { | |
| provider: string; | |
| id: string; | |
| preview_url: string; | |
| thumbnail_url: string; | |
| download_url: string; | |
| width: number; | |
| height: number; | |
| duration: number; | |
| fps: number | null; | |
| author: string; | |
| page_url: string; | |
| } | |
| export const searchStockFootage = ( | |
| projectId: number, | |
| params: { | |
| q: string; | |
| provider?: string; | |
| page?: number; | |
| per_page?: number; | |
| /** Scene image box in px on the 1080p canvas β steers orientation + rendition. */ | |
| box_w?: number; | |
| box_h?: number; | |
| } | |
| ) => | |
| api.get<{ clips: StockClip[] }>(`/projects/${projectId}/stock-footage/search`, { | |
| params, | |
| }); | |
| /** One scene awaiting stock-footage review at the generation gate. */ | |
| export interface PendingFootageScene { | |
| scene_id: number; | |
| order: number; | |
| title: string; | |
| scene_type: string | null; | |
| layout: string | null; | |
| /** Scene length + saved framing, used to seed the review gate's clip editor. */ | |
| duration_seconds?: number | null; | |
| image_focus_x?: number | null; | |
| image_focus_y?: number | null; | |
| image_zoom?: number | null; | |
| video_start_seconds?: number | null; | |
| /** Set for custom templates, where the box ratio can't be derived from layout. */ | |
| image_box_aspect_ratio?: string | null; | |
| clip: { | |
| filename: string; | |
| url: string; | |
| duration_seconds: number | null; | |
| author: string | null; | |
| provider: string | null; | |
| } | null; | |
| /** Scraped still used when auto stock search found no clip for this scene. */ | |
| fallback_image?: { | |
| filename: string; | |
| url: string; | |
| } | null; | |
| } | |
| /** Scenes to review while a project sits at `awaiting_stock_footage_review` | |
| * (or, for a legacy project, the old `awaiting_footage` gate). */ | |
| export const getPendingStockFootage = (projectId: number) => | |
| api.get<{ status: string; awaiting: boolean; scenes: PendingFootageScene[] }>( | |
| `/projects/${projectId}/stock-footage/pending` | |
| ); | |
| /** | |
| * Point a scene at an already-uploaded clip. The upload endpoint creates the | |
| * asset but does not touch the scene, so the review gate calls this right after | |
| * swapping β otherwise the new clip is orphaned and the scene keeps the old one. | |
| */ | |
| export const linkStockFootage = ( | |
| projectId: number, | |
| sceneId: number, | |
| filename: string | |
| ) => | |
| api.post<{ detail: string; scene_id: number; filename: string }>( | |
| `/projects/${projectId}/stock-footage/link`, | |
| { scene_id: sceneId, filename } | |
| ); | |
| /** Accept the auto-picked clips as-is and finalize the video. */ | |
| export const approveStockFootage = (projectId: number) => | |
| api.post<{ detail: string; status: string }>( | |
| `/projects/${projectId}/stock-footage/approve` | |
| ); | |
| /** Discard every auto-picked clip: fall back to images (or hide), then finalize. */ | |
| export const rejectStockFootage = (projectId: number) => | |
| api.post<{ detail: string; status: string }>( | |
| `/projects/${projectId}/stock-footage/reject` | |
| ); | |
| /** The processed VIDEO asset returned after uploading a chosen clip. */ | |
| export interface UploadedStockClip { | |
| asset_id: number; | |
| filename: string; | |
| video_url: string; | |
| audio_variant_url: string | null; | |
| has_audio: boolean; | |
| duration_seconds: number | null; | |
| width: number | null; | |
| height: number | null; | |
| source_author: string; | |
| source_provider: string; | |
| } | |
| /** | |
| * Download + normalise a chosen clip into a VIDEO asset, WITHOUT linking it to | |
| * the scene. The backend transcodes it to CFR 30 fps (so it stays in sync with | |
| * the 30 fps composition) β expect a few seconds. The returned URLs let the | |
| * editor stage and preview the clip (audio included); the scene link is written | |
| * later by the normal scene Save (via `assignedVideo` in the descriptor). | |
| */ | |
| export const uploadStockFootage = ( | |
| projectId: number, | |
| sceneId: number, | |
| clip: StockClip | |
| ) => | |
| api.post<UploadedStockClip>( | |
| `/projects/${projectId}/scenes/${sceneId}/stock-footage`, | |
| { | |
| provider: clip.provider, | |
| clip_id: clip.id, | |
| download_url: clip.download_url, | |
| width: clip.width, | |
| height: clip.height, | |
| duration: clip.duration, | |
| author: clip.author, | |
| page_url: clip.page_url, | |
| } | |
| ); | |
| export interface LayoutPropSchemaEntry { | |
| label?: string; | |
| defaults?: Record<string, unknown>; | |
| fields?: Array<{ key: string; label?: string; type?: string }>; | |
| scene_defaults?: { title?: string; narration?: string }; | |
| } | |
| export interface LayoutInfo { | |
| layouts: string[]; | |
| layout_names: Record<string, string>; | |
| layouts_without_image?: string[]; | |
| layout_prop_schema?: Record<string, LayoutPropSchemaEntry>; | |
| } | |
| export const getValidLayouts = (projectId: number) => | |
| api.get<LayoutInfo>(`/projects/${projectId}/layouts`); | |
| export interface SceneOrderItem { | |
| scene_id: number; | |
| order: number; | |
| } | |
| export const reorderScenes = ( | |
| projectId: number, | |
| sceneOrders: SceneOrderItem[] | |
| ) => | |
| api.post<Scene[]>(`/projects/${projectId}/scenes/reorder`, { | |
| scene_orders: sceneOrders, | |
| }); | |
| export const regenerateScene = ( | |
| projectId: number, | |
| sceneId: number, | |
| description: string, | |
| narrationText: string, | |
| regenerateVoiceover: boolean, | |
| layout?: string, | |
| imageFile?: File, | |
| voiceoverVerbatim: boolean = true | |
| ) => { | |
| const formData = new FormData(); | |
| // Only append description if it has a value | |
| if (description && description.trim()) { | |
| formData.append("description", description); | |
| } | |
| formData.append("narration_text", narrationText); | |
| formData.append("regenerate_voiceover", regenerateVoiceover ? "true" : "false"); | |
| formData.append("voiceover_verbatim", voiceoverVerbatim ? "true" : "false"); | |
| if (layout) formData.append("layout", layout); | |
| if (imageFile) formData.append("image", imageFile); | |
| return api.post<Scene>( | |
| `/projects/${projectId}/scenes/${sceneId}/regenerate`, | |
| formData, | |
| { headers: { "Content-Type": "multipart/form-data" } } | |
| ); | |
| }; | |
| // A background add-scene generation job (polled via getAddSceneStatus). | |
| export interface AddSceneJob { | |
| id: number; | |
| status: "queued" | "running" | "completed" | "failed"; | |
| current_step: string; | |
| error_message?: string | null; | |
| // Set on success so the client can locate the newly inserted scene row. | |
| new_scene_id?: number | null; | |
| // 1-indexed insert position among active scenes (null = appended at end). | |
| position?: number | null; | |
| } | |
| // Enqueue background generation of a new AI scene. `position` is 1-indexed among | |
| // active scenes (the new scene takes that slot, everything after shifts down); omit | |
| // to append. Returns a job immediately β poll getAddSceneStatus for completion. | |
| export const addScene = ( | |
| projectId: number, | |
| prompt: string, | |
| position?: number | |
| ) => | |
| api.post<AddSceneJob>(`/projects/${projectId}/scenes/add`, { | |
| prompt, | |
| position: position ?? null, | |
| }); | |
| // Latest add-scene job for a project (null if one has never been run). | |
| export const getAddSceneStatus = (projectId: number) => | |
| api.get<AddSceneJob | null>(`/projects/${projectId}/scenes/add-status`); | |
| export const launchStudio = (id: number) => | |
| api.post<StudioResponse>(`/projects/${id}/launch-studio`); | |
| export const renderVideo = ( | |
| id: number, | |
| forceReRender = false | |
| ) => | |
| api.post<RenderStartResponse>( | |
| `/projects/${id}/render?force_render=${forceReRender}` | |
| ); | |
| export interface RenderStartResponse { | |
| detail: string; | |
| progress: number; | |
| resolution?: string; | |
| render_run_id?: string | null; | |
| } | |
| export interface RenderStatus { | |
| progress: number; | |
| rendered_frames: number; | |
| total_frames: number; | |
| done: boolean; | |
| error: string | null; | |
| time_remaining: string | null; | |
| eta_seconds: number | null; | |
| progress_unknown?: boolean; | |
| render_attempt?: number | null; | |
| render_run_id?: string | null; | |
| r2_video_url: string | null; | |
| } | |
| export const getRenderStatus = (id: number) => | |
| api.get<RenderStatus>(`/projects/${id}/render-status`); | |
| export const cancelRender = (id: number) => | |
| api.post<{ detail: string; cancelled: boolean }>(`/projects/${id}/cancel-render`); | |
| /** Fetch video as blob for playback. Returns object URL; caller must revoke it. */ | |
| export const fetchVideoBlob = async (id: number): Promise<string> => { | |
| const res = await api.get(`/projects/${id}/download`, { | |
| responseType: "blob", | |
| }); | |
| if (res.status !== 200) throw new Error(`Download failed (${res.status})`); | |
| const blob = res.data as Blob; | |
| if (!blob || blob.size === 0) throw new Error("Empty video"); | |
| return window.URL.createObjectURL(blob); | |
| }; | |
| export const downloadVideo = async (id: number, filename?: string) => { | |
| try { | |
| const res = await api.get(`/projects/${id}/download`); | |
| const finalR2Url = res.request.responseURL; | |
| const link = document.createElement("a"); | |
| link.href = finalR2Url; | |
| link.setAttribute("download", filename || "video.mp4"); | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| } catch (err) { | |
| // Fallback: If the API call fails, just try a direct browser navigation | |
| console.error("Link trigger failed, trying direct window location", err); | |
| } | |
| }; | |
| export const downloadStudioZip = async (id: number, filename?: string) => { | |
| const res = await api.get(`/projects/${id}/download-studio`, { | |
| responseType: "blob", | |
| }); | |
| const url = window.URL.createObjectURL(new Blob([res.data])); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = filename || "studio_project.zip"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| window.URL.revokeObjectURL(url); | |
| }; | |
| export const sendChatMessage = (id: number, message: string) => | |
| api.post<ChatResponse>(`/projects/${id}/chat`, { message }); | |
| export const getChatHistory = (id: number) => | |
| api.get<ChatMessage[]>(`/projects/${id}/chat/history`); | |
| // βββ Custom Templates API (Pro only) βββββββββββββββββββββ | |
| /** Scene-exit transition styles (must match GeneratedTransition registry). */ | |
| export type TransitionStyle = | |
| | "fade" | |
| | "accent_wash" | |
| | "rule_sweep" | |
| | "ink_wash" | |
| | "whip_blur" | |
| | "push_slide" | |
| | "cover_wipe" | |
| | "page_flip" | |
| | "clock_sweep" | |
| // richer custom presentations (palette-driven; ported from economist/chronicle) | |
| | "parallax_push" | |
| | "whip_pan" | |
| | "accent_bar" | |
| | "page_fold" | |
| | "ink_bleed"; | |
| export interface CustomTemplateTheme { | |
| colors: { accent: string; bg: string; text: string; surface: string; muted: string; bg2?: string }; | |
| fonts: { heading: string; body: string; mono: string }; | |
| borderRadius: number; | |
| style: string; | |
| animationPreset: string; | |
| category: string; | |
| patterns: { | |
| cards: { corners: string; shadowDepth: string; borderStyle: string }; | |
| spacing: { density: string; gridGap: number }; | |
| images: { treatment: string; overlay: string; captionStyle: string }; | |
| layout: { direction: string; decorativeElements: string[] }; | |
| }; | |
| /** Motion personality β drives kit animation timing + scene-exit transitions. */ | |
| motion?: { | |
| energy?: "calm" | "smooth" | "energetic"; | |
| easing?: string; | |
| transitionFamily?: TransitionStyle[]; | |
| }; | |
| /** Data-viz styling cues for the craft kit's CustomChart. */ | |
| charts?: { | |
| style?: "clean" | "precise" | "editorial"; | |
| gridStyle?: "dashed" | "horizontal" | "none"; | |
| palette?: string[]; | |
| }; | |
| /** Background decoration system for the kit's <Decor>. */ | |
| decor?: { | |
| system?: "none" | "dots" | "grid" | "orbs" | "starfield" | "rules" | "vignette"; | |
| intensity?: number; | |
| }; | |
| /** Preferred content archetypes for this brand (scene-type variety hint). */ | |
| sceneBias?: string[]; | |
| /** | |
| * The user's raw prompt / uploaded-doc text (prompt & doc creation paths only). | |
| * Round-trips extract β create β DB so scene generation can honor explicit | |
| * scene requests ("add a testimonial scene"). Absent for URL-scraped themes. | |
| */ | |
| brief?: string; | |
| } | |
| export interface CustomTemplateItem { | |
| id: number; | |
| name: string; | |
| source_url: string | null; | |
| category: string; | |
| genres?: string[]; | |
| theme: CustomTemplateTheme; | |
| preview_colors: { accent: string; bg: string; text: string }; | |
| component_code: string | null; | |
| intro_code: string | null; | |
| outro_code: string | null; | |
| content_codes: string[] | null; | |
| content_archetype_ids: (string | { id: string; best_for?: string[] })[] | null; | |
| current_version_id: number | null; | |
| preview_image_url: string | null; | |
| logo_urls?: string[]; | |
| og_image?: string; | |
| generation_failed: boolean; | |
| is_regenerating: boolean; | |
| my_rating?: number | null; | |
| my_rating_comment?: string | null; | |
| created_at: string; | |
| updated_at: string; | |
| } | |
| export interface TemplateRating { | |
| id: number; | |
| user_id: number; | |
| custom_template_id: number; | |
| rating: number; | |
| suggestion: string | null; | |
| created_at: string; | |
| updated_at: string; | |
| } | |
| export interface TemplateVersionItem { | |
| id: number; | |
| label: string; | |
| created_at: string; | |
| } | |
| export interface TemplateVersionsResponse { | |
| current_version_id: number | null; | |
| versions: TemplateVersionItem[]; | |
| } | |
| export interface ExtractThemeResponse { | |
| extractable: boolean; | |
| reason: string; | |
| theme: CustomTemplateTheme | null; | |
| template_name: string; | |
| logo_urls: string[]; | |
| og_image: string; | |
| screenshot_url: string; | |
| } | |
| export const listCustomTemplates = () => | |
| api.get<CustomTemplateItem[]>("/custom-templates"); | |
| export const getCustomTemplate = (id: number) => | |
| api.get<CustomTemplateItem>(`/custom-templates/${id}`); | |
| export const createCustomTemplate = (data: { | |
| name: string; | |
| source_url?: string; | |
| theme: CustomTemplateTheme; | |
| logo_urls?: string[]; | |
| og_image?: string; | |
| screenshot_url?: string; | |
| reason?: string; | |
| }) => api.post<CustomTemplateItem>("/custom-templates", data); | |
| export const updateCustomTemplate = ( | |
| id: number, | |
| data: { name?: string; theme?: CustomTemplateTheme } | |
| ) => api.put<CustomTemplateItem>(`/custom-templates/${id}`, data); | |
| export const deleteCustomTemplate = (id: number, force = false) => | |
| api.delete(`/custom-templates/${id}${force ? "?force=true" : ""}`); | |
| export const extractTheme = (url: string) => | |
| api.post<ExtractThemeResponse>("/custom-templates/extract-theme", { url }); | |
| export const extractThemeFromPrompt = (prompt: string, name?: string) => | |
| api.post<ExtractThemeResponse>("/custom-templates/extract-theme-from-prompt", { prompt, name }); | |
| export const extractThemeFromDoc = (file: File, name?: string) => { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| if (name) formData.append("name", name); | |
| return api.post<ExtractThemeResponse>( | |
| "/custom-templates/extract-theme-from-doc", | |
| formData, | |
| { headers: { "Content-Type": "multipart/form-data" } } | |
| ); | |
| }; | |
| export const generateTemplateCode = (templateId: number) => | |
| api.post<{ detail: string; template_id: number }>(`/custom-templates/${templateId}/generate-code`); | |
| export interface CodeGenStatus { | |
| status: "generating" | "complete" | "error" | "unknown"; | |
| step: string; | |
| running: boolean; | |
| error: string | null; | |
| } | |
| export const getCodeGenerationStatus = (templateId: number) => | |
| api.get<CodeGenStatus>(`/custom-templates/${templateId}/generation-status`); | |
| export const getTemplateCode = (templateId: number) => | |
| api.get<{ component_code: string | null; intro_code: string | null; outro_code: string | null; content_codes: string[] | null }>( | |
| `/custom-templates/${templateId}/code` | |
| ); | |
| // βββ Project-scoped (owner-resolved) reads for shared projects βββββ | |
| // On a shared project, a collaborator must see the OWNER's templates/voices, | |
| // not their own. These authorize via project membership on the backend and | |
| // resolve against the project owner. The frontend routes through them only | |
| // when the viewer is a collaborator (project.user_id !== user.id). | |
| export const listProjectCustomTemplates = (projectId: number) => | |
| api.get<CustomTemplateItem[]>(`/projects/${projectId}/custom-templates`); | |
| export const getProjectTemplateCode = (projectId: number, templateId: number) => | |
| api.get<{ component_code: string | null; intro_code: string | null; outro_code: string | null; content_codes: string[] | null }>( | |
| `/projects/${projectId}/custom-templates/${templateId}/code` | |
| ); | |
| export const listProjectCraftedTemplates = ( | |
| projectId: number, | |
| opts?: CraftedTemplateFetchOptions, | |
| ) => | |
| api.get<CraftedTemplateSummary[]>( | |
| `/projects/${projectId}/crafted-templates`, | |
| craftedRequestConfig(opts), | |
| ); | |
| export const getProjectCraftedTemplateDetail = ( | |
| projectId: number, | |
| templateId: string, | |
| opts?: CraftedTemplateFetchOptions, | |
| ) => | |
| api.get<CraftedTemplateDetail>( | |
| `/projects/${projectId}/crafted-templates/${encodeURIComponent(templateId)}`, | |
| craftedRequestConfig(opts), | |
| ); | |
| export const getProjectVoices = (projectId: number) => | |
| api.get<SavedVoiceFromAPI[]>(`/projects/${projectId}/voices`); | |
| // βββ Brand asset uploads ββββββββββββββββββββββββββββββββββββ | |
| export const uploadTemplateLogo = (templateId: number, file: File) => { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| return api.post<{ logo_url: string; template: CustomTemplateItem }>( | |
| `/custom-templates/${templateId}/upload-logo`, | |
| formData, | |
| { headers: { "Content-Type": "multipart/form-data" } } | |
| ); | |
| }; | |
| // βββ Template versioning & regeneration βββββββββββββββββββββ | |
| // Fire-and-poll, like generateTemplateCode: returns 202 immediately and the | |
| // caller polls getCodeGenerationStatus / re-fetches the template list. | |
| export const regenerateTemplateCode = (templateId: number) => | |
| api.post<{ detail: string; template_id: number }>(`/custom-templates/${templateId}/regenerate-code`); | |
| export const getTemplateVersions = (templateId: number) => | |
| api.get<TemplateVersionsResponse>(`/custom-templates/${templateId}/versions`); | |
| export const rollbackTemplateVersion = (templateId: number, versionId: number) => | |
| api.post<CustomTemplateItem>( | |
| `/custom-templates/${templateId}/versions/${versionId}/rollback` | |
| ); | |
| export const submitTemplateRating = ( | |
| templateId: number, | |
| data: { rating: 1 | 2 | 3 | 4 | 5; suggestion?: string } | |
| ) => api.post<TemplateRating>(`/custom-templates/${templateId}/rating`, data); | |
| // βββ ElevenLabs voices (default / available) βββββββββββββββββ | |
| export interface ElevenLabsVoice { | |
| voice_id: string; | |
| name: string; | |
| preview_url: string | null; | |
| labels: Record<string, string>; | |
| category?: string; | |
| description?: string; | |
| plan?: "free" | "paid"; | |
| } | |
| export interface ListVoicesResponse { | |
| voices: ElevenLabsVoice[]; | |
| has_more: boolean; | |
| } | |
| export const getVoices = () => api.get<ListVoicesResponse>("/voices"); | |
| export const getPrebuiltVoices = () => | |
| api.get<{ voices: ElevenLabsVoice[]; has_more: boolean }>("/voices/prebuilt"); | |
| // βββ Voice design (preset + custom prompt) βββββββββββββββββββ | |
| export interface VoiceDesignPreview { | |
| generated_voice_id: string; | |
| audio_base_64: string; | |
| media_type?: string; | |
| duration_secs?: number; | |
| } | |
| export interface VoiceDesignResponse { | |
| previews: VoiceDesignPreview[]; | |
| text?: string; | |
| } | |
| export interface DesignFromPresetPayload { | |
| gender?: string; | |
| age?: string; | |
| persona?: string; | |
| speed?: string; | |
| accent?: string; | |
| } | |
| export const designVoiceFromPreset = (payload: DesignFromPresetPayload) => | |
| api.post<VoiceDesignResponse>("/voices/design-from-preset", payload); | |
| export const designVoiceFromPrompt = (payload: { prompt: string }) => | |
| api.post<VoiceDesignResponse>("/voices/design-from-prompt", payload); | |
| // βββ Saved voices (user's My Voices, persisted in DB) βββββββββ | |
| export interface SavedVoiceFromAPI { | |
| id: number; | |
| voice_id: string; | |
| name: string; | |
| preview_url?: string | null; | |
| source: string; | |
| plan?: string | null; // "free" | "paid" for prebuilt (ElevenLabs) | |
| gender?: string | null; | |
| accent?: string | null; | |
| description?: string | null; | |
| created_at: string; | |
| custom_voice_id?: number | null; | |
| } | |
| export const getMyVoices = () => api.get<SavedVoiceFromAPI[]>("/voices/saved"); | |
| export const saveVoice = (payload: { | |
| voice_id: string; | |
| name: string; | |
| preview_url?: string; | |
| source?: string; | |
| plan?: string; // "free" | "paid" for prebuilt | |
| gender?: string; | |
| accent?: string; | |
| description?: string; | |
| custom_voice_id?: number; | |
| }) => api.post<SavedVoiceFromAPI>("/voices/saved", payload); | |
| // βββ Custom voices (creation records: prompt/response/form) βββββ | |
| export interface CustomVoiceFromAPI { | |
| id: number; | |
| name: string; | |
| voice_id: string; | |
| source: string; | |
| prompt_text?: string | null; | |
| form_gender?: string | null; | |
| form_age?: string | null; | |
| form_persona?: string | null; | |
| form_speed?: string | null; | |
| form_accent?: string | null; | |
| preview_url?: string | null; | |
| created_at: string; | |
| } | |
| export const createCustomVoice = (payload: { | |
| voice_id: string; | |
| source: "prompt" | "form"; | |
| name?: string; | |
| prompt_text?: string; | |
| response?: Record<string, unknown>; | |
| form_gender?: string; | |
| form_age?: string; | |
| form_persona?: string; | |
| form_speed?: string; | |
| form_accent?: string; | |
| preview_url?: string; | |
| }) => api.post<CustomVoiceFromAPI>("/voices/custom", payload); | |
| export const getCustomVoices = () => api.get<CustomVoiceFromAPI[]>("/voices/custom"); | |
| export const getCustomVoicePreview = (customVoiceId: number) => | |
| api.get<{ preview_url: string | null; ready: boolean }>(`/voices/custom/${customVoiceId}/preview`); | |
| export const deleteCustomVoice = (id: number) => | |
| api.delete<{ ok: boolean }>(`/voices/custom/${id}`); | |
| export const createCustomVoiceClone = (formData: FormData) => | |
| api.post<CustomVoiceFromAPI>("/voices/clone", formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| export const deleteSavedVoice = (id: number) => | |
| api.delete<{ ok: boolean }>(`/voices/saved/${id}`); | |
| // βββ Embed API ββββββββββββββββββββββββββββββββββββββββββββ | |
| export const generateEmbedToken = (projectId: number) => | |
| api.post<{ embed_token: string; preview_url: string }>(`/embed/token/${projectId}`); | |
| export default api; | |