"use client"; import { useState, useCallback, useRef } from "react"; import type { LogEntry, PipelineStage } from "@/lib/types"; import { IngestResultSchema, type IngestResult } from "@/lib/schemas/blueprint"; import { storeVideoBlob, setVideoUrl, setYoutubeId } from "@/lib/video-store"; type UploadedFile = { name: string; size: number; type: string; content: string; raw?: File }; type MediaUploadProps = { onLog: (entry: Omit) => void; onBlueprint: (data: IngestResult, sourceText: string) => void; onStageChange: (stage: PipelineStage) => void; }; function formatBytes(b: number) { if (b < 1024) return `${b} B`; if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`; return `${(b / 1048576).toFixed(1)} MB`; } const TEXT_EXTS = [".txt", ".json", ".md", ".log", ".csv", ".xml", ".yaml", ".yml", ".ts", ".js"]; function readFile(file: File): Promise { return new Promise((resolve) => { const isText = file.type.startsWith("text/") || TEXT_EXTS.some((ext) => file.name.endsWith(ext)); if (isText && file.size < 5 * 1024 * 1024) { const reader = new FileReader(); reader.onload = (e) => resolve({ name: file.name, size: file.size, type: file.type, content: (e.target?.result as string) ?? "" }); reader.onerror = () => resolve({ name: file.name, size: file.size, type: file.type, content: `[read error: ${file.name}]` }); reader.readAsText(file); } else if (file.type.startsWith("video/") || file.type.startsWith("audio/")) { const url = URL.createObjectURL(file); const el = document.createElement(file.type.startsWith("video/") ? "video" : "audio"); el.src = url; el.onloadedmetadata = () => { const durationSecs = Math.round(el.duration) || 0; URL.revokeObjectURL(url); storeVideoBlob(file, durationSecs).catch(console.error); const mins = Math.floor(durationSecs / 60); const secs = durationSecs % 60; resolve({ name: file.name, size: file.size, type: file.type, raw: file, content: `[media: ${file.name} (${file.type}, ${formatBytes(file.size)}, duration: ${mins}m ${secs}s) — awaiting transcription]` }); }; el.onerror = () => { URL.revokeObjectURL(url); storeVideoBlob(file, 0).catch(console.error); resolve({ name: file.name, size: file.size, type: file.type, raw: file, content: `[media: ${file.name} (${file.type}, ${formatBytes(file.size)}) — awaiting transcription]` }); }; } else { resolve({ name: file.name, size: file.size, type: file.type, content: `[media: ${file.name} (${file.type || "unknown"}, ${formatBytes(file.size)})]` }); } }); } export function MediaUpload({ onLog, onBlueprint, onStageChange }: MediaUploadProps) { const [files, setFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [youtubeUrl, setYoutubeUrl] = useState(""); const [youtubeLoading, setYoutubeLoading] = useState(false); const [uploadToCloud, setUploadToCloud] = useState(false); const inputRef = useRef(null); const addFiles = useCallback(async (raw: FileList | File[]) => { const processed = await Promise.all(Array.from(raw).map(readFile)); setFiles((prev) => { const existing = new Set(prev.map((f) => f.name)); return [...prev, ...processed.filter((f) => !existing.has(f.name))]; }); }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); }, [addFiles] ); const handleInputChange = useCallback( (e: React.ChangeEvent) => { if (e.target.files) addFiles(e.target.files); }, [addFiles] ); const removeFile = useCallback((name: string) => { setFiles((prev) => prev.filter((f) => f.name !== name)); }, []); const fetchYoutubeTranscript = useCallback(async () => { const url = youtubeUrl.trim(); if (!url || youtubeLoading) return; setYoutubeLoading(true); setError(null); try { const res = await fetch("/api/fetch-youtube-transcript", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const json = await res.json() as { transcript?: string; videoId?: string; error?: string }; if (!res.ok || json.error) { // Persist the video ID even on transcript failure so the embed still renders if (json.videoId) setYoutubeId(json.videoId); throw new Error(json.error ?? "Failed to fetch transcript"); } setYoutubeId(json.videoId!); const virtualFile: UploadedFile = { name: `youtube-${json.videoId}.txt`, size: json.transcript!.length, type: "text/plain", content: `[YouTube transcript: ${url}]\n\n${json.transcript}`, }; setFiles((prev) => prev.some((f) => f.name === virtualFile.name) ? prev : [...prev, virtualFile] ); onLog({ level: "success", message: `YouTube transcript ready — ${json.transcript!.length.toLocaleString()} chars` }); setYoutubeUrl(""); } catch (err) { const msg = err instanceof Error ? err.message : "Failed"; setError(msg); onLog({ level: "error", message: `YouTube transcript failed: ${msg}` }); } finally { setYoutubeLoading(false); } }, [youtubeUrl, youtubeLoading, onLog]); const runIngest = useCallback(async () => { if (!files.length) return; setIsLoading(true); setError(null); onStageChange("ingesting"); // Resolve final content for each file — transcribe video/audio via Deepgram let resolvedFiles = [...files]; const mediaFiles = files.filter((f) => f.raw); if (mediaFiles.length > 0) { onLog({ level: "info", message: `Transcribing ${mediaFiles.length} media file(s) with Deepgram…`, model: "deepseek" }); // Fetch token first — key never leaves the server let deepgramKey: string | null = null; try { const tokenRes = await fetch("/api/transcribe-token"); if (tokenRes.ok) { const tokenJson = await tokenRes.json() as { apiKey?: string }; deepgramKey = tokenJson.apiKey ?? null; } } catch { /* key unavailable */ } const transcribed = await Promise.all( mediaFiles.map(async (f) => { if (!deepgramKey) { onLog({ level: "warn", message: `Transcription skipped — DEEPGRAM_API_KEY not configured`, model: "deepseek" }); return { name: f.name, transcript: null }; } try { // Upload directly from browser to Deepgram — bypasses Codespaces proxy size limits const res = await fetch( "https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&punctuate=true¶graphs=true&language=en", { method: "POST", headers: { Authorization: `Token ${deepgramKey}`, "Content-Type": f.raw!.type || "audio/mpeg", }, body: f.raw!, } ); const json = await res.json() as { results?: { channels?: { alternatives?: { transcript?: string }[] }[] }; err_msg?: string }; if (!res.ok) throw new Error(json.err_msg ?? `Deepgram ${res.status}`); const transcript = json.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ""; if (!transcript.trim()) throw new Error("Empty transcript returned"); onLog({ level: "success", message: `Transcript ready for "${f.name}" (${transcript.length.toLocaleString()} chars)`, model: "deepseek" }); return { name: f.name, transcript }; } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; onLog({ level: "warn", message: `Transcription skipped for "${f.name}": ${msg}`, model: "deepseek" }); return { name: f.name, transcript: null }; } }) ); const tMap = new Map(transcribed.map((t) => [t.name, t.transcript])); resolvedFiles = files.map((f) => { const tx = tMap.get(f.name); return tx ? { ...f, content: `[transcript of ${f.name}]\n\n${tx}` } : f; }); } // Upload video to Cloudflare R2 if enabled if (uploadToCloud) { const videoFile = mediaFiles.find((f) => f.raw?.type.startsWith("video/")); if (videoFile?.raw) { try { onLog({ level: "info", message: `Requesting cloud upload slot for "${videoFile.raw.name}"…` }); const signRes = await fetch("/api/r2-presign", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: videoFile.raw.name, contentType: videoFile.raw.type }), }); const signJson = await signRes.json() as { presignedUrl?: string; publicUrl?: string; error?: string }; if (!signRes.ok || !signJson.presignedUrl) throw new Error(signJson.error ?? "Presign failed"); onLog({ level: "info", message: `Uploading to R2 — this may take a moment for large files…` }); const uploadRes = await fetch(signJson.presignedUrl, { method: "PUT", headers: { "Content-Type": videoFile.raw.type }, body: videoFile.raw, }); if (!uploadRes.ok) throw new Error(`R2 upload failed: HTTP ${uploadRes.status}`); if (signJson.publicUrl) { setVideoUrl(signJson.publicUrl); onLog({ level: "success", message: `Video stored at: ${signJson.publicUrl}` }); } else { onLog({ level: "warn", message: `Uploaded to R2 but no public URL — set R2_PUBLIC_URL in env to enable playback` }); } } catch (err) { const msg = err instanceof Error ? err.message : "Upload failed"; onLog({ level: "warn", message: `Cloud upload skipped: ${msg}` }); } } } onLog({ level: "info", message: `Sending ${files.length} file(s) to DeepSeek ingest pipeline…`, model: "deepseek" }); try { const sourceText = resolvedFiles .map((f) => `--- ${f.name} (${f.type || "unknown"}) ---\n${f.content}`) .join("\n\n"); const res = await fetch("/api/ingest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourceText, locale: "en-US" }) }); const json: unknown = await res.json(); if (!res.ok) { const msg = (json as { detail?: string }).detail ?? res.statusText; throw new Error(msg); } const parsed = IngestResultSchema.safeParse(json); if (!parsed.success) { throw new Error(`Schema validation failed: ${parsed.error.issues[0]?.message}`); } onLog({ level: "success", message: `Blueprint extracted: "${parsed.data.title}"`, model: "deepseek" }); onBlueprint(parsed.data, sourceText); // Stage management handed to the pipeline orchestrator in page.tsx } catch (err) { const msg = err instanceof Error ? err.message : "Ingest failed"; setError(msg); onLog({ level: "error", message: `Ingest failed: ${msg}`, model: "gemini" }); onStageChange("error"); } finally { setIsLoading(false); } }, [files, onLog, onBlueprint, onStageChange]); return (

Feed the Pipeline

{/* YouTube URL input */}
setYoutubeUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && fetchYoutubeTranscript()} placeholder="Paste YouTube URL to auto-fetch transcript…" className="min-h-12 flex-1 rounded-xl border border-slate-600 bg-slate-800/60 px-4 text-base text-slate-100 placeholder-slate-500 focus:border-cyan-500 focus:outline-none" />
{/* Drop zone */}
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} onClick={() => inputRef.current?.click()} onKeyDown={(e) => e.key === "Enter" && inputRef.current?.click()} className={[ "focus-ring flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed transition-colors", isDragging ? "border-accent-500 bg-accent-500/10" : "border-slate-600 bg-shell-800/40 hover:border-slate-500 active:border-accent-500/60" ].join(" ")} >

Drop files or tap to browse

Footage · workshops · podcasts

{/* File list */} {files.length > 0 && (
    {files.map((f) => (
  • {f.name}

    {formatBytes(f.size)}

  • ))}
)} {/* Error banner */} {error && (
{error}
)}
{/* Run button — fixed at bottom */}
{/* Cloud upload toggle — only shown when a video file is queued */} {files.some((f) => f.raw?.type.startsWith("video/")) && ( )}
); }