window.AdCard = function AdCard({ ad, selected, onToggle, onExpand, onDislike, minimal, showPassVerdict = true, }) { const p = PLATFORMS[ad.platform] || PLATFORMS.foreplay; const [thumbnailBroken, setThumbnailBroken] = React.useState(false); const [measuredDuration, setMeasuredDuration] = React.useState(''); const raw = ad.raw && typeof ad.raw === 'object' ? ad.raw : {}; const hasTranscript = ad.transcript && ad.transcript.length > 0; const isFallbackTranscript = ad.transcriptSource === 'copy_fallback' || ad.transcript_source === 'copy_fallback' || ad.isVoiceTranscript === false; const hasVoiceTranscript = hasTranscript && !isFallbackTranscript; const transcriptProvider = ad.transcriptProvider || ad.transcript_provider || raw.transcript_provider || (raw.brandsearch_transcript && raw.brandsearch_transcript.provider) || null; const transcriptModel = ad.transcriptModel || ad.transcript_model || raw.transcript_model || (raw.brandsearch_transcript && raw.brandsearch_transcript.model) || null; const transcriptSourceLabel = isFallbackTranscript ? 'fallback copy' : transcriptProvider ? `via ${transcriptProvider}` : hasVoiceTranscript ? 'source transcript' : null; const creativeUrl = ad.creativeUrl || ad.videoUrl || raw.creative_url || raw.creativeUrl || raw.video || raw.video_url || null; const thumbnailUrl = ad.thumbnailUrl || raw.thumbnail_url || raw.thumbnail || raw.thumb_url || raw.thumb || raw.image_url || raw.image || null; const hasVideo = !!creativeUrl; const hasThumbnail = !!thumbnailUrl && !thumbnailBroken; const truncationWarning = !!ad.hasTruncationWarning || String(ad.truncationStatus || ad.truncation_status || '').toLowerCase() === 'truncated'; const truncationTitle = ad.truncationReason || ad.truncation_reason || 'Video file appears incomplete; retained as discovery signal but skipped for remodel.'; // AdSpy / BrandSearch transcripts are generated server-side via Whisper / // ElevenLabs after the ad is fetched. Until the transcript hydration call // returns we badge the card so users know something is in flight \u2014 // otherwise the missing transcript looks like a permanent gap. const transcriptStatus = ad.transcriptStatus || null; const transcriptPending = hasVideo && !hasVoiceTranscript && (ad.platform === 'adspy' || ad.platform === 'brandsearch') && transcriptStatus !== 'fallback' && transcriptStatus !== 'unavailable' && transcriptStatus !== 'empty'; const showVideoThumb = hasVideo && !hasThumbnail; const thumbBackground = (hasVideo || thumbnailUrl) ? '#111827' : ad.thumb; const score = ad.score; const scoreText = (typeof score === 'number') ? (score > 10 ? Math.round(score) : score.toFixed(1)) : null; const passThresholdRaw = Number( ad.passThreshold ?? ad.pass_threshold ?? ad.scoreThreshold ?? ad.score_threshold, ); const passThreshold = Number.isFinite(passThresholdRaw) ? passThresholdRaw : null; const passVerdict = (typeof ad.passes === 'boolean') ? ad.passes : ((typeof score === 'number' && passThreshold !== null) ? score >= passThreshold : null); const passVerdictTitle = passThreshold !== null ? `Configured winner threshold: ${passThreshold.toFixed(1)}` : 'Pass/fail verdict from scorer'; const metricChips = RESILIA_ADAPT.metricChips(ad); const isCurrentRun = !!(ad.currentSearchRun || ad.currentAutoresearchRun || ad.currentPipelineMove); const displayDuration = ad.duration || measuredDuration; const formatDuration = (seconds) => { if (!Number.isFinite(seconds) || seconds <= 0) return ''; const total = Math.round(seconds); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`; }; const captureDuration = (video) => { const formatted = formatDuration(video.duration); if (formatted) setMeasuredDuration(formatted); }; const openExpand = (e, opts = {}) => { e.stopPropagation(); onExpand(ad, opts); }; const dislikeAd = (e) => { e.stopPropagation(); if (typeof onDislike === 'function') onDislike(ad); }; return (