| 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<Phase>('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<SegmentVideoModel>('seedance-2-fast'); |
| const [seedanceResolution, setSeedanceResolution] = useState<'480p' | '720p' | '1080p'>('480p'); |
| const [imageFile, setImageFile] = useState<File | null>(null); |
| const [imagePreview, setImagePreview] = useState<string | null>(null); |
| const [hostedUrl, setHostedUrl] = useState<string | null>(null); |
| const [productPageUrl, setProductPageUrl] = useState(''); |
| const [heroRemoteUrl, setHeroRemoteUrl] = useState<string | null>(null); |
| const [scrapedImageUrls, setScrapedImageUrls] = useState<string[]>([]); |
| const [scrapeBusy, setScrapeBusy] = useState(false); |
| |
| 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<ConceptPlan[]>([]); |
| const [selectedRenderConcepts, setSelectedRenderConcepts] = useState<string[]>([]); |
| const [segmentPromptEdits, setSegmentPromptEdits] = useState<Record<string, string>>({}); |
|
|
| const [renderIndex, setRenderIndex] = useState(0); |
| const [renderTotalSegments, setRenderTotalSegments] = useState(0); |
| const [renderUnitLabel, setRenderUnitLabel] = useState<'segments' | 'concepts'>('segments'); |
| const [error, setError] = useState<string | null>(null); |
| const [finalUrl, setFinalUrl] = useState<string | null>(null); |
| const [libraryVideos, setLibraryVideos] = useState<LibraryVideo[]>([]); |
| const libraryVideosRef = useRef<LibraryVideo[]>([]); |
| const [abort, setAbort] = useState<AbortController | null>(null); |
| const [phaseElapsedSeconds, setPhaseElapsedSeconds] = useState(0); |
| const [planPulseIndex, setPlanPulseIndex] = useState(0); |
| const [renderPulseIndex, setRenderPulseIndex] = useState(0); |
| const [briefFlowMode, setBriefFlowMode] = useState<BriefFlowMode>('planned'); |
| const [directConcepts, setDirectConcepts] = useState<string[]>([]); |
| const [directConceptGroups, setDirectConceptGroups] = useState<DirectConceptGroups | null>(null); |
| const [directConceptExecutionPlans, setDirectConceptExecutionPlans] = useState<Record<string, ConceptExecutionPlan>>({}); |
| const [selectedPlannedConcepts, setSelectedPlannedConcepts] = useState<string[]>([]); |
| const [selectedDirectConcepts, setSelectedDirectConcepts] = useState<string[]>([]); |
| const [conceptsBusy, setConceptsBusy] = useState(false); |
| const [selectedReferenceImageUrls, setSelectedReferenceImageUrls] = useState<string[]>([]); |
|
|
| 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<Record<string, number>> = {}; |
| 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(); |
| }; |
|
|
| |
| 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<Record<string, number>> = {}; |
| 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) { |
| |
| 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]; |
| |
| const durationSeconds = 15; |
| const framePrompts = (plan?.reference_frame_prompts ?? [conceptText]).slice(0, 4); |
| |
| 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<Phase, string> = { |
| 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 ( |
| <div className="space-y-10"> |
| <TimelineStepper |
| title="Workflow progress" |
| currentStepLabel={phaseLabels[phase]} |
| steps={phaseOrder} |
| stepLabels={phaseLabels} |
| activeIndex={phaseIndex} |
| /> |
| <AnimatePresence mode="wait"> |
| {phase === 'brief' && ( |
| <motion.section |
| key="brief" |
| initial={{ opacity: 0, y: 12 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -10 }} |
| transition={{ duration: 0.3 }} |
| className="studio-card p-6 sm:p-8" |
| > |
| <SectionHeader |
| badge="1" |
| title="Product brief" |
| description="Import a storefront URL to autofill copy and gallery, or type manually. A hosted hero image is required for Veo." |
| /> |
| |
| <div className="studio-import-panel mt-8"> |
| <span className="studio-label text-accent/80">Import from URL</span> |
| <p className="mt-2 text-sm leading-relaxed text-slate-600"> |
| Scraper pulls JSON-LD, Open Graph, and Shopify{' '} |
| <code className="rounded-md bg-slate-200 px-1.5 py-0.5 text-xs text-slate-700"> |
| /products/<handle>.json |
| </code>{' '} |
| images. |
| </p> |
| <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-stretch"> |
| <input |
| type="url" |
| className="studio-input min-w-0 flex-1 !mt-0" |
| value={productPageUrl} |
| onChange={(e) => setProductPageUrl(e.target.value)} |
| placeholder="https://…/products/your-handle" |
| /> |
| <button |
| type="button" |
| disabled={scrapeBusy} |
| onClick={importFromProductUrl} |
| className="studio-btn-secondary shrink-0 px-6 disabled:opacity-50" |
| > |
| {scrapeBusy ? 'Importing…' : 'Import product'} |
| </button> |
| </div> |
| {scrapedImageUrls.length > 1 && ( |
| <div className="mt-5 border-t border-slate-200 pt-5"> |
| <span className="studio-label">Gallery — tap hero</span> |
| <div className="mt-3 flex flex-wrap gap-2.5"> |
| {scrapedImageUrls.map((u) => ( |
| <button |
| key={u} |
| type="button" |
| onClick={() => selectScrapedHero(u)} |
| className={`relative h-16 w-16 overflow-hidden rounded-xl border-2 transition shadow-md ${ |
| heroRemoteUrl === u |
| ? 'border-accent ring-2 ring-accent/30' |
| : 'border-slate-200 hover:border-slate-300' |
| }`} |
| > |
| <img src={u} alt="" loading="lazy" className="h-full w-full object-cover" /> |
| </button> |
| ))} |
| </div> |
| </div> |
| )} |
| {briefFlowMode === 'direct_15s' && scrapedImageUrls.length > 0 && ( |
| <div className="mt-5 border-t border-slate-200 pt-5"> |
| <span className="studio-label">Reference images for generation (multi-select)</span> |
| <p className="mt-1 text-xs text-slate-600"> |
| Select multiple product angles so Seedance keeps product identity accurate. |
| </p> |
| <div className="mt-3 flex flex-wrap gap-2.5"> |
| {scrapedImageUrls.map((u) => { |
| const checked = selectedReferenceImageUrls.includes(u); |
| return ( |
| <button |
| key={`ref-${u}`} |
| type="button" |
| onClick={() => |
| setSelectedReferenceImageUrls((prev) => |
| prev.includes(u) ? prev.filter((x) => x !== u) : [...prev, u] |
| ) |
| } |
| className={`relative h-16 w-16 overflow-hidden rounded-xl border-2 transition shadow-md ${ |
| checked |
| ? 'border-accent ring-2 ring-accent/30' |
| : 'border-slate-200 hover:border-slate-300' |
| }`} |
| > |
| <img src={u} alt="" loading="lazy" className="h-full w-full object-cover" /> |
| </button> |
| ); |
| })} |
| </div> |
| <p className="mt-2 text-xs text-slate-500"> |
| Selected: {selectedReferenceImageUrls.length} / {scrapedImageUrls.length} (up to 9 used) |
| </p> |
| </div> |
| )} |
| </div> |
| |
| <div className="mt-8 grid gap-5 sm:grid-cols-2"> |
| <div className="block sm:col-span-2"> |
| <span className="studio-label">Generation flow</span> |
| <div className="mt-2 flex flex-wrap gap-2"> |
| <button |
| type="button" |
| onClick={() => setBriefFlowMode('planned')} |
| className={`rounded-xl border px-3 py-2 text-sm transition ${ |
| briefFlowMode === 'planned' |
| ? 'border-accent/40 bg-accent/[0.08] text-slate-900' |
| : 'border-slate-200 bg-white text-slate-700 hover:border-slate-300' |
| }`} |
| > |
| Plan shots then render |
| </button> |
| <button |
| type="button" |
| onClick={() => { |
| setBriefFlowMode('direct_15s'); |
| if (directConcepts.length === 0 && !conceptsBusy) { |
| void runDirectConceptIdeas(); |
| } |
| }} |
| disabled={!isSeedanceSegmentModel(videoModel)} |
| className={`rounded-xl border px-3 py-2 text-sm transition ${ |
| briefFlowMode === 'direct_15s' |
| ? 'border-accent/40 bg-accent/[0.08] text-slate-900' |
| : 'border-slate-200 bg-white text-slate-700 hover:border-slate-300' |
| } disabled:cursor-not-allowed disabled:opacity-50`} |
| > |
| AI concept videos (Seedance) |
| </button> |
| </div> |
| <p className="mt-2 text-xs text-slate-600"> |
| {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).'} |
| </p> |
| </div> |
| {briefFlowMode === 'direct_15s' && ( |
| <ConceptSelector |
| title="AI-generated concepts (4 UGC + 3 Model Showcase + 3 Feature Highlight)" |
| description="Select only the concepts you want to render." |
| conceptsBusy={conceptsBusy} |
| hasConcepts={directConcepts.length > 0} |
| selectedConcepts={selectedDirectConcepts} |
| groups={conceptSelectionGroups} |
| onRegenerate={runDirectConceptIdeas} |
| onToggle={(concept, checked) => |
| setSelectedDirectConcepts((prev) => |
| checked ? [...prev, concept] : prev.filter((x) => x !== concept) |
| ) |
| } |
| /> |
| )} |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Product name</span> |
| <input |
| className="studio-input !mt-1.5" |
| value={productName} |
| onChange={(e) => setProductName(e.target.value)} |
| placeholder="AuraBrew Six" |
| /> |
| </label> |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Price (optional)</span> |
| <input |
| className="studio-input !mt-1.5" |
| value={price} |
| onChange={(e) => setPrice(e.target.value)} |
| placeholder="$79.99" |
| /> |
| </label> |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Description (optional)</span> |
| <textarea |
| className="studio-input !mt-1.5 min-h-[88px] resize-y" |
| value={description} |
| onChange={(e) => setDescription(e.target.value)} |
| placeholder="Two-in-one statement earrings with tassel detailing and elegant finish." |
| /> |
| </label> |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Target audience</span> |
| <select |
| className="studio-select !mt-1.5" |
| value={targetAudience} |
| onChange={(e) => setTargetAudience(e.target.value)} |
| > |
| <option value="">Select target audience</option> |
| {TARGET_AUDIENCE_OPTIONS.map((option) => ( |
| <option key={option} value={option}> |
| {option} |
| </option> |
| ))} |
| </select> |
| </label> |
| {briefFlowMode === 'planned' && ( |
| <ConceptSelector |
| title="Creative concepts (4 UGC + 3 Model Showcase + 3 Feature Highlight)" |
| description="Select at least one concept to include in planning." |
| conceptsBusy={conceptsBusy} |
| hasConcepts={directConcepts.length > 0} |
| selectedConcepts={selectedPlannedConcepts} |
| groups={conceptSelectionGroups} |
| onRegenerate={runDirectConceptIdeas} |
| onToggle={(concept, checked) => |
| setSelectedPlannedConcepts((prev) => |
| checked ? [...prev, concept] : prev.filter((x) => x !== concept) |
| ) |
| } |
| /> |
| )} |
| {briefFlowMode === 'planned' && ( |
| <> |
| <label className="block"> |
| <span className="studio-label">Shots (3–6)</span> |
| <input |
| type="number" |
| min={3} |
| max={6} |
| className="studio-input !mt-1.5" |
| value={shotCount} |
| onChange={(e) => setShotCount(Number(e.target.value))} |
| /> |
| </label> |
| <div className="block"> |
| <span className="studio-label">Seconds / shot</span> |
| <p className="mt-1.5 text-xs leading-relaxed text-slate-600"> |
| Auto-selected by AI from your concept + product brief (4s, 6s, or 8s pacing). |
| </p> |
| </div> |
| </> |
| )} |
| <label className="block"> |
| <span className="studio-label">Aspect ratio</span> |
| <select |
| className="studio-select !mt-1.5" |
| value={aspectRatio} |
| onChange={(e) => setAspectRatio(e.target.value)} |
| > |
| <option value="9:16">9:16 vertical</option> |
| <option value="16:9">16:9 cinematic</option> |
| <option value="1:1">1:1 square</option> |
| </select> |
| </label> |
| <label className="block"> |
| <span className="studio-label">Seed</span> |
| <input |
| type="number" |
| className="studio-input !mt-1.5" |
| value={seed} |
| onChange={(e) => setSeed(Number(e.target.value))} |
| /> |
| </label> |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Video model (KIE · Replicate fallback)</span> |
| <select |
| className="studio-select !mt-1.5 max-w-md" |
| value={videoModel} |
| onChange={(e) => setVideoModel(e.target.value as SegmentVideoModel)} |
| > |
| <option value="veo3_fast">Veo 3.1 Fast (image → video, SSE)</option> |
| <option value="seedance-2">Seedance 2 — ByteDance (image + prompt)</option> |
| <option value="seedance-2-fast">Seedance 2 Fast — ByteDance (image + prompt)</option> |
| </select> |
| <p className="mt-1.5 text-xs text-slate-600"> |
| Same API key. Seedance 2 uses <span className="text-slate-800">first_frame_url</span> image-to-video |
| (your hero or GPT keyframe) plus the shot plan as the text prompt; duration 4–15s (planner currently |
| picks 4s, 6s, or 8s based on concept + brief). Two URLs would map to first+last frame; three or more |
| to reference-only mode per KIE docs. |
| </p> |
| </label> |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Seedance resolution</span> |
| <select |
| className="studio-select !mt-1.5 max-w-md" |
| value={seedanceResolution} |
| onChange={(e) => |
| setSeedanceResolution(e.target.value as '480p' | '720p' | '1080p') |
| } |
| disabled={!isSeedanceSegmentModel(videoModel)} |
| > |
| <option value="480p">480p (default)</option> |
| <option value="720p">720p</option> |
| <option value="1080p">1080p</option> |
| </select> |
| <p className="mt-1 text-xs text-slate-500"> |
| Applies only when using Seedance models. |
| </p> |
| </label> |
| <label className="block sm:col-span-2"> |
| <span className="studio-label">Voice (Veo only)</span> |
| <select |
| className="studio-select !mt-1.5 max-w-md" |
| value={voiceType} |
| onChange={(e) => setVoiceType(e.target.value)} |
| > |
| {['Deep', 'Warm', 'Crisp'].map((v) => ( |
| <option key={v} value={v}> |
| {v} |
| </option> |
| ))} |
| </select> |
| <p className="mt-1 text-xs text-slate-500"> |
| Seedance uses API synced audio (<code className="rounded bg-slate-200 px-1 text-[0.65rem]">generate_audio</code>). |
| </p> |
| </label> |
| <div className="sm:col-span-2"> |
| <span className="studio-label">Hero image</span> |
| <p className="mt-1 text-xs text-slate-600"> |
| Upload replaces an imported hero. Images are hosted on your API for KIE. |
| </p> |
| <div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-center"> |
| <input |
| type="file" |
| accept="image/*" |
| onChange={(e) => onPickImage(e.target.files?.[0] ?? null)} |
| className="text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-xl file:border-0 file:bg-accent file:px-4 file:py-2.5 file:text-sm file:font-semibold file:text-ink file:shadow-sm hover:file:brightness-110" |
| /> |
| {heroPreviewSrc && ( |
| <div className="relative"> |
| <img |
| src={heroPreviewSrc} |
| alt="Hero preview" |
| loading="lazy" |
| className="h-28 w-28 rounded-2xl border border-slate-200 object-cover shadow-sm" |
| /> |
| <span className="absolute -bottom-1 -right-1 rounded-full bg-accent px-2 py-0.5 text-[0.6rem] font-bold text-ink"> |
| Hero |
| </span> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| <div className="mt-10 flex flex-wrap gap-3 border-t border-slate-200 pt-8"> |
| <button |
| type="button" |
| onClick={briefFlowMode === 'direct_15s' ? runDirectConceptRender : runPlan} |
| className="studio-btn-primary" |
| > |
| {briefFlowMode === 'direct_15s' |
| ? 'Generate AI concept videos' |
| : 'Generate shot plan'} |
| </button> |
| </div> |
| {briefFlowMode === 'direct_15s' && seedanceDirectCostEstimate && ( |
| <SeedanceReplicateCostNote |
| videoModel={videoModel} |
| resolution={seedanceResolution} |
| clipCount={seedanceDirectCostEstimate.clipCount} |
| totalOutputSeconds={seedanceDirectCostEstimate.totalOutputSeconds} |
| flow="direct" |
| /> |
| )} |
| </motion.section> |
| )} |
| |
| {phase === 'planning' && ( |
| <motion.section |
| key="planning" |
| initial={{ opacity: 0, y: 12 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -10 }} |
| className="studio-card px-6 py-12 text-center sm:px-10 sm:py-14" |
| > |
| <div className="mx-auto max-w-md"> |
| <p className="text-xs font-medium uppercase tracking-[0.18em] text-accent/80"> |
| AI planner in progress · {phaseElapsedSeconds}s elapsed |
| </p> |
| <div className="studio-progress-track h-2.5"> |
| <motion.div |
| className="studio-progress-fill" |
| initial={{ width: 0 }} |
| animate={{ width: `${planProgress}%` }} |
| transition={{ duration: 0.25 }} |
| /> |
| </div> |
| <p className="mt-6 font-display text-lg text-slate-900">Composing your shot plan</p> |
| <p className="mt-2 text-sm text-slate-600">Streaming segment beats from the planner…</p> |
| <AnimatePresence mode="wait"> |
| <motion.p |
| key={`plan-pulse-${planPulseIndex}`} |
| initial={{ opacity: 0, y: 4 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -4 }} |
| transition={{ duration: 0.2 }} |
| className="mt-3 text-xs text-slate-500" |
| > |
| {planPulseMessages[planPulseIndex]} |
| </motion.p> |
| </AnimatePresence> |
| <div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left"> |
| <p className="text-xs font-semibold uppercase tracking-wide text-slate-600">What happens next</p> |
| <p className="mt-1.5 text-xs leading-relaxed text-slate-600"> |
| We will show your generated shot list in a quick review screen before rendering starts. |
| </p> |
| </div> |
| <button type="button" onClick={cancelPlan} className="studio-btn-ghost mt-8"> |
| Cancel |
| </button> |
| </div> |
| </motion.section> |
| )} |
| |
| {phase === 'review' && ( |
| <motion.section |
| key="review" |
| initial={{ opacity: 0, y: 12 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -10 }} |
| className="studio-card p-6 sm:p-8" |
| > |
| <SectionHeader |
| badge="2" |
| title="Shot plans" |
| description="Review each concept plan, then choose one or many concepts to render in parallel." |
| /> |
| |
| <div className="mt-8 space-y-6"> |
| {conceptPlans.map(({ conceptKey, conceptLabel, payload }) => { |
| const checked = selectedRenderConcepts.includes(conceptKey); |
| return ( |
| <section key={conceptKey} className="rounded-2xl border border-slate-200 bg-slate-50 p-4 sm:p-5"> |
| <label className="flex cursor-pointer items-start gap-3"> |
| <input |
| type="checkbox" |
| checked={checked} |
| onChange={(e) => { |
| setSelectedRenderConcepts((prev) => { |
| if (e.target.checked) return [...prev, conceptKey]; |
| return prev.filter((id) => id !== conceptKey); |
| }); |
| }} |
| className="mt-1 h-4 w-4 rounded border-slate-300 bg-white text-accent focus:ring-accent/40" |
| /> |
| <div> |
| <p className="text-sm font-semibold text-slate-900">{conceptLabel}</p> |
| <p className="mt-1 text-xs text-slate-600"> |
| {payload.environment || 'Concept plan ready'} |
| {payload.seconds_per_segment ? ` · ${payload.seconds_per_segment}s / shot` : ''} |
| </p> |
| </div> |
| </label> |
| <ol className="mt-4 space-y-2.5"> |
| {payload.segments.map((s, i) => { |
| const promptKey = `${conceptKey}:${i}`; |
| const generatedPrompt = segmentToSeedancePrompt(s, productName.trim()); |
| const currentPrompt = segmentPromptEdits[promptKey] ?? generatedPrompt; |
| const isEdited = currentPrompt.trim() !== generatedPrompt.trim(); |
| return ( |
| <li key={`${conceptKey}-${i + 1}`} className="studio-shot-card pl-5 text-sm text-slate-700"> |
| <div className="flex items-center gap-2"> |
| <span className="font-mono text-xs font-semibold text-accent">Shot {i + 1}</span>{' '} |
| <span className="text-slate-500">({s.segment_info.duration})</span> |
| {isEdited && ( |
| <span className="rounded-full bg-accent/[0.12] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent"> |
| edited |
| </span> |
| )} |
| </div> |
| <div className="mt-1.5 leading-snug text-slate-600"> |
| {s.scene_continuity.camera_movement.slice(0, 96)} |
| </div> |
| <label className="mt-3 block"> |
| <span className="studio-label">Prompt (editable)</span> |
| <textarea |
| className="studio-input !mt-1.5 min-h-[110px] resize-y font-mono text-xs leading-relaxed" |
| value={currentPrompt} |
| onChange={(e) => |
| setSegmentPromptEdits((prev) => ({ |
| ...prev, |
| [promptKey]: e.target.value, |
| })) |
| } |
| /> |
| </label> |
| {isEdited && ( |
| <button |
| type="button" |
| onClick={() => |
| setSegmentPromptEdits((prev) => { |
| const next = { ...prev }; |
| delete next[promptKey]; |
| return next; |
| }) |
| } |
| className="studio-btn-ghost mt-2 !px-3 !py-1.5 text-xs" |
| > |
| Reset to generated prompt |
| </button> |
| )} |
| </li> |
| ); |
| })} |
| </ol> |
| </section> |
| ); |
| })} |
| </div> |
| |
| {health && health.ffprobe_available === false && ( |
| <p className="mt-6 rounded-xl border border-amber-500/25 bg-amber-500/[0.08] px-4 py-3 text-xs leading-relaxed text-amber-100/95"> |
| <code className="rounded bg-amber-100 px-1 text-amber-700">ffprobe</code> missing — clip duration |
| for merge may fail. Install FFmpeg on the API host. |
| </p> |
| )} |
| {health && health.ffmpeg_available === false && ( |
| <p className="mt-3 rounded-xl border border-red-500/30 bg-red-500/[0.08] px-4 py-3 text-xs leading-relaxed text-red-100"> |
| <code className="rounded bg-red-100 px-1 text-red-700">ffmpeg</code> not on server PATH — final |
| merge will error. |
| </p> |
| )} |
| |
| <label |
| className={`mt-8 flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition ${ |
| gptOptionEnabled |
| ? 'border-slate-200 bg-white hover:border-slate-300' |
| : 'cursor-not-allowed border-slate-200 bg-slate-100 opacity-55' |
| }`} |
| > |
| <input |
| type="checkbox" |
| className="mt-1 h-4 w-4 rounded border-slate-300 bg-white text-accent focus:ring-accent/40" |
| checked={useGptFirstFrames && gptOptionEnabled} |
| disabled={!gptOptionEnabled} |
| onChange={(e) => setUseGptFirstFrames(e.target.checked)} |
| /> |
| <div> |
| <span className="text-sm font-semibold text-slate-900">GPT Image keyframes per shot</span> |
| <p className="mt-2 text-xs leading-relaxed text-slate-600"> |
| Before each rendered clip, synthesize a first frame with OpenAI{' '} |
| <code className="rounded bg-slate-200 px-1 py-0.5 text-[0.7rem] text-slate-700">images.edit</code>{' '} |
| using{' '} |
| {referenceUrlsForGpt.length} reference image |
| {referenceUrlsForGpt.length === 1 ? '' : 's'} |
| {referenceUrlsForGpt.length >= 2 |
| ? ' (merged hero + gallery for stronger product match).' |
| : scrapedImageUrls.length > 0 |
| ? ' from your import.' |
| : heroRemoteUrl |
| ? ' (hero URL).' |
| : ' (hosted hero only — import a product page for 2–4 angles).'}{' '} |
| Server needs <code className="rounded bg-slate-200 px-1 text-[0.7rem]">OPENAI_API_KEY</code> and{' '} |
| <code className="rounded bg-slate-200 px-1 text-[0.7rem]">GPT_IMAGE_MODEL</code> |
| {health?.gpt_image_model ? ( |
| <> |
| {' '} |
| ( |
| <code className="rounded bg-slate-200 px-1 text-[0.7rem] text-accent-dim"> |
| {health.gpt_image_model} |
| </code> |
| ). |
| </> |
| ) : ( |
| <> |
| {' '} |
| (e.g. <code className="rounded bg-slate-200 px-1 text-[0.7rem]">gpt-image-1.5</code>). |
| </> |
| )} |
| </p> |
| {health && !health.openai_configured && ( |
| <p className="mt-3 text-xs leading-relaxed text-amber-100/90"> |
| OpenAI not configured — set <code className="rounded bg-amber-100 px-1 text-amber-800">OPENAI_API_KEY</code> and |
| restart the API. |
| </p> |
| )} |
| </div> |
| </label> |
|
|
| <div className="mt-10 flex flex-wrap gap-3 border-t border-slate-200 pt-8"> |
| <button type="button" onClick={runRender} className="studio-btn-primary"> |
| {useGptFirstFrames && gptOptionEnabled |
| ? `Generate keyframes + render (${isSeedanceSegmentModel(videoModel) ? 'Seedance' : 'Veo'})` |
| : `Render all segments (${isSeedanceSegmentModel(videoModel) ? 'Seedance' : 'Veo'})`} |
| </button> |
| <button type="button" onClick={() => setPhase('brief')} className="studio-btn-secondary"> |
| Edit brief |
| </button> |
| </div> |
| {seedancePlannedRenderCostEstimate ? ( |
| <SeedanceReplicateCostNote |
| videoModel={videoModel} |
| resolution={seedanceResolution} |
| clipCount={seedancePlannedRenderCostEstimate.clipCount} |
| totalOutputSeconds={seedancePlannedRenderCostEstimate.totalOutputSeconds} |
| flow="planned" |
| /> |
| ) : isSeedanceSegmentModel(videoModel) && conceptPlans.length > 0 && selectedRenderConcepts.length === 0 ? ( |
| <p className="mt-3 max-w-2xl text-xs text-slate-500"> |
| Select a concept to see ~cost, clips, and seconds. |
| </p> |
| ) : null} |
| </motion.section> |
| )} |
|
|
| {phase === 'rendering' && ( |
| <motion.section |
| key="rendering" |
| initial={{ opacity: 0, y: 12 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -10 }} |
| className="studio-card p-6 sm:p-8" |
| > |
| <SectionHeader |
| badge="3" |
| title="Rendering" |
| description={`${renderLabel || `${renderUnitLabel === 'segments' ? 'Segment' : 'Concept'} ${renderIndex} / ${renderTotalSegments}`} — each concept gets its own merged video.`} |
| /> |
| <div className="mt-5 flex flex-wrap items-center gap-2 text-xs text-slate-600"> |
| <span className="rounded-full border border-slate-300 bg-white px-2.5 py-1"> |
| {phaseElapsedSeconds}s elapsed |
| </span> |
| <span className="rounded-full border border-slate-300 bg-white px-2.5 py-1"> |
| {Math.round(renderProgressPct)}% complete |
| </span> |
| <span className="rounded-full border border-slate-300 bg-white px-2.5 py-1"> |
| {Math.max(0, renderTotalSegments - renderIndex)} {renderUnitLabel} remaining |
| </span> |
| </div> |
| <div className="studio-progress-track mt-8 h-2.5 max-w-xl"> |
| <motion.div |
| className="studio-progress-fill" |
| initial={{ width: 0 }} |
| animate={{ |
| width: `${renderProgressPct}%`, |
| }} |
| transition={{ duration: 0.3 }} |
| /> |
| </div> |
| <AnimatePresence mode="wait"> |
| <motion.p |
| key={`render-pulse-${renderPulseIndex}`} |
| initial={{ opacity: 0, y: 4 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -4 }} |
| transition={{ duration: 0.2 }} |
| className="mt-4 text-sm text-slate-600" |
| > |
| {renderPulseMessages[renderPulseIndex]} |
| </motion.p> |
| </AnimatePresence> |
| {renderTotalSegments > 0 && ( |
| <div className="mt-6 grid gap-2 sm:grid-cols-2"> |
| {Array.from({ length: renderTotalSegments }).map((_, idx) => { |
| const done = idx < renderIndex; |
| return ( |
| <div |
| key={`render-shot-${idx + 1}`} |
| className={`rounded-xl border px-3 py-2 text-xs transition ${ |
| done |
| ? 'border-accent/35 bg-accent/[0.08] text-accent' |
| : 'border-slate-200 bg-white text-slate-500' |
| }`} |
| > |
| {renderUnitLabel === 'segments' ? 'Segment' : 'Concept'} {idx + 1} {done ? 'ready' : 'queued'} |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </motion.section> |
| )} |
|
|
| {phase === 'done' && finalUrl && ( |
| <motion.section |
| key="done" |
| initial={{ opacity: 0, y: 12 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -10 }} |
| className="studio-card p-6 sm:p-8" |
| > |
| <SectionHeader badge="4" title="Master cut" description="Stitched with FFmpeg on your API server." /> |
| <div className="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-md ring-1 ring-slate-200"> |
| <video |
| src={finalUrl} |
| controls |
| preload="metadata" |
| className="aspect-video w-full max-h-[min(520px,70vh)] bg-slate-100 object-contain" |
| /> |
| </div> |
| <div className="mt-8 flex flex-wrap gap-3"> |
| <a href={finalUrl} download="product-showcase-master.mp4" className="studio-btn-primary"> |
| Download MP4 |
| </a> |
| <button type="button" onClick={reset} className="studio-btn-secondary"> |
| New project |
| </button> |
| </div> |
| </motion.section> |
| )} |
| </AnimatePresence> |
|
|
| {error && ( |
| <div |
| className="flex gap-3 rounded-2xl border border-red-300 bg-red-50 px-4 py-4 text-sm text-red-700 shadow-sm" |
| role="alert" |
| > |
| <span className="shrink-0 text-lg leading-none text-red-500" aria-hidden> |
| ! |
| </span> |
| <p className="leading-relaxed">{error}</p> |
| </div> |
| )} |
|
|
| {libraryVideos.length > 0 && ( |
| <SectionCard className="p-6 sm:p-8"> |
| <SectionHeader |
| badge="Library" |
| title="Generated videos" |
| description="Session library of your master cuts. Click any card to preview it above." |
| /> |
| <div className="mt-6 grid gap-4 sm:grid-cols-2"> |
| {libraryVideos.map((item) => { |
| const isActive = finalUrl === item.url; |
| return ( |
| <article |
| key={item.id} |
| className={`rounded-2xl border p-3 transition ${ |
| isActive |
| ? 'border-accent/45 bg-accent/[0.08]' |
| : 'border-slate-200 bg-white hover:border-slate-300' |
| }`} |
| > |
| <button |
| type="button" |
| onClick={() => { |
| setFinalUrl(item.url); |
| setPhase('done'); |
| }} |
| className="w-full text-left" |
| > |
| <video src={item.url} preload="metadata" className="aspect-video w-full rounded-xl bg-slate-100 object-cover" /> |
| <p className="mt-3 text-sm font-semibold text-slate-900">{item.title}</p> |
| <p className="mt-1 text-xs text-slate-600"> |
| {new Date(item.createdAt).toLocaleString()} · {item.successfulClips}/{item.totalClips} clips merged |
| </p> |
| </button> |
| <div className="mt-3 flex gap-2"> |
| <a |
| href={item.url} |
| download={`${item.title.toLowerCase().replace(/\s+/g, '-') || 'product-showcase'}-${item.id}.mp4`} |
| className="studio-btn-secondary !px-3 !py-2 text-xs" |
| > |
| Download |
| </a> |
| </div> |
| </article> |
| ); |
| })} |
| </div> |
| </SectionCard> |
| )} |
| </div> |
| ); |
| } |
|
|