sushilideaclan01's picture
change the
c4f9173
Raw
History Blame Contribute Delete
67.2 kB
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);
/** 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<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();
};
/** 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<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) {
// 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<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/&lt;handle&gt;.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>
);
}