| import type { ClipMetadata, SegmentsPayload, StreamEvent, VeoSegment } from '@/types'; |
|
|
| |
| |
| |
| |
| |
| |
| const API_BASE = |
| import.meta.env.DEV && import.meta.env.VITE_PUBLIC_API_IN_DEV !== 'true' |
| ? '' |
| : (import.meta.env.VITE_API_BASE_URL || ''); |
|
|
| |
| |
| |
| |
| |
| export function imageSrcForHeroPreview( |
| heroRemoteUrl: string | null, |
| imagePreview: string | null |
| ): string | null { |
| if (heroRemoteUrl) return heroRemoteUrl; |
| if (!imagePreview) return null; |
| if (imagePreview.startsWith('blob:') || imagePreview.startsWith('data:')) return imagePreview; |
|
|
| if (imagePreview.includes('/api/images/') && import.meta.env.DEV) { |
| try { |
| return new URL(imagePreview).pathname; |
| } catch { |
| return imagePreview.startsWith('/') ? imagePreview : null; |
| } |
| } |
|
|
| return imagePreview; |
| } |
|
|
| const GPT_IMAGE_EDIT_MAX_REFS = 4; |
|
|
| |
| |
| |
| |
| |
| export function pickProductReferenceUrlsForGpt(options: { |
| hostedUrl: string | null; |
| heroRemoteUrl: string | null; |
| scrapedImageUrls: string[]; |
| max?: number; |
| }): string[] { |
| const max = Math.min(GPT_IMAGE_EDIT_MAX_REFS, Math.max(1, options.max ?? GPT_IMAGE_EDIT_MAX_REFS)); |
| const out: string[] = []; |
| const seen = new Set<string>(); |
|
|
| const push = (u: string | null | undefined): boolean => { |
| const s = (u ?? '').trim(); |
| if (!s || seen.has(s)) return false; |
| seen.add(s); |
| out.push(s); |
| return out.length >= max; |
| }; |
|
|
| if (push(options.hostedUrl)) return out; |
| if (push(options.heroRemoteUrl)) return out; |
| for (const u of options.scrapedImageUrls) { |
| if (push(u)) break; |
| } |
|
|
| return out; |
| } |
|
|
| async function parseError(res: Response, fallback: string): Promise<string> { |
| const ct = res.headers.get('content-type'); |
| try { |
| if (ct?.includes('application/json')) { |
| const j = await res.json(); |
| return j.detail || j.message || fallback; |
| } |
| const t = await res.text(); |
| return t?.trim() || fallback; |
| } catch { |
| return fallback; |
| } |
| } |
|
|
| export interface HealthStatus { |
| status: string; |
| service?: string; |
| kie_configured: boolean; |
| |
| replicate_configured?: boolean; |
| openai_configured: boolean; |
| gpt_image_model?: string; |
| ffmpeg_available?: boolean; |
| ffprobe_available?: boolean; |
| public_base_url?: string; |
| server_port?: number; |
| } |
|
|
| export async function checkHealth(): Promise<HealthStatus> { |
| const url = `${API_BASE}/health`; |
| const res = await fetch(url); |
| if (!res.ok) throw new Error(await parseError(res, 'Health check failed')); |
| return res.json(); |
| } |
|
|
| export async function uploadImage(file: File): Promise<{ url: string }> { |
| const fd = new FormData(); |
| fd.append('file', file); |
| const res = await fetch(`${API_BASE}/api/upload-image`, { method: 'POST', body: fd }); |
| if (!res.ok) throw new Error(await parseError(res, 'Upload failed')); |
| return res.json(); |
| } |
|
|
| export interface ScrapeProductResponse { |
| product_name: string; |
| description: string; |
| price: string; |
| offers: string; |
| target_audience: string; |
| product_images: string; |
| brand: string; |
| category: string; |
| image_urls: string[]; |
| source_url: string; |
| [key: string]: unknown; |
| } |
|
|
| export async function scrapeProductPage(url: string): Promise<ScrapeProductResponse> { |
| const res = await fetch(`${API_BASE}/api/showcase/scrape`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ url }), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Scrape failed')); |
| return res.json(); |
| } |
|
|
| export async function hostImageFromUrl(url: string): Promise<{ url: string; source_url: string }> { |
| const res = await fetch(`${API_BASE}/api/host-image-url`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ url }), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Could not host image')); |
| return res.json(); |
| } |
|
|
| export async function generateDirectConcepts(params: { |
| productName: string; |
| description?: string; |
| price?: string; |
| targetAudience?: string; |
| count?: number; |
| ugcCount?: number; |
| modelShowcaseCount?: number; |
| featureHighlightCount?: number; |
| }): Promise<{ |
| concepts: string[]; |
| concept_groups?: { |
| ugc: string[]; |
| model_showcase: string[]; |
| feature_highlight: string[]; |
| }; |
| concept_execution_plans?: Record< |
| string, |
| { |
| duration_seconds: number; |
| render_mode: 'direct_seedance' | 'segmented'; |
| reference_frame_prompts: string[]; |
| } |
| >; |
| }> { |
| const res = await fetch(`${API_BASE}/api/showcase/direct-concepts`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| product_name: params.productName, |
| tagline: params.description ?? '', |
| mood: params.price ? `price: ${params.price}` : '', |
| target_audience: params.targetAudience ?? '', |
| count: params.count ?? 10, |
| ugc_count: params.ugcCount ?? 4, |
| model_showcase_count: params.modelShowcaseCount ?? 3, |
| feature_highlight_count: params.featureHighlightCount ?? 3, |
| }), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Concept generation failed')); |
| return res.json(); |
| } |
|
|
| export async function regenerateDirectConcept(params: { |
| productName: string; |
| description?: string; |
| price?: string; |
| targetAudience?: string; |
| exclude?: string[]; |
| index?: number; |
| }): Promise<{ concept: string }> { |
| const res = await fetch(`${API_BASE}/api/showcase/direct-concept-one`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| product_name: params.productName, |
| tagline: params.description ?? '', |
| mood: params.price ? `price: ${params.price}` : '', |
| target_audience: params.targetAudience ?? '', |
| exclude: params.exclude ?? [], |
| index: params.index ?? 1, |
| }), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Concept regeneration failed')); |
| return res.json(); |
| } |
|
|
| |
| export const SHOWCASE_CONCEPT_OPTIONS = [ |
| { |
| id: 'luxury_studio', |
| label: 'Luxury studio launch', |
| description: 'Slow dolly, pedestal, whisper VO — flagship packshot grammar.', |
| category: 'Commercial', |
| }, |
| { |
| id: 'ugc_authentic', |
| label: 'UGC / social authentic', |
| description: 'Handheld, desk or counter, creator energy — short-form hooks.', |
| category: 'Social', |
| }, |
| { |
| id: 'tech_minimal', |
| label: 'Tech / minimal', |
| description: 'Void set, hard edge light, precise motion — device-film calm.', |
| category: 'Commercial', |
| }, |
| { |
| id: 'lifestyle_natural', |
| label: 'Lifestyle / natural light', |
| description: 'Real rooms, sun paths, slow living — editorial home story.', |
| category: 'Commercial', |
| }, |
| { |
| id: 'bold_editorial', |
| label: 'Bold / color editorial', |
| description: 'Gels, graphic shadows, campaign-poster energy.', |
| category: 'Experimental', |
| }, |
| { |
| id: 'unboxing_asmr', |
| label: 'Unboxing / desk ASMR', |
| description: 'Top-down peel, satisfying motion, whisper pacing.', |
| category: 'Social', |
| }, |
| { |
| id: 'high_energy_sports', |
| label: 'High-energy sports', |
| description: 'Kinetic camera, impact cuts, performance-first momentum.', |
| category: 'Commercial', |
| }, |
| { |
| id: 'moody_cinematic_noir', |
| label: 'Moody cinematic noir', |
| description: 'Chiaroscuro lighting, suspense pacing, dramatic reveals.', |
| category: 'Experimental', |
| }, |
| { |
| id: 'playful_stopmotion_style', |
| label: 'Playful stop-motion style', |
| description: 'Tabletop whimsy, snappy object beats, colorful transitions.', |
| category: 'Experimental', |
| }, |
| { |
| id: 'nature_outdoor_adventure', |
| label: 'Nature / outdoor adventure', |
| description: 'Golden trails, outdoor scale, utility-forward storytelling.', |
| category: 'Commercial', |
| }, |
| ] as const; |
|
|
| export type ShowcaseConceptId = (typeof SHOWCASE_CONCEPT_OPTIONS)[number]['id']; |
|
|
| export async function showcasePlanStream( |
| params: { |
| productName: string; |
| description?: string; |
| price?: string; |
| targetAudience: string; |
| shotCount: number; |
| /** Optional override; when omitted backend chooses 4/6/8 from concept + product brief. */ |
| secondsPerSegment?: number; |
| /** Creative arc for shot planning (template + GPT). */ |
| creativeConcept?: ShowcaseConceptId; |
| image?: File | null; |
| /** Original CDN URL for vision (optional); file upload takes precedence when present. */ |
| heroImageUrl?: string; |
| }, |
| onEvent: (e: StreamEvent) => void, |
| signal?: AbortSignal |
| ): Promise<SegmentsPayload> { |
| const fd = new FormData(); |
| fd.append('productName', params.productName); |
| fd.append('tagline', params.description ?? ''); |
| fd.append('mood', params.price ? `price: ${params.price}` : ''); |
| fd.append('targetAudience', params.targetAudience); |
| fd.append('shotCount', String(params.shotCount)); |
| if (params.secondsPerSegment != null) { |
| fd.append('secondsPerSegment', String(params.secondsPerSegment)); |
| } |
| fd.append('creativeConcept', params.creativeConcept ?? 'luxury_studio'); |
| if (params.image) fd.append('image', params.image); |
| if (params.heroImageUrl) fd.append('heroImageUrl', params.heroImageUrl); |
|
|
| const res = await fetch(`${API_BASE}/api/showcase/plan-stream`, { |
| method: 'POST', |
| body: fd, |
| signal, |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Shot plan failed')); |
| if (!res.body) throw new Error('No response body'); |
|
|
| const reader = res.body.getReader(); |
| const dec = new TextDecoder(); |
| let buf = ''; |
| let final: SegmentsPayload | null = null; |
|
|
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buf += dec.decode(value, { stream: true }); |
| const lines = buf.split('\n'); |
| buf = lines.pop() || ''; |
| for (const line of lines) { |
| if (!line.trim()) continue; |
| const ev = JSON.parse(line) as StreamEvent; |
| onEvent(ev); |
| if (ev.event === 'complete') final = ev.payload; |
| if (ev.event === 'error') throw new Error(ev.message); |
| } |
| } |
| } finally { |
| reader.releaseLock(); |
| } |
|
|
| if (!final) throw new Error('Stream ended without complete payload'); |
| return final; |
| } |
|
|
| export interface KlingGenerateResponse { |
| taskId: string; |
| status: string; |
| } |
|
|
| |
| export function segmentClipSeconds(segment: { segment_info?: { duration?: string } }): 4 | 6 | 8 { |
| const raw = segment.segment_info?.duration ?? ''; |
| const m = String(raw).match(/^(\d+)/); |
| if (m) { |
| const n = parseInt(m[1], 10); |
| if (n === 4 || n === 6 || n === 8) return n; |
| } |
| return 8; |
| } |
|
|
| export type SegmentVideoModel = 'veo3_fast' | 'seedance-2' | 'seedance-2-fast'; |
|
|
| export type SeedanceSegmentModel = 'seedance-2' | 'seedance-2-fast'; |
|
|
| |
| export function kieSeedanceModelId(model: SeedanceSegmentModel): string { |
| return model === 'seedance-2-fast' ? 'bytedance/seedance-2-fast' : 'bytedance/seedance-2'; |
| } |
|
|
| export function isSeedanceSegmentModel(model: SegmentVideoModel): model is SeedanceSegmentModel { |
| return model === 'seedance-2' || model === 'seedance-2-fast'; |
| } |
|
|
| |
| export const REPLICATE_SEEDANCE_20_URL = 'https://replicate.com/bytedance/seedance-2.0'; |
|
|
| |
| |
| |
| |
| export const REPLICATE_SEEDANCE_20_NON_VIDEO_IN_USD_PER_SEC: Record<'480p' | '720p' | '1080p', number> = { |
| '480p': 0.08, |
| '720p': 0.18, |
| '1080p': 0.45, |
| }; |
|
|
| export function replicateSeedance20NonVideoInUsdPerSec( |
| resolution: '480p' | '720p' | '1080p' |
| ): number { |
| return REPLICATE_SEEDANCE_20_NON_VIDEO_IN_USD_PER_SEC[resolution]; |
| } |
|
|
| |
| export function estimateReplicateSeedance20Usd(params: { |
| resolution: '480p' | '720p' | '1080p'; |
| totalOutputSeconds: number; |
| }): { usdPerSecond: number; estimatedUsd: number } { |
| const usdPerSecond = replicateSeedance20NonVideoInUsdPerSec(params.resolution); |
| const estimatedUsd = usdPerSecond * Math.max(0, params.totalOutputSeconds); |
| return { usdPerSecond, estimatedUsd }; |
| } |
|
|
| |
| export function textSuggestsJewelry(...parts: (string | undefined | null)[]): boolean { |
| const t = parts.map((p) => String(p || '').toLowerCase()).join(' '); |
| if (!t.trim()) return false; |
| if (/\bring\s+light\b/.test(t)) return false; |
| return /\b(?:rings?|necklaces?|bracelets?|earrings?|pendants?|bangles?|anklets?|chokers?|lockets?|studs?|hoops?|cartilage|lobe|jewelry|jewellery|mangalsutra|nose\s*pin|925|14k|18k|karat|carat|sterling)\b/.test( |
| t |
| ); |
| } |
|
|
| |
| export function jewelryWearPlacementPromptBlock(): string { |
| return ( |
| '[WEAR_PLACEMENT — jewelry on body] ' + |
| 'When a person wears the hero piece it must rest on real anatomy with believable contact—no floating, no metal clipping through skin, no z-fighting. ' + |
| 'Scale must match the references (band width, stone size vs finger or ear). ' + |
| 'Rings: default to the ring finger (fourth digit) of the visible hand unless a reference clearly shows another finger; band sits between knuckles with consistent top-facing stone orientation. ' + |
| 'Earrings: pierced placement at lobe or cartilage as the SKU implies; symmetric pair when the product is a pair. ' + |
| 'Necklaces: natural gravity drape at collarbone/sternum; clasp hidden at nape when appropriate. ' + |
| 'Bracelets / anklets: centered on wrist or ankle joint, not mid-forearm or calf. ' + |
| 'Keep the exact same SKU silhouette and metal color as the reference set—no resized cartoon prop or mirrored design error on the body.' |
| ); |
| } |
|
|
| |
| export function segmentToSeedancePrompt(segment: VeoSegment, productName?: string): string { |
| const ch = segment.character_description; |
| const sc = segment.scene_continuity; |
| const at = segment.action_timeline; |
| const lines: string[] = []; |
| if (productName?.trim()) lines.push(`Product: ${productName.trim()}.`); |
| if (ch?.current_state) lines.push(ch.current_state); |
| if (sc?.environment) lines.push(sc.environment); |
| if (sc?.camera_position) lines.push(`Camera: ${sc.camera_position}`); |
| if (sc?.camera_movement) lines.push(`Motion: ${sc.camera_movement}`); |
| if (sc?.lighting_state) lines.push(`Lighting: ${sc.lighting_state}`); |
| if (sc?.background_elements) lines.push(`Background: ${sc.background_elements}`); |
| if (at?.dialogue) lines.push(`VO: ${at.dialogue}`); |
| const sync = at?.synchronized_actions; |
| const syncLines: string[] = []; |
| if (sync && typeof sync === 'object') { |
| for (const [k, v] of Object.entries(sync)) { |
| if (v) syncLines.push(`${k}: ${v}`); |
| } |
| } |
| for (const s of syncLines) lines.push(s); |
| let text = lines.filter(Boolean).join('\n').trim(); |
| if (!text) { |
| text = |
| 'Cinematic premium product showcase, photoreal, smooth camera, shallow depth of field.'; |
| } |
| const jewelryCtx = [ |
| productName, |
| ch?.current_state, |
| sc?.environment, |
| sc?.background_elements, |
| at?.dialogue, |
| ...syncLines, |
| ]; |
| if (textSuggestsJewelry(...jewelryCtx)) { |
| text = `${text}\n\n${jewelryWearPlacementPromptBlock()}`; |
| } |
| if (text.length > 20000) text = text.slice(0, 20000); |
| return text; |
| } |
|
|
| |
| export const SEEDANCE_PROMPT_MAX_CHARS = 20000; |
|
|
| export type DirectSeedancePromptParams = { |
| productName: string; |
| description: string; |
| price: string; |
| targetAudience: string; |
| aspectRatio: string; |
| durationSeconds: number; |
| conceptText: string; |
| frameHints?: string[]; |
| |
| generateAudio?: boolean; |
| }; |
|
|
| |
| |
| |
| |
| export function parseDirectConceptLine(conceptText: string): { |
| core: string; |
| angle?: string; |
| trigger?: string; |
| } { |
| let rest = conceptText.trim(); |
| let trigger: string | undefined; |
| const tri = rest.match(/\.\s*Trigger:\s*(.+)$/i); |
| if (tri && tri.index !== undefined) { |
| trigger = tri[1].trim(); |
| rest = rest.slice(0, tri.index).trim(); |
| } |
| let core = rest; |
| let angle: string | undefined; |
| const ang = rest.match(/\.\s*Angle:\s*(.+)$/i); |
| if (ang && ang.index !== undefined) { |
| angle = ang[1].trim(); |
| core = rest.slice(0, ang.index).trim(); |
| } |
| if (!core && (angle || trigger)) { |
| core = [angle, trigger].filter(Boolean).join(' — ') || conceptText.trim(); |
| } |
| return { core: core || conceptText.trim(), angle, trigger }; |
| } |
|
|
| function _beatLabels(durationSeconds: number, hintCount: number): string[] { |
| const d = Math.max(4, Math.min(15, Math.floor(durationSeconds))); |
| if (hintCount <= 0) return []; |
| if (hintCount === 1) return [`0–${d}s`]; |
| if (hintCount === 2) return [`0–${Math.round(d / 2)}s`, `${Math.round(d / 2)}–${d}s`]; |
| const out: string[] = []; |
| for (let i = 0; i < hintCount; i++) { |
| const start = Math.round((i * d) / hintCount); |
| const end = i === hintCount - 1 ? d : Math.round(((i + 1) * d) / hintCount); |
| out.push(`${start}–${end}s`); |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| export function buildDirectSeedancePrompt(p: DirectSeedancePromptParams): string { |
| const name = p.productName.trim(); |
| const ar = p.aspectRatio.trim() || '9:16'; |
| const dur = Math.max(4, Math.min(15, Math.floor(p.durationSeconds))); |
| const parsed = parseDirectConceptLine(p.conceptText); |
| const hints = (p.frameHints ?? []).map((h) => h.trim()).filter(Boolean).slice(0, 4); |
| const labels = _beatLabels(dur, hints.length); |
|
|
| const sections: string[] = []; |
|
|
| sections.push( |
| `[TASK] One continuous ${dur}s ${ar} photoreal product commercial for ${name ? `"${name}"` : 'this product'}. ` + |
| 'Single storyline with smooth transitions—avoid unrelated stock montage cuts.' |
| ); |
|
|
| sections.push(`[CREATIVE] ${parsed.core}`); |
| if (parsed.angle) sections.push(`[ANGLE] ${parsed.angle}`); |
| if (parsed.trigger) sections.push(`[PSYCHOLOGY_TRIGGER] ${parsed.trigger}`); |
|
|
| if (hints.length > 0) { |
| const beats = hints.map((h, i) => `${labels[i]}: ${h}`).join('\n'); |
| sections.push( |
| `[SHOT_BEATS — chronological, one flowing take]\n${beats}\n` + |
| 'Blend these beats without jarring jumps; keep the product readable in every beat.' |
| ); |
| } |
|
|
| const facts: string[] = []; |
| const rawDesc = p.description.trim(); |
| const desc = |
| rawDesc.length > 2800 ? `${rawDesc.slice(0, 2800).trim()}…` : rawDesc; |
| if (desc) facts.push(`Description: ${desc}`); |
| if (p.price.trim()) facts.push(`Price (optional supers / tone only): ${p.price.trim()}`); |
| if (p.targetAudience.trim()) facts.push(`Audience / tone: ${p.targetAudience.trim()}`); |
| if (facts.length) sections.push(`[PRODUCT_CONTEXT]\n${facts.join('\n')}`); |
|
|
| const jewelryLike = textSuggestsJewelry(name, desc, parsed.core, p.conceptText, ...hints); |
|
|
| sections.push( |
| '[REFERENCE_IMAGES] Multiple angles may be provided—treat them as one SKU. ' + |
| 'Match silhouette, metal tone, stone layout, and proportions exactly; do not invent a different design.' |
| ); |
|
|
| if (jewelryLike) { |
| sections.push(jewelryWearPlacementPromptBlock()); |
| } |
|
|
| sections.push( |
| '[LOOK] Soft commercial key + gentle fill, natural skin where people appear, shallow depth when it helps legibility, ' + |
| 'smooth gimbal or slow dolly—no handheld shake unless the creative clearly asks for UGC.' |
| ); |
|
|
| const negBase = |
| 'Extra fingers, warped hands, melting metal, duplicated bands, unreadable text walls, fake logos, harsh unrelated location jumps.'; |
| const negJewelry = |
| 'wrong-finger ring placement, floating or hovering jewelry, metal intersecting palm or ear, mirrored asymmetric design, novelty oversized prop scale on body.'; |
| sections.push(`[NEGATIVE] ${negBase}${jewelryLike ? ` ${negJewelry}` : ''}`); |
|
|
| if (p.generateAudio !== false) { |
| sections.push( |
| '[AUDIO] Light tasteful ambience; optional short VO that supports the angle—avoid overpowering music unless the brief implies it.' |
| ); |
| } else { |
| sections.push('[AUDIO] No voiceover; subtle room tone or silence is fine.'); |
| } |
|
|
| let text = sections.join('\n\n').trim(); |
| if (text.length > SEEDANCE_PROMPT_MAX_CHARS) { |
| text = text.slice(0, SEEDANCE_PROMPT_MAX_CHARS); |
| } |
| return text; |
| } |
|
|
| export async function seedanceCreate(body: { |
| prompt: string; |
| reference_image_urls: string[]; |
| aspect_ratio: string; |
| duration: number; |
| resolution?: string; |
| generate_audio?: boolean; |
| /** KIE model, e.g. `bytedance/seedance-2` or `bytedance/seedance-2-fast`. */ |
| model?: string; |
| }): Promise<{ taskId: string }> { |
| const res = await fetch(`${API_BASE}/api/seedance/create`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Seedance task failed')); |
| return res.json(); |
| } |
|
|
| export function createSeedanceEventSource(taskId: string): EventSource { |
| return new EventSource(`${API_BASE}/api/seedance/events/${encodeURIComponent(taskId)}`); |
| } |
|
|
| export async function seedanceStatus(taskId: string): Promise<{ |
| state?: string; |
| url?: string | null; |
| failMsg?: string | null; |
| }> { |
| const res = await fetch(`${API_BASE}/api/seedance/status/${encodeURIComponent(taskId)}`); |
| if (!res.ok) throw new Error(await parseError(res, 'Seedance status check failed')); |
| return res.json(); |
| } |
|
|
| export function waitForSeedanceVideo(taskId: string, timeoutMs = 600000): Promise<string> { |
| return new Promise((resolve, reject) => { |
| let settled = false; |
| const es = createSeedanceEventSource(taskId); |
| let poller: number | null = null; |
| const pollEveryMs = 10000; |
|
|
| const settleSuccess = (url: string) => { |
| if (settled) return; |
| settled = true; |
| if (poller !== null) window.clearInterval(poller); |
| clearTimeout(t); |
| es.close(); |
| resolve(url); |
| }; |
|
|
| const settleFail = (msg?: string | null) => { |
| if (settled) return; |
| settled = true; |
| if (poller !== null) window.clearInterval(poller); |
| clearTimeout(t); |
| es.close(); |
| reject(new Error(msg || 'Seedance generation failed')); |
| }; |
|
|
| const inspect = (data: { state?: string; url?: string | null; failMsg?: string | null }) => { |
| if (data.state === 'success' && data.url) { |
| settleSuccess(data.url); |
| } else if (data.state === 'fail') { |
| settleFail(data.failMsg); |
| } |
| }; |
|
|
| const startFallbackPolling = () => { |
| if (poller !== null || settled) return; |
| poller = window.setInterval(async () => { |
| if (settled) return; |
| try { |
| const data = await seedanceStatus(taskId); |
| inspect(data); |
| } catch { |
| |
| } |
| }, pollEveryMs); |
| }; |
|
|
| |
| void seedanceStatus(taskId).then(inspect).catch(() => { |
| |
| }); |
|
|
| const t = setTimeout(() => { |
| if (settled) return; |
| settled = true; |
| if (poller !== null) window.clearInterval(poller); |
| es.close(); |
| reject(new Error('Seedance generation timed out')); |
| }, timeoutMs); |
| es.onmessage = (ev) => { |
| if (settled) return; |
| try { |
| const data = JSON.parse(ev.data) as { |
| state?: string; |
| url?: string | null; |
| failMsg?: string | null; |
| }; |
| inspect(data); |
| } catch { |
| |
| } |
| }; |
| es.onerror = () => { |
| |
| startFallbackPolling(); |
| }; |
| }); |
| } |
|
|
| export async function klingGenerate(body: { |
| prompt: string | object; |
| imageUrls?: string[]; |
| model?: string; |
| aspectRatio?: string; |
| generationType?: string; |
| seeds?: number; |
| voiceType?: string; |
| /** 4, 6, or 8 — forwarded to the API so the provider can honor shot length */ |
| durationSeconds?: number; |
| }): Promise<KlingGenerateResponse> { |
| const res = await fetch(`${API_BASE}/api/veo/generate`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'Video start failed')); |
| return res.json(); |
| } |
|
|
| export function createKlingEventSource(taskId: string): EventSource { |
| return new EventSource(`${API_BASE}/api/veo/events/${taskId}`); |
| } |
|
|
| export async function klingStatus(taskId: string): Promise<{ |
| status?: string; |
| url?: string | null; |
| error?: string | null; |
| message?: string | null; |
| }> { |
| const res = await fetch(`${API_BASE}/api/veo/status/${encodeURIComponent(taskId)}`); |
| if (!res.ok) throw new Error(await parseError(res, 'Video status check failed')); |
| return res.json(); |
| } |
|
|
| export function waitForKlingVideo(taskId: string, timeoutMs = 420000): Promise<string> { |
| return new Promise((resolve, reject) => { |
| let settled = false; |
| const es = createKlingEventSource(taskId); |
| let poller: number | null = null; |
| const pollEveryMs = 10000; |
|
|
| const settleSuccess = (url: string) => { |
| if (settled) return; |
| settled = true; |
| if (poller !== null) window.clearInterval(poller); |
| clearTimeout(t); |
| es.close(); |
| resolve(url); |
| }; |
|
|
| const settleFail = (msg?: string | null) => { |
| if (settled) return; |
| settled = true; |
| if (poller !== null) window.clearInterval(poller); |
| clearTimeout(t); |
| es.close(); |
| reject(new Error(msg || 'Generation failed')); |
| }; |
|
|
| const inspect = (data: { |
| status?: string; |
| url?: string | null; |
| error?: string | null; |
| message?: string | null; |
| }) => { |
| if (data.status === 'succeeded' && data.url) { |
| settleSuccess(data.url); |
| } else if (data.status === 'failed' || data.status === 'cancelled') { |
| settleFail(data.error || data.message); |
| } |
| }; |
|
|
| const startFallbackPolling = () => { |
| if (poller !== null || settled) return; |
| poller = window.setInterval(async () => { |
| if (settled) return; |
| try { |
| const data = await klingStatus(taskId); |
| inspect(data); |
| } catch { |
| |
| } |
| }, pollEveryMs); |
| }; |
|
|
| |
| void klingStatus(taskId).then(inspect).catch(() => { |
| |
| }); |
|
|
| const t = setTimeout(() => { |
| if (settled) return; |
| settled = true; |
| if (poller !== null) window.clearInterval(poller); |
| es.close(); |
| reject(new Error('Video generation timed out')); |
| }, timeoutMs); |
| es.onmessage = (ev) => { |
| if (settled) return; |
| try { |
| const data = JSON.parse(ev.data); |
| inspect(data); |
| } catch { |
| |
| } |
| }; |
| es.onerror = () => { |
| |
| startFallbackPolling(); |
| }; |
| }); |
| } |
|
|
| export async function downloadVideo( |
| url: string, |
| opts?: { trimSeconds?: 4 | 6 | 8 } |
| ): Promise<Blob> { |
| const q = new URLSearchParams({ url }); |
| if (opts?.trimSeconds != null) q.set('trimSeconds', String(opts.trimSeconds)); |
| const res = await fetch(`${API_BASE}/api/veo/download?${q}`); |
| if (!res.ok) throw new Error('Download failed'); |
| return res.blob(); |
| } |
|
|
| function loadVideoFromFile(file: File): Promise<HTMLVideoElement> { |
| return new Promise((resolve, reject) => { |
| const v = document.createElement('video'); |
| v.preload = 'metadata'; |
| v.src = URL.createObjectURL(file); |
| v.onloadedmetadata = () => resolve(v); |
| v.onerror = () => { |
| URL.revokeObjectURL(v.src); |
| reject(new Error('Could not read video')); |
| }; |
| }); |
| } |
|
|
| export async function getVideoDuration(file: File): Promise<number> { |
| const v = await loadVideoFromFile(file); |
| try { |
| return v.duration; |
| } finally { |
| URL.revokeObjectURL(v.src); |
| } |
| } |
|
|
| export async function mergeVideos(blobs: Blob[], clips: ClipMetadata[]): Promise<Blob> { |
| const fd = new FormData(); |
| fd.append('clips_data', JSON.stringify(clips)); |
| blobs.forEach((b, i) => fd.append('files', b, `clip_${i}.mp4`)); |
| const res = await fetch(`${API_BASE}/api/export/merge`, { method: 'POST', body: fd }); |
| if (!res.ok) throw new Error(await parseError(res, 'Merge failed')); |
| return res.blob(); |
| } |
|
|
| |
| |
| |
| |
| export async function mapWithConcurrency<T, R>( |
| items: T[], |
| concurrency: number, |
| mapper: (item: T, index: number) => Promise<R>, |
| onProgress?: (completed: number, total: number) => void |
| ): Promise<R[]> { |
| const n = items.length; |
| if (n === 0) return []; |
| const cap = Math.max(1, Math.min(concurrency, n)); |
| const results: R[] = new Array(n); |
| let next = 0; |
| let finished = 0; |
|
|
| async function worker(): Promise<void> { |
| while (true) { |
| const i = next++; |
| if (i >= n) return; |
| results[i] = await mapper(items[i], i); |
| finished++; |
| onProgress?.(finished, n); |
| } |
| } |
|
|
| await Promise.all(Array.from({ length: cap }, () => worker())); |
| return results; |
| } |
|
|
| |
| export const SEGMENT_RENDER_CONCURRENCY = 3; |
|
|
| |
| export async function clipsFromBlobs(blobs: Blob[]): Promise<ClipMetadata[]> { |
| return Promise.all( |
| blobs.map(async (b, i) => { |
| const file = new File([b], `c${i}.mp4`, { type: 'video/mp4' }); |
| const dur = await getVideoDuration(file); |
| return { |
| index: i, |
| startTime: 0, |
| endTime: dur, |
| type: 'video' as const, |
| }; |
| }) |
| ); |
| } |
|
|
| export async function generateSegmentFirstFrame(params: { |
| segment: VeoSegment; |
| referenceImageUrls: string[]; |
| aspectRatio: string; |
| productName: string; |
| }): Promise<{ url: string; model: string; size: string }> { |
| const candidateRefs = Array.from( |
| new Set( |
| params.referenceImageUrls |
| .map((u) => (u || '').trim()) |
| .filter(Boolean) |
| ) |
| ).slice(0, GPT_IMAGE_EDIT_MAX_REFS); |
|
|
| const vettedRefs: string[] = []; |
| for (const url of candidateRefs) { |
| |
| if (!url.includes('/api/images/')) { |
| vettedRefs.push(url); |
| continue; |
| } |
| try { |
| const probe = await fetch(url, { method: 'GET' }); |
| if (probe.ok) vettedRefs.push(url); |
| } catch { |
| |
| vettedRefs.push(url); |
| } |
| } |
|
|
| const referenceImageUrls = vettedRefs.length > 0 ? vettedRefs : candidateRefs; |
| const res = await fetch(`${API_BASE}/api/showcase/segment-first-frame`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| segment: params.segment, |
| reference_image_urls: referenceImageUrls, |
| aspect_ratio: params.aspectRatio, |
| product_name: params.productName, |
| }), |
| }); |
| if (!res.ok) throw new Error(await parseError(res, 'GPT Image first frame failed')); |
| return res.json(); |
| } |
|
|
| export async function generateSegmentVideo( |
| segment: VeoSegment, |
| imageUrl: string, |
| aspectRatio: string, |
| seed: number, |
| voiceType: string, |
| opts: { |
| model?: SegmentVideoModel; |
| productName?: string; |
| seedanceResolution?: '480p' | '720p' | '1080p'; |
| promptOverride?: string; |
| referenceImageUrls?: string[]; |
| } = {} |
| ): Promise<Blob> { |
| const clipSec = segmentClipSeconds(segment); |
| const model = opts.model ?? 'seedance-2-fast'; |
|
|
| if (isSeedanceSegmentModel(model)) { |
| const prompt = |
| opts.promptOverride?.trim() || segmentToSeedancePrompt(segment, opts.productName); |
| const { taskId } = await seedanceCreate({ |
| prompt, |
| reference_image_urls: |
| opts.referenceImageUrls && opts.referenceImageUrls.length > 0 |
| ? opts.referenceImageUrls |
| : [imageUrl], |
| aspect_ratio: aspectRatio, |
| duration: clipSec, |
| resolution: opts.seedanceResolution ?? '480p', |
| generate_audio: voiceType.trim().toLowerCase() !== 'none', |
| model: kieSeedanceModelId(model), |
| }); |
| const url = await waitForSeedanceVideo(taskId); |
| return downloadVideo(url, { trimSeconds: clipSec }); |
| } |
|
|
| const { taskId } = await klingGenerate({ |
| prompt: opts.promptOverride?.trim() || segment, |
| imageUrls: [imageUrl], |
| model: 'veo3_fast', |
| aspectRatio, |
| generationType: 'FIRST_AND_LAST_FRAMES_2_VIDEO', |
| seeds: seed, |
| voiceType, |
| durationSeconds: clipSec, |
| }); |
| const url = await waitForKlingVideo(taskId); |
| return downloadVideo(url, { trimSeconds: clipSec }); |
| } |
|
|