import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import type { SegmentsPayload } from '@/types'; import { ConceptSelector } from '@/components/showcase/ConceptSelector'; import { SectionCard } from '@/components/showcase/SectionCard'; import { SectionHeader } from '@/components/showcase/SectionHeader'; import { TimelineStepper } from '@/components/showcase/TimelineStepper'; import { SeedanceReplicateCostNote } from '@/components/showcase/SeedanceReplicateCostNote'; import { buildDirectSeedancePrompt, clipsFromBlobs, downloadVideo, generateDirectConcepts, generateSegmentFirstFrame, generateSegmentVideo, hostImageFromUrl, imageSrcForHeroPreview, isSeedanceSegmentModel, kieSeedanceModelId, mapWithConcurrency, mergeVideos, pickProductReferenceUrlsForGpt, scrapeProductPage, SEGMENT_RENDER_CONCURRENCY, seedanceCreate, segmentClipSeconds, segmentToSeedancePrompt, showcasePlanStream, uploadImage, waitForSeedanceVideo, type HealthStatus, type SegmentVideoModel, type ShowcaseConceptId, } from '@/api'; type Phase = 'brief' | 'planning' | 'review' | 'rendering' | 'done'; type ShowcaseFlowProps = { health: HealthStatus | null; }; type LibraryVideo = { id: string; url: string; title: string; createdAt: number; successfulClips: number; totalClips: number; }; type ConceptPlan = { conceptKey: string; conceptLabel: string; conceptTemplateId: ShowcaseConceptId; payload: SegmentsPayload; }; type BriefFlowMode = 'planned' | 'direct_15s'; type DirectConceptGroups = { ugc: string[]; model_showcase: string[]; feature_highlight: string[]; }; type ConceptExecutionPlan = { duration_seconds: number; render_mode: 'direct_seedance' | 'segmented'; reference_frame_prompts: string[]; }; const TARGET_AUDIENCE_OPTIONS = [ 'Women 18–24', 'Women 25–34', 'Women 35–44', 'Urban Tier 1 Women', 'Urban Tier 2 Women', 'Working Professionals', 'Corporate Women', 'Women Entrepreneurs', 'Disposable Income ₹30k+', 'Living Independently', 'Married, No Kids', 'Newly Married', 'Single Women', 'Monthly Online Shoppers', 'English Digital-first', 'Demi-fine Jewelry Buyers', 'Minimalist Lovers', 'Statement Buyers', 'Everyday Wear Buyers', 'Occasion Shoppers', 'Layering Lovers', 'Choker Buyers', 'CZ Jewelry Fans', 'Anti-tarnish Seekers', 'Hypoallergenic Buyers', 'Gold Finish Lovers', 'Silver Finish Lovers', 'Indo-western Fans', 'Contemporary Ethnic', 'Sustainable Shoppers', 'Premium Accessories', 'IG Jewelry Followers', 'Pinterest Users', 'Fashion Discovery', 'Outfit Reel Savers', 'Online Jewelry Shoppers', 'Cart Abandoners', 'Instagram Shop Users', 'Google Shoppers', 'Self-Gifters', 'Repeat Buyers', 'Sale-responsive', 'Value Premium Buyers', '₹1.5k–₹3k Buyers', 'COD Buyers', 'UPI-first', 'Mobile-only', 'D2C Followers', 'Instagram Trusters', 'Brand Switchers', 'Limited Edition Buyers', 'First-time Buyers', 'Impulse Buyers', 'Birthday Self-Gift', 'Anniversary Buyers', 'Wedding Guests', 'Bridesmaids', 'Festive Buyers', 'Rakhi Self-Gift', 'Valentine Self-love', 'New Year Parties', 'Office Parties', 'Vacation Shoppers', 'Date-night', 'Wedding Season', 'Festive Office', 'Outfit Completion', 'Reel Jewelry', 'Fashion Influencer Followers', 'Jewelry Influencer Fans', 'Styling Reel Fans', 'Vogue India', 'Elle India', "Harper's Bazaar", 'Nykaa Fashion', 'Myntra Premium', 'Ajio Luxe', 'Fashion Page Followers', 'Self-love Believers', 'Self-rewarders', 'Aesthetic Buyers', 'Aspirational Value', 'Uniqueness Seekers', 'Anti-mass Market', 'Creative Women', 'Fashion Experimenters', 'Early Adopters', 'Global Trend Followers', 'Quiet Luxury Fans', 'Instagram-first', 'UGC Creators', 'Event Goers', 'Photo Dressers', 'Website Visitors', 'Product Viewers', 'Add-to-Cart', 'Past Purchasers', 'Top 10% LAL', ] as const; export function ShowcaseFlow({ health }: ShowcaseFlowProps) { const [phase, setPhase] = useState('brief'); const [productName, setProductName] = useState(''); const [price, setPrice] = useState(''); const [description, setDescription] = useState(''); const [targetAudience, setTargetAudience] = useState(''); const [shotCount, setShotCount] = useState(3); const [aspectRatio, setAspectRatio] = useState('9:16'); const [seed, setSeed] = useState(12005); const [voiceType, setVoiceType] = useState('Crisp'); const [videoModel, setVideoModel] = useState('seedance-2-fast'); const [seedanceResolution, setSeedanceResolution] = useState<'480p' | '720p' | '1080p'>('480p'); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [hostedUrl, setHostedUrl] = useState(null); const [productPageUrl, setProductPageUrl] = useState(''); const [heroRemoteUrl, setHeroRemoteUrl] = useState(null); const [scrapedImageUrls, setScrapedImageUrls] = useState([]); const [scrapeBusy, setScrapeBusy] = useState(false); /** Synthesize a per-shot keyframe with OpenAI GPT Image (multi-ref edits) before Veo. */ const [useGptFirstFrames, setUseGptFirstFrames] = useState(true); const [renderLabel, setRenderLabel] = useState(''); useEffect(() => { if (health && !health.openai_configured) { setUseGptFirstFrames(false); } }, [health]); const [planProgress, setPlanProgress] = useState(0); const [conceptPlans, setConceptPlans] = useState([]); const [selectedRenderConcepts, setSelectedRenderConcepts] = useState([]); const [segmentPromptEdits, setSegmentPromptEdits] = useState>({}); const [renderIndex, setRenderIndex] = useState(0); const [renderTotalSegments, setRenderTotalSegments] = useState(0); const [renderUnitLabel, setRenderUnitLabel] = useState<'segments' | 'concepts'>('segments'); const [error, setError] = useState(null); const [finalUrl, setFinalUrl] = useState(null); const [libraryVideos, setLibraryVideos] = useState([]); const libraryVideosRef = useRef([]); const [abort, setAbort] = useState(null); const [phaseElapsedSeconds, setPhaseElapsedSeconds] = useState(0); const [planPulseIndex, setPlanPulseIndex] = useState(0); const [renderPulseIndex, setRenderPulseIndex] = useState(0); const [briefFlowMode, setBriefFlowMode] = useState('planned'); const [directConcepts, setDirectConcepts] = useState([]); const [directConceptGroups, setDirectConceptGroups] = useState(null); const [directConceptExecutionPlans, setDirectConceptExecutionPlans] = useState>({}); const [selectedPlannedConcepts, setSelectedPlannedConcepts] = useState([]); const [selectedDirectConcepts, setSelectedDirectConcepts] = useState([]); const [conceptsBusy, setConceptsBusy] = useState(false); const [selectedReferenceImageUrls, setSelectedReferenceImageUrls] = useState([]); const onPickImage = useCallback((f: File | null) => { setImageFile(f); if (imagePreview && imagePreview.startsWith('blob:')) URL.revokeObjectURL(imagePreview); setImagePreview(f ? URL.createObjectURL(f) : null); setHostedUrl(null); setHeroRemoteUrl(null); setScrapedImageUrls([]); setSelectedReferenceImageUrls([]); }, [imagePreview]); const importFromProductUrl = async () => { setError(null); const u = productPageUrl.trim(); if (!u) { setError('Paste a product page URL.'); return; } setScrapeBusy(true); try { const data = await scrapeProductPage(u); setProductName((data.product_name || '').trim()); setPrice((data.price || '').trim()); setDescription((data.description || '').trim()); const urls = Array.isArray(data.image_urls) ? data.image_urls.filter(Boolean) : []; if (!urls.length) { setError('No product images found on this page.'); return; } setScrapedImageUrls(urls); setSelectedReferenceImageUrls(urls.slice(0, 6)); setHeroRemoteUrl(urls[0]); setImageFile(null); const hosted = await hostImageFromUrl(urls[0]); setHostedUrl(hosted.url); setImagePreview(hosted.url); } catch (e) { setError(e instanceof Error ? e.message : 'Import failed'); } finally { setScrapeBusy(false); } }; const selectScrapedHero = async (url: string) => { setError(null); try { setHeroRemoteUrl(url); setSelectedReferenceImageUrls((prev) => (prev.includes(url) ? prev : [...prev, url])); setImageFile(null); const hosted = await hostImageFromUrl(url); setHostedUrl(hosted.url); setImagePreview(hosted.url); } catch (e) { setError(e instanceof Error ? e.message : 'Could not use that image'); } }; const runPlan = async () => { setError(null); setConceptPlans([]); setSelectedRenderConcepts([]); if (!productName.trim()) { setError('Add a product name.'); return; } if (selectedPlannedConcepts.length === 0) { setError('Generate and select at least one creative concept.'); return; } if (!imageFile && !hostedUrl) { setError('Import a product URL or upload a hero image.'); return; } setPhase('planning'); setPlanProgress(0); const ac = new AbortController(); setAbort(ac); try { let veoHost = hostedUrl; if (imageFile) { const up = await uploadImage(imageFile); veoHost = up.url; setHostedUrl(up.url); } if (!veoHost) { setError('Could not resolve a hosted hero image for video generation.'); setPhase('brief'); return; } const progressByConcept: Partial> = {}; const conceptOrder = [...selectedPlannedConcepts]; const planned = await mapWithConcurrency( conceptOrder, Math.min(3, conceptOrder.length), async (conceptText) => { const conceptTemplateId = templateIdForGeneratedConcept(conceptText); try { const payload: SegmentsPayload = await showcasePlanStream( { productName: productName.trim(), description: [description.trim(), `Creative concept: ${conceptText}`].filter(Boolean).join('\n'), price: price.trim(), targetAudience: targetAudience.trim(), shotCount, creativeConcept: conceptTemplateId, image: imageFile ?? null, heroImageUrl: imageFile ? undefined : heroRemoteUrl || undefined, }, (ev) => { if (ev.event === 'segment') { progressByConcept[conceptText] = ev.progress; const sum = conceptOrder.reduce((acc, id) => acc + (progressByConcept[id] ?? 0), 0); setPlanProgress(Math.round(sum / conceptOrder.length)); } }, ac.signal ); return { ok: true as const, conceptKey: conceptText, conceptLabel: conceptText, conceptTemplateId, payload }; } catch (e) { return { ok: false as const, conceptKey: conceptText, error: e instanceof Error ? e.message : 'Planning failed', }; } } ); const successes = planned .filter((r) => r.ok) .map((r) => ({ conceptKey: r.conceptKey, conceptLabel: r.conceptLabel, conceptTemplateId: r.conceptTemplateId, payload: r.payload, })); const failures = planned.filter((r) => !r.ok); if (successes.length === 0) { throw new Error(failures[0]?.error || 'Planning failed'); } setConceptPlans(successes); setSelectedRenderConcepts(successes.map((s) => s.conceptKey)); setSegmentPromptEdits({}); if (failures.length > 0) { setError(`Planned ${successes.length}/${conceptOrder.length} concepts. Some concepts failed.`); } setPlanProgress(100); setPhase('review'); } catch (e) { setError(e instanceof Error ? e.message : 'Planning failed'); setPhase('brief'); } finally { setAbort(null); } }; const cancelPlan = () => { abort?.abort(); }; /** 2–4 distinct URLs for GPT Image edits: hosted hero + storefront hero + scraped gallery (deduped). */ const referenceUrlsForGpt = useMemo( () => pickProductReferenceUrlsForGpt({ hostedUrl, heroRemoteUrl, scrapedImageUrls, }), [hostedUrl, heroRemoteUrl, scrapedImageUrls] ); const runRender = async () => { if (!hostedUrl) { setError('Missing hosted image URL. Re-run plan from the brief step.'); return; } setError(null); setPhase('rendering'); const plansToRender = conceptPlans.filter((p) => selectedRenderConcepts.includes(p.conceptKey)); if (plansToRender.length === 0) { setError('Select at least one concept from the shot plan to render.'); return; } const totalSegments = plansToRender.reduce((acc, p) => acc + p.payload.segments.length, 0); setRenderIndex(0); setRenderTotalSegments(totalSegments); setRenderUnitLabel('segments'); setRenderLabel('Starting parallel render…'); const refs = referenceUrlsForGpt; const doGpt = useGptFirstFrames && gptOptionEnabled; const modelLabel = isSeedanceSegmentModel(videoModel) ? 'Seedance' : 'Veo'; try { const completedSegmentsByConcept: Partial> = {}; let gptFrameFallbacks = 0; const renderedConcepts = await mapWithConcurrency( plansToRender, Math.min(2, plansToRender.length), async ({ conceptKey, conceptLabel, payload }) => { const next = await mapWithConcurrency( payload.segments, SEGMENT_RENDER_CONCURRENCY, async (seg, index) => { let veoStillUrl = hostedUrl; const promptKey = `${conceptKey}:${index}`; const promptOverride = segmentPromptEdits[promptKey]?.trim() || undefined; try { if (doGpt) { try { const { url } = await generateSegmentFirstFrame({ segment: seg, referenceImageUrls: refs, aspectRatio, productName: productName.trim(), }); veoStillUrl = url; } catch (e) { // Fallback: keep rendering with hosted hero image if GPT keyframe fails. gptFrameFallbacks += 1; console.warn('GPT keyframe failed, using hosted hero image for segment', { conceptKey, segmentIndex: index, error: e instanceof Error ? e.message : String(e), }); } } const blob = await generateSegmentVideo( seg, veoStillUrl, aspectRatio, seed, voiceType, { model: videoModel, productName: productName.trim(), seedanceResolution, promptOverride, } ); return { ok: true as const, blob, index }; } catch (e) { return { ok: false as const, index, error: e instanceof Error ? e.message : 'Segment render failed', }; } }, (done, total) => { completedSegmentsByConcept[conceptKey] = done; const doneAll = plansToRender.reduce((acc, p) => acc + (completedSegmentsByConcept[p.conceptKey] ?? 0), 0); setRenderIndex(doneAll); setRenderLabel( doGpt ? `GPT keyframes + ${modelLabel}: ${conceptLabel} ${done}/${total} · total ${doneAll}/${totalSegments}` : `${modelLabel}: ${conceptLabel} ${done}/${total} · total ${doneAll}/${totalSegments}` ); } ); const successes = next .filter((r) => r.ok) .sort((a, b) => a.index - b.index) .map((r) => r.blob); const failures = next.filter((r) => !r.ok); if (successes.length === 0) { return { ok: false as const, conceptKey, conceptLabel, error: failures[0]?.error || 'Render failed', }; } const meta = await clipsFromBlobs(successes); const merged = await mergeVideos(successes, meta); return { ok: true as const, conceptKey, conceptLabel, merged, successfulClips: successes.length, totalClips: payload.segments.length, failedClips: failures.length, }; } ); setRenderLabel(''); const successes = renderedConcepts.filter((r) => r.ok); const failures = renderedConcepts.filter((r) => !r.ok); if (successes.length === 0) throw new Error(failures[0]?.error || 'Render failed'); const createdAt = Date.now(); const items: LibraryVideo[] = successes.map((s, idx) => { const url = URL.createObjectURL(s.merged); return { id: `${createdAt}-${idx}-${Math.random().toString(36).slice(2, 8)}`, url, title: `${productName.trim() || 'Product showcase'} · ${s.conceptLabel}`, createdAt: createdAt + idx, successfulClips: s.successfulClips, totalClips: s.totalClips, }; }); setFinalUrl(items[0]?.url ?? null); setLibraryVideos((prev) => [...items, ...prev]); const failedConcepts = failures.length; const partialClips = successes.reduce((acc, s) => acc + (s.failedClips > 0 ? 1 : 0), 0); if (failedConcepts > 0 || partialClips > 0) { setError( `Rendered ${successes.length}/${plansToRender.length} concepts. ${failedConcepts} concept(s) failed, ${partialClips} concept(s) had partial clip failures.` ); } else if (gptFrameFallbacks > 0) { setError( `Rendered successfully with ${gptFrameFallbacks} GPT keyframe fallback(s). Some OpenAI first-frame calls failed (502), so those shots used your hosted hero image.` ); } setPhase('done'); } catch (e) { setRenderLabel(''); setError(e instanceof Error ? e.message : 'Render failed'); setPhase('review'); } }; const templateIdForGeneratedConcept = useCallback( (conceptText: string): ShowcaseConceptId => { if (directConceptGroups?.ugc.includes(conceptText)) return 'ugc_authentic'; if (directConceptGroups?.feature_highlight.includes(conceptText)) return 'tech_minimal'; return 'luxury_studio'; }, [directConceptGroups] ); const runDirectConceptIdeas = async () => { setError(null); if (!productName.trim()) { setError('Add a product name first, then generate concepts.'); return; } setConceptsBusy(true); try { const { concepts, concept_groups, concept_execution_plans } = await generateDirectConcepts({ productName: productName.trim(), description: description.trim(), price: price.trim(), targetAudience: targetAudience.trim(), count: 10, ugcCount: 4, modelShowcaseCount: 3, featureHighlightCount: 3, }); const unique = Array.from( new Set( (concepts || []) .map((c) => c.trim()) .filter(Boolean) ) ).slice(0, 10); if (unique.length === 0) throw new Error('Could not generate concepts. Try again.'); const groups: DirectConceptGroups = concept_groups ?? { ugc: unique.slice(0, 4), model_showcase: unique.slice(4, 7), feature_highlight: unique.slice(7, 10), }; setDirectConcepts(unique); setDirectConceptGroups(groups); setDirectConceptExecutionPlans(concept_execution_plans ?? {}); setSelectedPlannedConcepts(unique.slice(0, Math.min(1, unique.length))); setSelectedDirectConcepts(unique.slice(0, Math.min(1, unique.length))); } catch (e) { setError(e instanceof Error ? e.message : 'Concept generation failed'); } finally { setConceptsBusy(false); } }; const runDirectConceptRender = async () => { setError(null); if (!productName.trim()) { setError('Add a product name.'); return; } if (!isSeedanceSegmentModel(videoModel)) { setError('AI concept render flow is available for Seedance models only.'); return; } if (selectedDirectConcepts.length === 0) { setError('Generate AI concepts and select at least one concept.'); return; } if (!imageFile && !hostedUrl && selectedReferenceImageUrls.length === 0) { setError('Import a product URL or upload a hero image.'); return; } setPhase('rendering'); setRenderIndex(0); setRenderTotalSegments(selectedDirectConcepts.length); setRenderUnitLabel('concepts'); setRenderLabel('Generating AI-planned concept videos…'); try { let hosted = hostedUrl; if (imageFile) { const up = await uploadImage(imageFile); hosted = up.url; setHostedUrl(up.url); setImagePreview(up.url); } const refs = Array.from( new Set( [...selectedReferenceImageUrls, heroRemoteUrl || '', hosted || ''] .map((u) => u.trim()) .filter(Boolean) ) ).slice(0, 9); if (refs.length === 0) throw new Error('No valid reference images available for Seedance.'); const rendered = await mapWithConcurrency( selectedDirectConcepts, Math.min(2, selectedDirectConcepts.length), async (conceptText) => { const plan = directConceptExecutionPlans[conceptText]; // direct_15s flow: always request a full 15s Seedance clip (backend clamps 4–15). const durationSeconds = 15; const framePrompts = (plan?.reference_frame_prompts ?? [conceptText]).slice(0, 4); // Direct Seedance flow: use scraped/hosted reference URLs only (no GPT keyframes). const seedanceRefs = refs; if ((plan?.render_mode ?? 'direct_seedance') === 'direct_seedance' && durationSeconds <= 15) { const { taskId } = await seedanceCreate({ model: kieSeedanceModelId(videoModel), prompt: buildDirectSeedancePrompt({ productName: productName.trim(), description: description.trim(), price: price.trim(), targetAudience: targetAudience.trim(), aspectRatio, durationSeconds, conceptText, frameHints: framePrompts, generateAudio: voiceType.trim().toLowerCase() !== 'none', }), reference_image_urls: seedanceRefs, aspect_ratio: aspectRatio, duration: durationSeconds, resolution: seedanceResolution, generate_audio: voiceType.trim().toLowerCase() !== 'none', }); const outUrl = await waitForSeedanceVideo(taskId); const blob = await downloadVideo(outUrl); return { conceptText, blob, durationSeconds, mode: 'direct_seedance' as const }; } const conceptTemplateId = templateIdForGeneratedConcept(conceptText); const autoShotCount = Math.max(3, Math.min(6, Math.ceil(durationSeconds / 4))); const payload: SegmentsPayload = await showcasePlanStream({ productName: productName.trim(), description: [description.trim(), `Creative concept: ${conceptText}`].filter(Boolean).join('\n'), price: price.trim(), targetAudience: targetAudience.trim(), shotCount: autoShotCount, creativeConcept: conceptTemplateId, image: imageFile ?? null, heroImageUrl: imageFile ? undefined : heroRemoteUrl || undefined, }, () => {}); const perShot = await mapWithConcurrency( payload.segments, SEGMENT_RENDER_CONCURRENCY, async (seg) => generateSegmentVideo(seg, seedanceRefs[0] || refs[0], aspectRatio, seed, voiceType, { model: videoModel, productName: productName.trim(), seedanceResolution, referenceImageUrls: seedanceRefs, }) ); const meta = await clipsFromBlobs(perShot); const merged = await mergeVideos(perShot, meta); return { conceptText, blob: merged, durationSeconds, mode: 'segmented' as const }; }, (done, total) => { setRenderIndex(done); setRenderLabel(`AI-planned concepts: ${done}/${total}`); } ); const createdAt = Date.now(); const items: LibraryVideo[] = rendered.map((r, idx) => { const url = URL.createObjectURL(r.blob); return { id: `${createdAt}-${idx}-${Math.random().toString(36).slice(2, 8)}`, url, title: `${productName.trim() || 'Product showcase'} · ${r.conceptText.slice(0, 64)} · ${r.durationSeconds}s ` + `${r.mode === 'segmented' ? 'segmented' : 'direct'}`, createdAt: createdAt + idx, successfulClips: 1, totalClips: 1, }; }); setRenderLabel(''); setFinalUrl(items[0]?.url ?? null); setLibraryVideos((prev) => [...items, ...prev]); setPhase('done'); } catch (e) { setRenderLabel(''); setError(e instanceof Error ? e.message : 'AI concept generation failed'); setPhase('brief'); } }; const reset = () => { setFinalUrl(null); setConceptPlans([]); setSelectedRenderConcepts([]); setPhase('brief'); setRenderIndex(0); setRenderTotalSegments(0); setRenderUnitLabel('segments'); setError(null); setProductPageUrl(''); setHeroRemoteUrl(null); setScrapedImageUrls([]); setRenderLabel(''); setSegmentPromptEdits({}); setDirectConcepts([]); setDirectConceptGroups(null); setDirectConceptExecutionPlans({}); setSelectedPlannedConcepts([]); setSelectedDirectConcepts([]); setSelectedReferenceImageUrls([]); }; useEffect(() => { libraryVideosRef.current = libraryVideos; }, [libraryVideos]); useEffect(() => { return () => { for (const item of libraryVideosRef.current) { URL.revokeObjectURL(item.url); } }; }, []); const heroPreviewSrc = useMemo( () => imageSrcForHeroPreview(heroRemoteUrl, imagePreview), [heroRemoteUrl, imagePreview] ); const canUseGptFrames = referenceUrlsForGpt.length >= 1; const openaiReady = health == null || health.openai_configured; const gptOptionEnabled = canUseGptFrames && openaiReady; const renderProgressPct = renderTotalSegments ? Math.min(100, (renderIndex / renderTotalSegments) * 100) : 0; const planPulseMessages = [ 'Shaping narrative arc and camera rhythm…', 'Aligning product highlights to visual beats…', 'Balancing continuity, mood, and pacing…', ]; const renderPulseMessages = [ 'Rendering clips in parallel threads…', 'Maintaining visual continuity between shots…', 'Preparing final merge and audio sync…', ]; const phaseOrder: Phase[] = ['brief', 'planning', 'review', 'rendering', 'done']; const phaseIndex = phaseOrder.indexOf(phase); const phaseLabels: Record = { brief: 'Brief', planning: 'Planning', review: 'Review', rendering: 'Rendering', done: 'Master', }; const conceptSelectionGroups = [ { key: 'ugc', label: '4 UGC Concepts', items: directConceptGroups?.ugc ?? [] }, { key: 'model_showcase', label: '3 Model Showcase Concepts', items: directConceptGroups?.model_showcase ?? [] }, { key: 'feature_highlight', label: '3 Feature Highlight Concepts', items: directConceptGroups?.feature_highlight ?? [] }, ]; const seedanceDirectCostEstimate = useMemo(() => { if (briefFlowMode !== 'direct_15s') return null; const n = selectedDirectConcepts.length; if (n === 0) return null; return { clipCount: n, totalOutputSeconds: n * 15 }; }, [briefFlowMode, selectedDirectConcepts]); const seedancePlannedRenderCostEstimate = useMemo(() => { const plans = conceptPlans.filter((p) => selectedRenderConcepts.includes(p.conceptKey)); let clipCount = 0; let totalOutputSeconds = 0; for (const p of plans) { for (const seg of p.payload.segments) { clipCount += 1; totalOutputSeconds += segmentClipSeconds(seg); } } if (clipCount === 0) return null; return { clipCount, totalOutputSeconds }; }, [conceptPlans, selectedRenderConcepts]); useEffect(() => { if (phase !== 'planning' && phase !== 'rendering') { setPhaseElapsedSeconds(0); return; } setPhaseElapsedSeconds(0); const interval = window.setInterval(() => { setPhaseElapsedSeconds((s) => s + 1); }, 1000); return () => window.clearInterval(interval); }, [phase]); useEffect(() => { if (phase !== 'planning') { setPlanPulseIndex(0); return; } const interval = window.setInterval(() => { setPlanPulseIndex((i) => (i + 1) % planPulseMessages.length); }, 2800); return () => window.clearInterval(interval); }, [phase, planPulseMessages.length]); useEffect(() => { if (phase !== 'rendering') { setRenderPulseIndex(0); return; } const interval = window.setInterval(() => { setRenderPulseIndex((i) => (i + 1) % renderPulseMessages.length); }, 2600); return () => window.clearInterval(interval); }, [phase, renderPulseMessages.length]); return (
{phase === 'brief' && (
Import from URL

Scraper pulls JSON-LD, Open Graph, and Shopify{' '} /products/<handle>.json {' '} images.

setProductPageUrl(e.target.value)} placeholder="https://…/products/your-handle" />
{scrapedImageUrls.length > 1 && (
Gallery — tap hero
{scrapedImageUrls.map((u) => ( ))}
)} {briefFlowMode === 'direct_15s' && scrapedImageUrls.length > 0 && (
Reference images for generation (multi-select)

Select multiple product angles so Seedance keeps product identity accurate.

{scrapedImageUrls.map((u) => { const checked = selectedReferenceImageUrls.includes(u); return ( ); })}

Selected: {selectedReferenceImageUrls.length} / {scrapedImageUrls.length} (up to 9 used)

)}
Generation flow

{briefFlowMode === 'planned' ? 'Creates a shot plan first, then renders and merges clips.' : 'AI chooses per-concept duration and render mode: direct Seedance (<=15s) or segmented+merge (>15s).'}

{briefFlowMode === 'direct_15s' && ( 0} selectedConcepts={selectedDirectConcepts} groups={conceptSelectionGroups} onRegenerate={runDirectConceptIdeas} onToggle={(concept, checked) => setSelectedDirectConcepts((prev) => checked ? [...prev, concept] : prev.filter((x) => x !== concept) ) } /> )}