import { ArrowLeft, Captions, Check, Clock3, Download, Film, FolderOpen, Gauge, Languages, Layers, Link as LinkIcon, Loader2, Maximize2, Moon, Move, Music2, PanelRightOpen, Pause, Play, RefreshCcw, Scissors, SkipBack, SkipForward, SlidersHorizontal, Sparkles, Sun, Trash2, Type, Upload, Volume2, Wand2, Zap, } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; const LANGUAGES = [ { code: "en", label: "EN", name: "English" }, { code: "th", label: "TH", name: "ไทย" }, { code: "ja", label: "JP", name: "日本語" }, { code: "zh", label: "ZH", name: "中文" }, { code: "ko", label: "KO", name: "한국어" }, ]; const NICHES = [ "education", "gaming", "podcast", "commentary", "cars", "beauty", "fitness", "finance", "tech", "lifestyle", "music", "other", ]; const CLIP_STYLES = ["informative", "funny", "dramatic", "educational", "commentary"]; const LANGUAGE_OPTIONS = ["Thai", "English", "Japanese", "Chinese", "Korean", "Auto"]; const PLATFORM_OPTIONS = ["tiktok", "youtube_shorts", "instagram_reels"]; const FONT_OPTIONS = ["Inter", "Noto Sans Thai", "Poppins", "Montserrat", "Arial", "Impact"]; const CUE_DENSITIES = ["word", "short", "medium", "long"]; const CAPTION_ANIMATIONS = ["none", "highlight", "pop", "bounce"]; const defaultCaptionStyle = { fontFamily: "Inter", fontSize: 38, fillColor: "#ffffff", strokeColor: "#080b12", strokeWidth: 4, position: 18, x: 50, // horizontal % of preview stage y: 82, // vertical % of preview stage (default near bottom) cueDensity: "short", animation: "highlight", }; const captionPresets = { clean: { fontFamily: "Inter", fontSize: 34, fillColor: "#ffffff", strokeColor: "#111827", strokeWidth: 3, position: 18, cueDensity: "medium", animation: "none", }, bold: { fontFamily: "Impact", fontSize: 46, fillColor: "#fef08a", strokeColor: "#020617", strokeWidth: 5, position: 20, cueDensity: "short", animation: "pop", }, karaoke: { fontFamily: "Poppins", fontSize: 40, fillColor: "#ffffff", strokeColor: "#020617", strokeWidth: 4, position: 22, cueDensity: "word", animation: "highlight", }, }; const defaultProfile = { niche: "education", niche_custom: "", channel_description: "", clip_style: "informative", clip_length_seconds: 60, clip_count: 5, primary_language: "Thai", target_platform: "tiktok", }; const en = { appSubtitle: "Turn long videos into short clips — powered by AMD ROCm", idle: "Idle", queued: "Queued", running: "Processing", completed: "Done", failed: "Failed", demoMode: "Demo mode", productionMode: "Live mode", theme: "Theme", language: "Language", startPipeline: "Generate clips", channelProfile: "Channel profile", channelProfileText: "Tell the AI about your channel so it picks the right moments.", videoInput: "Video", niche: "Channel niche", nicheHelp: "Pick the closest category, or choose Other for something more specific.", customNiche: "Your niche", customNichePlaceholder: "e.g. Thai AI tutorials for beginners", channelDescription: "Channel description", channelDescriptionHelp: "Write how you'd describe your channel to a friend. The AI uses this to stay on-brand.", channelDescriptionPlaceholder: "e.g. I explain AI tools in simple Thai with a bit of humor.", clipStyle: "Clip style", clipLength: "Clip length (seconds)", clipCount: "Number of clips", clipCountHelp: "How many clips the AI should find in this video.", primaryLanguage: "Language", platform: "Platform", youtube: "YouTube", upload: "Upload", youtubeUrl: "YouTube URL", youtubePlaceholder: "https://www.youtube.com/watch?v=...", videoFile: "Video file", pipeline: "Processing", ready: "Ready", progressNote: "Updates step by step — clip-by-clip progress shows during rendering.", currentStep: "Current step", timing: "Timing", timing_input: "Download", timing_transcription: "Transcription", timing_highlight_detection: "Highlights", timing_multimodal_analysis: "Visual check", timing_clip_generation: "Rendering", timing_total: "Total", transcript: "Transcript", transcriptEmpty: "Transcript will appear once the audio is processed.", clips: "Clips", noClips: "No clips yet", noClipsText: "Hit Generate to let the AI find and cut the best moments.", readyClips: "ready", score: "Score", reason: "Why", duration: "Duration", start: "Start", end: "End", subtitles: "Subtitles", openEditor: "Edit clip", approve: "Approve", approved: "Approved", regenerate: "Redo", delete: "Delete", download: "Download", backToDashboard: "Back", editor: "Clip editor", editorText: "Trim timing, edit subtitles, and approve before downloading.", preview: "Preview", clipRange: "Clip range", subtitleCues: "Subtitle cues", subtitleCueHelp: "Shorter cues work better on TikTok, Reels, and Shorts.", timelineTracks: "Timeline", videoTrack: "Video", subtitleTrack: "Subtitles", audioTrack: "Audio", toolSelect: "Select", toolTrim: "Trim", toolCaptions: "Captions", toolStyle: "Style", toolExport: "Export", captionStyle: "Caption style", captionPreset: "Preset", presetClean: "Clean", presetBold: "Bold", presetKaraoke: "Karaoke", font: "Font", fontSize: "Size", fillColor: "Fill color", strokeColor: "Outline color", strokeWidth: "Outline width", captionPosition: "Position", captionLength: "Line length", animation: "Animation", density_word: "Word by word", density_short: "Short phrases", density_medium: "Medium phrases", density_long: "Full lines", animation_none: "None", animation_highlight: "Highlight", animation_pop: "Pop", animation_bounce: "Bounce", editorTools: "Quick trim", rangeStart: "Start", rangeEnd: "End", trimStartBack: "−0.5s", trimStartForward: "+0.5s", trimEndBack: "−0.5s", trimEndForward: "+0.5s", moveClipLeft: "−1s", moveClipRight: "+1s", setClipLength30: "30s", setClipLength60: "60s", setClipLength90: "90s", inspector: "Details", title: "Title", status: "Status", notApproved: "Not approved", model: "Model", source: "Source", source_upload: "Upload", source_youtube: "YouTube", source_video: "Video", settings: "Settings", stepInput: "Download", stepTranscription: "Transcribe", stepHighlights: "Highlights", stepVisual: "Visual AI", stepRender: "Render", stepFinal: "Done", renderProgress: "Rendering {{current}} of {{total}}", appCta: "Generate clips", niche_education: "Education", niche_gaming: "Gaming", niche_podcast: "Podcast", niche_commentary: "Commentary", niche_cars: "Cars", niche_beauty: "Beauty", niche_fitness: "Fitness", niche_finance: "Finance", niche_tech: "Tech", niche_lifestyle: "Lifestyle", niche_music: "Music", niche_other: "Other", style_informative: "Informative", style_funny: "Funny", style_dramatic: "Dramatic", style_educational: "Educational", style_commentary: "Commentary", platform_tiktok: "TikTok", platform_youtube_shorts: "YouTube Shorts", platform_instagram_reels: "Instagram Reels", languageOption_Thai: "Thai", languageOption_English: "English", languageOption_Japanese: "Japanese", languageOption_Chinese: "Chinese", languageOption_Korean: "Korean", languageOption_Auto: "Auto-detect", // NLE editor mediaBin: "Clips", aiAssistant: "AI Assistant", aiReason: "AI hasn't explained yet — try regenerating.", aiReasonHead: "Why this moment", aiVisualHead: "Visual analysis", aiTighten: "Tighten to 30s", aiEmphasize: "Extend to 60s", aiRedoAll: "Regenerate clip", aiDeleteClip: "Remove clip", aiActionRedoSub: "Let AI pick a new moment", aiActionTightenSub: "Best for Reels & Shorts", aiActionEmphasizeSub: "Best for TikTok storytelling", aiActionDeleteSub: "Drop from this batch", dragToTrim: "Drag edges to trim · drag body to move", dragCueToRetime: "Drag cue edges or body to retime", dragToPosition: "Drag caption to reposition", // Subtitle editor addCue: "Add subtitle", cuePlaceholder: "Type subtitle text...", seekToCue: "Jump to this cue", aiSubtitleHead: "AI subtitle helpers", aiPolish: "Polish all", aiTranslate: "Translate", aiAutoTime: "Auto-time", aiAutoTimeHelp: "Re-time using Whisper word-level timestamps", // Clip edit clipEdit: "Clip length", clipLengthLabel: "Set length", clipExtendLabel: "Extend", clipSkipLabel: "Cut middle out", clipSkipAdd: "Cut", clipRebuildBtn: "Rebuild clip", from: "from", to: "to", // GPU status gpuActive: "GPU active", gpuDemo: "demo", gpuPending: "GPU pending", }; const translations = { en, th: { ...en, appSubtitle: "ตัดคลิปสั้นจากวิดีโอยาวอัตโนมัติ บน AMD ROCm", idle: "พร้อมใช้", queued: "รอคิว", running: "กำลังประมวลผล", completed: "เสร็จแล้ว", failed: "เกิดข้อผิดพลาด", demoMode: "โหมดเดโม", productionMode: "โหมดจริง", theme: "ธีม", language: "ภาษา", startPipeline: "สร้างคลิป", channelProfile: "โปรไฟล์ช่อง", channelProfileText: "บอก AI ว่าช่องคุณเป็นแนวไหน เพื่อให้เลือกไฮไลต์ได้ตรงกับสไตล์คุณ", videoInput: "วิดีโอ", niche: "แนวช่อง", nicheHelp: "เลือกหมวดที่ใกล้เคียงที่สุด หากเฉพาะเจาะจงมากให้เลือก อื่น ๆ", customNiche: "แนวช่องของคุณ", customNichePlaceholder: "เช่น สอน AI ภาษาไทยสำหรับมือใหม่", channelDescription: "อธิบายช่อง", channelDescriptionHelp: "เขียนแบบเป็นกันเอง AI จะนำไปใช้คัดเลือกช่วงที่เหมาะกับสไตล์ช่อง", channelDescriptionPlaceholder: "เช่น ช่องสอนใช้ AI แบบง่าย ๆ เข้าใจได้ทันที มีมุกนิดหน่อย", clipStyle: "สไตล์คลิป", clipLength: "ความยาวคลิป (วินาที)", clipCount: "จำนวนคลิป", clipCountHelp: "จำนวนคลิปที่ต้องการให้ AI หาให้จากวิดีโอนี้", primaryLanguage: "ภาษา", platform: "แพลตฟอร์ม", youtube: "YouTube", upload: "อัปโหลด", youtubeUrl: "ลิงก์ YouTube", youtubePlaceholder: "https://www.youtube.com/watch?v=...", videoFile: "ไฟล์วิดีโอ", pipeline: "การประมวลผล", ready: "พร้อม", progressNote: "อัปเดตทีละขั้นตอน — ระหว่างเรนเดอร์จะแสดงความคืบหน้าทีละคลิป", currentStep: "ขั้นตอนปัจจุบัน", timing: "เวลาที่ใช้", timing_input: "ดาวน์โหลด", timing_transcription: "ถอดเสียง", timing_highlight_detection: "ไฮไลต์", timing_multimodal_analysis: "วิเคราะห์ภาพ", timing_clip_generation: "เรนเดอร์", timing_total: "รวม", transcript: "คำบรรยาย", transcriptEmpty: "คำบรรยายจะแสดงเมื่อถอดเสียงเสร็จ", clips: "คลิป", noClips: "ยังไม่มีคลิป", noClipsText: "กด สร้างคลิป เพื่อให้ AI ค้นหาและตัดช่วงที่ดีที่สุด", readyClips: "คลิปพร้อมแล้ว", score: "คะแนน", reason: "เหตุผล", duration: "ความยาว", start: "เริ่ม", end: "จบ", subtitles: "ซับไตเติล", openEditor: "แก้ไขคลิป", approve: "อนุมัติ", approved: "อนุมัติแล้ว", regenerate: "ทำใหม่", delete: "ลบ", download: "ดาวน์โหลด", backToDashboard: "กลับ", editor: "ตัดต่อคลิป", editorText: "ปรับช่วงเวลา แก้ซับไตเติล และอนุมัติก่อนดาวน์โหลด", preview: "ตัวอย่าง", clipRange: "ช่วงเวลาคลิป", subtitleCues: "ซับไตเติล", subtitleCueHelp: "ซับสั้น ๆ อ่านง่ายกว่าบน TikTok, Reels และ Shorts", timelineTracks: "ไทม์ไลน์", videoTrack: "วิดีโอ", subtitleTrack: "ซับไตเติล", audioTrack: "เสียง", toolSelect: "เลือก", toolTrim: "ตัด", toolCaptions: "ซับ", toolStyle: "สไตล์", toolExport: "ส่งออก", captionStyle: "สไตล์ซับไตเติล", captionPreset: "พรีเซ็ต", presetClean: "เรียบง่าย", presetBold: "โดดเด่น", presetKaraoke: "คาราโอเกะ", font: "ฟอนต์", fontSize: "ขนาดตัวอักษร", fillColor: "สีตัวอักษร", strokeColor: "สีขอบ", strokeWidth: "ความหนาขอบ", captionPosition: "ตำแหน่ง", captionLength: "ความยาวต่อบรรทัด", animation: "อนิเมชัน", density_word: "ทีละคำ", density_short: "วลีสั้น", density_medium: "วลีกลาง", density_long: "บรรทัดยาว", animation_none: "ไม่มี", animation_highlight: "ไฮไลต์", animation_pop: "เด้งเข้า", animation_bounce: "เด้งตามจังหวะ", editorTools: "ปรับเวลาเร็ว", rangeStart: "จุดเริ่มต้น", rangeEnd: "จุดสิ้นสุด", trimStartBack: "−0.5 วิ", trimStartForward: "+0.5 วิ", trimEndBack: "−0.5 วิ", trimEndForward: "+0.5 วิ", moveClipLeft: "−1 วิ", moveClipRight: "+1 วิ", setClipLength30: "30 วิ", setClipLength60: "60 วิ", setClipLength90: "90 วิ", inspector: "รายละเอียด", title: "ชื่อคลิป", status: "สถานะ", notApproved: "ยังไม่อนุมัติ", model: "โมเดล AI", source: "แหล่งที่มา", source_upload: "อัปโหลด", source_youtube: "YouTube", source_video: "วิดีโอ", settings: "ตั้งค่า", stepInput: "ดาวน์โหลด", stepTranscription: "ถอดเสียง", stepHighlights: "ไฮไลต์", stepVisual: "AI ภาพ", stepRender: "เรนเดอร์", stepFinal: "เสร็จ", renderProgress: "กำลังเรนเดอร์คลิป {{current}} จาก {{total}}", appCta: "สร้างคลิป", niche_education: "การศึกษา", niche_gaming: "เกม", niche_podcast: "พอดแคสต์", niche_commentary: "คอมเมนทารี", niche_cars: "รถยนต์", niche_beauty: "บิวตี้", niche_fitness: "ฟิตเนส", niche_finance: "การเงิน", niche_tech: "เทคโนโลยี", niche_lifestyle: "ไลฟ์สไตล์", niche_music: "ดนตรี", niche_other: "อื่น ๆ", style_informative: "ให้ข้อมูล", style_funny: "ตลก", style_dramatic: "ดราม่า", style_educational: "สอน", style_commentary: "วิเคราะห์", platform_tiktok: "TikTok", platform_youtube_shorts: "YouTube Shorts", platform_instagram_reels: "Instagram Reels", languageOption_Thai: "ไทย", languageOption_English: "อังกฤษ", languageOption_Japanese: "ญี่ปุ่น", languageOption_Chinese: "จีน", languageOption_Korean: "เกาหลี", languageOption_Auto: "ตรวจจับอัตโนมัติ", // NLE editor mediaBin: "คลิปทั้งหมด", aiAssistant: "ผู้ช่วย AI", aiReason: "AI ยังไม่ได้อธิบาย ลองสร้างใหม่ดูสิ", aiReasonHead: "เหตุผลที่เลือกช่วงนี้", aiVisualHead: "วิเคราะห์ภาพ", aiTighten: "ตัดเหลือ 30 วิ", aiEmphasize: "ขยายเป็น 60 วิ", aiRedoAll: "สร้างคลิปนี้ใหม่", aiDeleteClip: "ลบคลิปนี้", aiActionRedoSub: "ให้ AI หาช่วงใหม่", aiActionTightenSub: "เหมาะกับ Reels และ Shorts", aiActionEmphasizeSub: "เหมาะกับ TikTok แบบเล่าเรื่อง", aiActionDeleteSub: "เอาออกจากชุดนี้", dragToTrim: "ลากขอบเพื่อ trim · ลากกลางเพื่อย้าย", dragCueToRetime: "ลากขอบหรือกลางซับเพื่อปรับเวลา", dragToPosition: "ลากข้อความเพื่อย้ายตำแหน่ง", addCue: "เพิ่มซับ", cuePlaceholder: "พิมพ์ข้อความซับ...", seekToCue: "ข้ามไปที่ซับนี้", aiSubtitleHead: "ผู้ช่วย AI สำหรับซับ", aiPolish: "เกลาคำพูด", aiTranslate: "แปล", aiAutoTime: "ตั้งเวลาอัตโนมัติ", aiAutoTimeHelp: "ปรับเวลาซับจาก Whisper รายคำ", clipEdit: "ปรับความยาวคลิป", clipLengthLabel: "ตั้งความยาว", clipExtendLabel: "เพิ่มเวลา", clipSkipLabel: "ตัดช่วงตรงกลางออก", clipSkipAdd: "ตัดออก", clipRebuildBtn: "สร้างคลิปใหม่", from: "จาก", to: "ถึง", gpuActive: "GPU ทำงาน", gpuDemo: "demo", gpuPending: "รอ GPU", }, ja: { ...en, appSubtitle: "長尺動画を短編クリップに ― AMD ROCm 搭載", idle: "待機中", queued: "順番待ち", running: "処理中", completed: "完了", failed: "失敗", demoMode: "デモモード", productionMode: "本番モード", theme: "テーマ", language: "言語", startPipeline: "クリップを生成", channelProfile: "チャンネルプロフィール", channelProfileText: "AIが最適な瞬間を選べるよう、チャンネルの情報を入力してください。", videoInput: "動画", niche: "ジャンル", nicheHelp: "近いカテゴリを選んでください。当てはまらない場合は「その他」を選択します。", customNiche: "ジャンルを入力", customNichePlaceholder: "例: 初心者向けタイ語AIチュートリアル", channelDescription: "チャンネル説明", channelDescriptionHelp: "友人に紹介するつもりで書いてください。AIがブランドの方向性を保つために使用します。", channelDescriptionPlaceholder: "例: タイ語でAIツールをわかりやすく、少しユーモアを交えて解説しています。", clipStyle: "クリップのスタイル", clipLength: "クリップの長さ(秒)", clipCount: "クリップ数", clipCountHelp: "この動画からAIに見つけてほしいクリップの数です。", primaryLanguage: "言語", platform: "配信プラットフォーム", youtube: "YouTube", upload: "アップロード", youtubeUrl: "YouTube URL", youtubePlaceholder: "https://www.youtube.com/watch?v=...", videoFile: "動画ファイル", pipeline: "処理状況", ready: "準備完了", progressNote: "工程ごとに更新され、レンダリング中はクリップ単位で進捗を表示します。", currentStep: "現在の工程", timing: "処理時間", timing_input: "ダウンロード", timing_transcription: "文字起こし", timing_highlight_detection: "ハイライト検出", timing_multimodal_analysis: "映像分析", timing_clip_generation: "クリップ生成", timing_total: "合計", transcript: "文字起こし", transcriptEmpty: "文字起こしが完了するとここに表示されます。", clips: "クリップ", noClips: "クリップがまだありません", noClipsText: "処理を開始すると、プロフィールに合わせた候補クリップが生成されます。", readyClips: "件のクリップ", score: "スコア", reason: "理由", duration: "長さ", start: "開始", end: "終了", openEditor: "エディターを開く", editor: "クリップエディター", editorText: "このクリップのタイミング、字幕、最終承認を調整します。", preview: "プレビュー", clipRange: "クリップ範囲", subtitles: "字幕", subtitleCues: "字幕ブロック", subtitleCueHelp: "短い字幕ブロックのほうがTikTok、Reels、Shortsで読みやすくなります。", timelineTracks: "タイムライン", videoTrack: "映像", subtitleTrack: "字幕", audioTrack: "音声", toolSelect: "選択", toolTrim: "トリム", toolCaptions: "字幕", toolStyle: "スタイル", toolExport: "書き出し", captionStyle: "字幕スタイル", captionPreset: "プリセット", presetClean: "シンプル", presetBold: "太字", presetKaraoke: "カラオケ", font: "フォント", fontSize: "サイズ", fillColor: "文字色", strokeColor: "縁の色", strokeWidth: "縁の太さ", captionPosition: "字幕の位置", captionLength: "字幕の長さ", animation: "アニメーション", density_word: "単語単位", density_short: "短めの行", density_medium: "標準", density_long: "長めの行", animation_none: "なし", animation_highlight: "ハイライト", animation_pop: "ポップ", animation_bounce: "バウンス", editorTools: "編集ツール", rangeStart: "開始位置", rangeEnd: "終了位置", trimStartBack: "開始 −0.5秒", trimStartForward: "開始 +0.5秒", trimEndBack: "終了 −0.5秒", trimEndForward: "終了 +0.5秒", moveClipLeft: "クリップ −1秒", moveClipRight: "クリップ +1秒", setClipLength30: "30秒に調整", setClipLength60: "60秒に調整", setClipLength90: "90秒に調整", inspector: "クリップ情報", title: "タイトル", status: "ステータス", notApproved: "未承認", model: "モデル", source: "ソース", source_upload: "アップロード", source_youtube: "YouTube", source_video: "動画", settings: "設定", stepInput: "入力", stepTranscription: "文字起こし", stepHighlights: "ハイライト", stepVisual: "映像分析", stepRender: "レンダリング", stepFinal: "仕上げ", renderProgress: "クリップ {{current}}/{{total}} をレンダリング中", appCta: "クリップを生成", approve: "承認", approved: "承認済み", regenerate: "再生成", delete: "削除", download: "ダウンロード", backToDashboard: "ホームに戻る", niche_education: "教育", niche_gaming: "ゲーム", niche_podcast: "ポッドキャスト", niche_commentary: "解説", niche_cars: "車", niche_beauty: "美容", niche_fitness: "フィットネス", niche_finance: "ファイナンス", niche_tech: "テクノロジー", niche_lifestyle: "ライフスタイル", niche_music: "音楽", niche_other: "その他", style_informative: "情報系", style_funny: "コメディ", style_dramatic: "ドラマチック", style_educational: "教育系", style_commentary: "解説", platform_tiktok: "TikTok", platform_youtube_shorts: "YouTube Shorts", platform_instagram_reels: "Instagram Reels", languageOption_Thai: "タイ語", languageOption_English: "英語", languageOption_Japanese: "日本語", languageOption_Chinese: "中国語", languageOption_Korean: "韓国語", languageOption_Auto: "自動検出", // NLE editor mediaBin: "クリップ一覧", aiAssistant: "AIアシスタント", aiReason: "AIの説明はまだありません。再生成してみてください。", aiReasonHead: "この場面を選んだ理由", aiVisualHead: "映像分析", aiTighten: "30秒に短縮", aiEmphasize: "60秒に延長", aiRedoAll: "このクリップを再生成", aiDeleteClip: "クリップを削除", aiActionRedoSub: "AIに別の場面を選ばせる", aiActionTightenSub: "Reels・Shortsに最適", aiActionEmphasizeSub: "TikTokのストーリーテリングに最適", aiActionDeleteSub: "このバッチから外す", dragToTrim: "端をドラッグでトリム · 中央をドラッグで移動", dragCueToRetime: "字幕の端や本体をドラッグしてタイミング調整", dragToPosition: "字幕をドラッグして移動", addCue: "字幕を追加", cuePlaceholder: "字幕テキストを入力...", seekToCue: "この字幕にジャンプ", aiSubtitleHead: "AI字幕アシスタント", aiPolish: "字幕を整える", aiTranslate: "翻訳", aiAutoTime: "自動タイミング", aiAutoTimeHelp: "Whisperの単語タイムスタンプで字幕を再調整", clipEdit: "クリップ長さ", clipLengthLabel: "長さを設定", clipExtendLabel: "延長", clipSkipLabel: "中央を切り取る", clipSkipAdd: "切り取り", clipRebuildBtn: "クリップを再生成", from: "から", to: "まで", gpuActive: "GPU動作中", gpuDemo: "デモ", gpuPending: "GPU待機中", }, zh: { ...en, appSubtitle: "把长视频变成短视频 — 由 AMD ROCm 驱动", idle: "待机", queued: "排队中", running: "处理中", completed: "完成", failed: "失败", demoMode: "演示模式", productionMode: "正式模式", theme: "主题", language: "语言", startPipeline: "生成短片", channelProfile: "频道资料", channelProfileText: "把你的频道情况告诉 AI,它才会挑出最合适的精彩瞬间。", videoInput: "视频", niche: "频道类型", nicheHelp: "选最接近的分类。如果不在列表中,请选择「其他」。", customNiche: "自定义类型", customNichePlaceholder: "例如:面向初学者的泰语 AI 教程", channelDescription: "频道介绍", channelDescriptionHelp: "像介绍给朋友一样写就好。AI 会用它判断内容是否符合频道风格。", channelDescriptionPlaceholder: "例如:我用泰语讲解 AI 工具,例子简单,也带一点幽默。", clipStyle: "短片风格", clipLength: "短片时长(秒)", clipCount: "短片数量", clipCountHelp: "希望 AI 从这段视频中生成多少条候选短片。", primaryLanguage: "语言", platform: "发布平台", youtube: "YouTube", upload: "上传", youtubeUrl: "YouTube 链接", youtubePlaceholder: "https://www.youtube.com/watch?v=...", videoFile: "视频文件", pipeline: "处理进度", ready: "准备就绪", progressNote: "每个步骤都会更新进度,渲染阶段会显示每条短片的状态。", currentStep: "当前步骤", timing: "耗时", timing_input: "下载", timing_transcription: "语音转写", timing_highlight_detection: "亮点识别", timing_multimodal_analysis: "画面分析", timing_clip_generation: "短片生成", timing_total: "总计", transcript: "字幕原文", transcriptEmpty: "转写完成后会显示在这里。", clips: "短片", noClips: "还没有短片", noClipsText: "启动流程后,AI 会根据频道资料生成候选短片。", readyClips: "条短片", score: "评分", reason: "原因", duration: "时长", start: "起点", end: "终点", openEditor: "打开编辑器", editor: "短片编辑器", editorText: "调整这条短片的时间、字幕,并完成最终确认。", preview: "预览", clipRange: "片段范围", subtitles: "字幕", subtitleCues: "字幕段落", subtitleCueHelp: "短字幕段落更适合 TikTok、Reels 和 Shorts。", timelineTracks: "时间线", videoTrack: "画面", subtitleTrack: "字幕", audioTrack: "音频", toolSelect: "选择", toolTrim: "裁剪", toolCaptions: "字幕", toolStyle: "样式", toolExport: "导出", captionStyle: "字幕样式", captionPreset: "预设", presetClean: "简洁", presetBold: "粗体", presetKaraoke: "卡拉 OK", font: "字体", fontSize: "字号", fillColor: "填充色", strokeColor: "描边色", strokeWidth: "描边宽度", captionPosition: "字幕位置", captionLength: "字幕长度", animation: "动效", density_word: "逐词", density_short: "短句", density_medium: "中等长度", density_long: "长句", animation_none: "无", animation_highlight: "高亮", animation_pop: "弹出", animation_bounce: "弹跳", editorTools: "编辑工具", rangeStart: "起点位置", rangeEnd: "终点位置", trimStartBack: "起点 −0.5 秒", trimStartForward: "起点 +0.5 秒", trimEndBack: "终点 −0.5 秒", trimEndForward: "终点 +0.5 秒", moveClipLeft: "整段 −1 秒", moveClipRight: "整段 +1 秒", setClipLength30: "调整为 30 秒", setClipLength60: "调整为 60 秒", setClipLength90: "调整为 90 秒", inspector: "属性", title: "标题", status: "状态", notApproved: "未确认", model: "模型", source: "来源", source_upload: "上传", source_youtube: "YouTube", source_video: "视频", settings: "设置", stepInput: "导入", stepTranscription: "语音转写", stepHighlights: "亮点识别", stepVisual: "画面分析", stepRender: "渲染", stepFinal: "收尾", renderProgress: "正在渲染第 {{current}}/{{total}} 条短片", appCta: "生成短片", approve: "确认", approved: "已确认", regenerate: "重新生成", delete: "删除", download: "下载", backToDashboard: "返回主界面", niche_education: "教育", niche_gaming: "游戏", niche_podcast: "播客", niche_commentary: "解说", niche_cars: "汽车", niche_beauty: "美妆", niche_fitness: "健身", niche_finance: "财经", niche_tech: "科技", niche_lifestyle: "生活", niche_music: "音乐", niche_other: "其他", style_informative: "知识型", style_funny: "幽默", style_dramatic: "戏剧化", style_educational: "教学型", style_commentary: "评论型", platform_tiktok: "TikTok", platform_youtube_shorts: "YouTube Shorts", platform_instagram_reels: "Instagram Reels", languageOption_Thai: "泰语", languageOption_English: "英语", languageOption_Japanese: "日语", languageOption_Chinese: "中文", languageOption_Korean: "韩语", languageOption_Auto: "自动检测", // NLE editor mediaBin: "片段列表", aiAssistant: "AI 助手", aiReason: "AI 还没解释,试试重新生成。", aiReasonHead: "为什么选这一段", aiVisualHead: "画面分析", aiTighten: "压缩到 30 秒", aiEmphasize: "延长到 60 秒", aiRedoAll: "重新生成此片段", aiDeleteClip: "删除此片段", aiActionRedoSub: "让 AI 找新的精彩瞬间", aiActionTightenSub: "适合 Reels 和 Shorts", aiActionEmphasizeSub: "适合 TikTok 故事化内容", aiActionDeleteSub: "从本批次移除", dragToTrim: "拖动边缘修剪 · 拖动中央移动", dragCueToRetime: "拖动字幕边缘或中央调整时间", dragToPosition: "拖动字幕移动位置", addCue: "添加字幕", cuePlaceholder: "输入字幕文字...", seekToCue: "跳到该字幕", aiSubtitleHead: "AI 字幕助手", aiPolish: "润色字幕", aiTranslate: "翻译", aiAutoTime: "自动对时", aiAutoTimeHelp: "用 Whisper 单词时间戳重新对齐", clipEdit: "片段长度", clipLengthLabel: "设置长度", clipExtendLabel: "延长", clipSkipLabel: "切掉中段", clipSkipAdd: "切掉", clipRebuildBtn: "重建片段", from: "从", to: "到", gpuActive: "GPU 活动", gpuDemo: "演示", gpuPending: "等待 GPU", }, ko: { ...en, appSubtitle: "긴 영상을 짧은 클립으로 ― AMD ROCm 기반", idle: "대기 중", queued: "대기열", running: "처리 중", completed: "완료", failed: "실패", demoMode: "데모 모드", productionMode: "정식 모드", theme: "테마", language: "언어", startPipeline: "클립 만들기", channelProfile: "채널 프로필", channelProfileText: "AI가 적절한 순간을 고를 수 있도록 채널 정보를 입력해 주세요.", videoInput: "영상", niche: "채널 분야", nicheHelp: "가장 가까운 카테고리를 선택하세요. 해당 항목이 없다면 기타를 선택합니다.", customNiche: "분야 직접 입력", customNichePlaceholder: "예: 초보자를 위한 태국어 AI 튜토리얼", channelDescription: "채널 소개", channelDescriptionHelp: "친구에게 소개하듯 자연스럽게 작성하세요. AI가 채널 톤을 유지하는 데 사용합니다.", channelDescriptionPlaceholder: "예: AI 도구를 태국어로 쉽게 설명하고, 약간의 유머도 곁들입니다.", clipStyle: "클립 스타일", clipLength: "클립 길이(초)", clipCount: "클립 개수", clipCountHelp: "이 영상에서 AI가 찾아낼 클립 개수입니다.", primaryLanguage: "언어", platform: "플랫폼", youtube: "YouTube", upload: "업로드", youtubeUrl: "YouTube URL", youtubePlaceholder: "https://www.youtube.com/watch?v=...", videoFile: "영상 파일", pipeline: "처리 진행", ready: "준비 완료", progressNote: "단계별로 진행률이 갱신되며, 렌더링 중에는 클립별 상태가 표시됩니다.", currentStep: "현재 단계", timing: "소요 시간", timing_input: "다운로드", timing_transcription: "음성 인식", timing_highlight_detection: "하이라이트 감지", timing_multimodal_analysis: "영상 분석", timing_clip_generation: "클립 생성", timing_total: "총 시간", transcript: "스크립트", transcriptEmpty: "음성 인식이 끝나면 여기에 표시됩니다.", clips: "클립", noClips: "아직 클립이 없습니다", noClipsText: "처리를 시작하면 채널 프로필에 맞춰 후보 클립이 생성됩니다.", readyClips: "개의 클립", score: "점수", reason: "선정 이유", duration: "길이", start: "시작", end: "끝", openEditor: "에디터 열기", editor: "클립 에디터", editorText: "이 클립의 타이밍, 자막, 최종 확인 상태를 조정합니다.", preview: "미리보기", clipRange: "클립 범위", subtitles: "자막", subtitleCues: "자막 단락", subtitleCueHelp: "짧은 자막 단락이 TikTok, Reels, Shorts에서 더 읽기 좋습니다.", timelineTracks: "타임라인", videoTrack: "영상", subtitleTrack: "자막", audioTrack: "오디오", toolSelect: "선택", toolTrim: "자르기", toolCaptions: "자막", toolStyle: "스타일", toolExport: "내보내기", captionStyle: "자막 스타일", captionPreset: "프리셋", presetClean: "심플", presetBold: "굵은 글씨", presetKaraoke: "노래방", font: "글꼴", fontSize: "글자 크기", fillColor: "글자색", strokeColor: "외곽선 색", strokeWidth: "외곽선 두께", captionPosition: "자막 위치", captionLength: "자막 길이", animation: "애니메이션", density_word: "단어 단위", density_short: "짧은 줄", density_medium: "보통", density_long: "긴 줄", animation_none: "없음", animation_highlight: "하이라이트", animation_pop: "팝", animation_bounce: "바운스", editorTools: "편집 도구", rangeStart: "시작 위치", rangeEnd: "끝 위치", trimStartBack: "시작 −0.5초", trimStartForward: "시작 +0.5초", trimEndBack: "끝 −0.5초", trimEndForward: "끝 +0.5초", moveClipLeft: "클립 −1초", moveClipRight: "클립 +1초", setClipLength30: "30초로 맞추기", setClipLength60: "60초로 맞추기", setClipLength90: "90초로 맞추기", inspector: "상세 정보", title: "제목", status: "상태", notApproved: "미확정", model: "모델", source: "소스", source_upload: "업로드", source_youtube: "YouTube", source_video: "영상", settings: "설정", stepInput: "불러오기", stepTranscription: "음성 인식", stepHighlights: "하이라이트", stepVisual: "영상 분석", stepRender: "렌더링", stepFinal: "마무리", renderProgress: "클립 {{current}}/{{total}} 렌더링 중", appCta: "클립 만들기", approve: "확정", approved: "확정됨", regenerate: "다시 만들기", delete: "삭제", download: "다운로드", backToDashboard: "홈으로", niche_education: "교육", niche_gaming: "게임", niche_podcast: "팟캐스트", niche_commentary: "해설", niche_cars: "자동차", niche_beauty: "뷰티", niche_fitness: "피트니스", niche_finance: "금융", niche_tech: "테크", niche_lifestyle: "라이프스타일", niche_music: "음악", niche_other: "기타", style_informative: "정보형", style_funny: "유머", style_dramatic: "감동적", style_educational: "교육형", style_commentary: "해설형", platform_tiktok: "TikTok", platform_youtube_shorts: "YouTube Shorts", platform_instagram_reels: "Instagram Reels", languageOption_Thai: "태국어", languageOption_English: "영어", languageOption_Japanese: "일본어", languageOption_Chinese: "중국어", languageOption_Korean: "한국어", languageOption_Auto: "자동 감지", // NLE editor mediaBin: "클립 목록", aiAssistant: "AI 어시스턴트", aiReason: "AI가 아직 설명하지 않았습니다. 다시 만들어 보세요.", aiReasonHead: "이 장면을 고른 이유", aiVisualHead: "영상 분석", aiTighten: "30초로 줄이기", aiEmphasize: "60초로 늘리기", aiRedoAll: "이 클립 다시 만들기", aiDeleteClip: "클립 삭제", aiActionRedoSub: "AI가 다른 장면을 찾도록", aiActionTightenSub: "Reels와 Shorts에 적합", aiActionEmphasizeSub: "TikTok 스토리텔링에 적합", aiActionDeleteSub: "이번 배치에서 제외", dragToTrim: "끝을 드래그해 트림 · 가운데를 드래그해 이동", dragCueToRetime: "자막 끝이나 중앙을 드래그해 타이밍 조정", dragToPosition: "자막을 드래그해 이동", addCue: "자막 추가", cuePlaceholder: "자막 텍스트 입력...", seekToCue: "이 자막으로 이동", aiSubtitleHead: "AI 자막 도우미", aiPolish: "자막 다듬기", aiTranslate: "번역", aiAutoTime: "자동 타이밍", aiAutoTimeHelp: "Whisper 단어 타임스탬프로 재조정", clipEdit: "클립 길이", clipLengthLabel: "길이 설정", clipExtendLabel: "연장", clipSkipLabel: "중간 잘라내기", clipSkipAdd: "잘라내기", clipRebuildBtn: "클립 다시 만들기", from: "부터", to: "까지", gpuActive: "GPU 활성", gpuDemo: "데모", gpuPending: "GPU 대기", }, }; // ============================================================ // App root // ============================================================ function App() { const [profile, setProfile] = useState(defaultProfile); const [sourceMode, setSourceMode] = useState("youtube"); const [youtubeUrl, setYoutubeUrl] = useState(""); const [file, setFile] = useState(null); const [job, setJob] = useState(null); const [health, setHealth] = useState(null); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); // Explicit view state — fixes the editor navigation bug const [view, setView] = useState("dashboard"); // 'dashboard' | 'editor' const [editorClipId, setEditorClipId] = useState(null); const [captionStyles, setCaptionStyles] = useState(() => { try { return JSON.parse(localStorage.getItem("elevenclip.captionStyles") || "{}"); } catch { return {}; } }); const [language, setLanguage] = useState( () => localStorage.getItem("elevenclip.language") || "en" ); const [theme, setTheme] = useState(() => { const saved = localStorage.getItem("elevenclip.theme"); if (saved) return saved; return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }); const t = (key, params) => { let value = translations[language]?.[key] ?? translations.en[key] ?? key; if (params) { Object.entries(params).forEach(([name, replacement]) => { value = value.replaceAll(`{{${name}}}`, String(replacement)); }); } return value; }; const activeClips = useMemo( () => (job?.clips || []).filter((clip) => !clip.deleted), [job?.clips] ); // Derive the editor clip from view state — no useEffect reset needed const editorClip = view === "editor" && editorClipId ? activeClips.find((clip) => clip.id === editorClipId) || null : null; const editorCaptionStyle = editorClip ? { ...defaultCaptionStyle, ...(captionStyles[editorClip.id] || {}) } : defaultCaptionStyle; // Navigation helpers function openEditor(clip) { setEditorClipId(clip.id); setView("editor"); } function closeEditor() { setView("dashboard"); } useEffect(() => { document.documentElement.dataset.theme = theme; localStorage.setItem("elevenclip.theme", theme); }, [theme]); useEffect(() => { localStorage.setItem("elevenclip.language", language); }, [language]); useEffect(() => { localStorage.setItem("elevenclip.captionStyles", JSON.stringify(captionStyles)); }, [captionStyles]); useEffect(() => { fetchJson("/health").then(setHealth).catch(() => setHealth(null)); }, []); useEffect(() => { if (!job || !["queued", "running"].includes(job.status)) return; const timer = window.setInterval(async () => { const next = await fetchJson(`/api/jobs/${job.id}`); setJob(next); }, 1200); return () => window.clearInterval(timer); }, [job]); async function submitJob(event) { event.preventDefault(); setError(""); setIsSubmitting(true); setView("dashboard"); setEditorClipId(null); try { if (sourceMode === "youtube") { const next = await fetchJson("/api/jobs/youtube", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ youtube_url: youtubeUrl, profile }), }); setJob(next); } else { const data = new FormData(); data.append("profile_json", JSON.stringify(profile)); data.append("file", file); const next = await fetchJson("/api/jobs/upload", { method: "POST", body: data }); setJob(next); } } catch (exc) { setError(exc.message); } finally { setIsSubmitting(false); } } async function patchClip(clipId, patch) { setJob((current) => ({ ...current, clips: current.clips.map((clip) => (clip.id === clipId ? { ...clip, ...patch } : clip)), })); try { const nextClip = await fetchJson(`/api/jobs/${job.id}/clips/${clipId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); setJob((current) => ({ ...current, clips: current.clips.map((clip) => (clip.id === clipId ? nextClip : clip)), })); } catch (exc) { setError(exc.message); } } async function regenerateClip(clip) { const nextClip = await fetchJson(`/api/jobs/${job.id}/clips/${clip.id}/regenerate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clip_style: profile.clip_style, clip_length_seconds: Number(profile.clip_length_seconds), subtitle_text: clip.subtitle_text, }), }); setJob((current) => ({ ...current, clips: current.clips.map((item) => (item.id === clip.id ? nextClip : item)), })); } // ─── AI subtitle actions ──────────────────────────────────── async function callAiSubtitle(endpoint, clip, body) { try { const nextClip = await fetchJson( `/api/jobs/${job.id}/clips/${clip.id}/subtitle/${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), } ); setJob((current) => ({ ...current, clips: current.clips.map((item) => (item.id === clip.id ? nextClip : item)), })); } catch (exc) { setError(exc.message); } } function polishSubtitles(clip) { return callAiSubtitle("polish", clip, { style: profile.clip_style }); } function translateSubtitles(clip, targetLanguage) { return callAiSubtitle("translate", clip, { target_language: targetLanguage }); } function autoTimeSubtitles(clip) { return callAiSubtitle("auto-time", clip, {}); } function setProfileValue(key) { return (value) => setProfile((current) => ({ ...current, [key]: value })); } function updateCaptionStyle(clipId, patch) { setCaptionStyles((current) => ({ ...current, [clipId]: { ...defaultCaptionStyle, ...(current[clipId] || {}), ...patch }, })); } return (
{view === "editor" && editorClipId && editorClip ? ( { patchClip(clip.id, { deleted: true }); closeEditor(); }} onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })} onRegenerate={regenerateClip} onPolishSubtitles={polishSubtitles} onTranslateSubtitles={translateSubtitles} onAutoTimeSubtitles={autoTimeSubtitles} captionStyle={editorCaptionStyle} onCaptionStyleChange={(patch) => updateCaptionStyle(editorClip.id, patch)} /> ) : ( patchClip(clip.id, { deleted: true })} onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })} onRegenerate={regenerateClip} /> )}
); } // ============================================================ // Dashboard layout // ============================================================ function Dashboard({ profile, setProfile, setProfileValue, sourceMode, setSourceMode, youtubeUrl, setYoutubeUrl, file, setFile, error, isSubmitting, submitJob, job, activeClips, t, onOpenEditor, onPatch, onDelete, onApprove, onRegenerate, }) { return (
{/* Sidebar — profile form */}
{/* Main content column */}
); } // ============================================================ // App Header // ============================================================ function AppHeader({ job, health, language, setLanguage, theme, setTheme, t, compact }) { const status = job?.status || "idle"; const modeLabel = health ? (health.demo_mode ? t("demoMode") : t("productionMode")) : "API"; const modeClass = health ? (health.demo_mode ? "demo" : "prod") : ""; return (

ElevenClip.AI

{!compact &&

{t("appSubtitle")}

}
{modeLabel}
); } // ============================================================ // Status pill // ============================================================ function StatusPill({ status, t }) { return {t(status)}; } // ============================================================ // Profile form (sidebar) // ============================================================ function ProfileForm({ t, profile, setProfile, setProfileValue, sourceMode, setSourceMode, youtubeUrl, setYoutubeUrl, file, setFile, error, isSubmitting, submitJob, }) { return (

{t("channelProfile")}

{t("channelProfileText")}

({ value, label: t(`niche_${value}`) }))} /> {profile.niche === "other" && ( )}
({ value, label: t(`style_${value}`) }))} /> setProfile((current) => ({ ...current, clip_length_seconds: Number(value) })) } options={[30, 45, 60, 90, 120].map((value) => ({ value, label: String(value) }))} />
setProfile((current) => ({ ...current, clip_count: Number(value) }))} options={[3, 5, 10].map((value) => ({ value, label: String(value) }))} />
({ value, label: t(`languageOption_${value}`), }))} /> ({ value, label: t(`platform_${value}`) }))} />

{t("videoInput")}

{sourceMode === "youtube" ? ( ) : ( )} {error &&
{error}
} ); } // ============================================================ // Progress panel // ============================================================ function ProgressPanel({ job, t }) { const progress = Math.round((job?.progress || 0) * 100); const steps = [ ["input", t("stepInput")], ["transcription", t("stepTranscription")], ["highlight_detection", t("stepHighlights")], ["multimodal_analysis", t("stepVisual")], ["clip_generation", t("stepRender")], ["finalizing", t("stepFinal")], ]; const stepIndex = Math.max(0, (job?.step_index || 0) - 1); const message = job?.current_step === "clip_generation" && job.active_clip_total ? t("renderProgress", { current: job.active_clip_index || 1, total: job.active_clip_total, }) : job?.message || t("ready"); return (

{t("pipeline")}

{message}

{progress}%
{steps.map(([id, label], index) => (
{index + 1}

{label}

))}

{t("progressNote")}

{job?.timings && Object.keys(job.timings).length > 0 && (
{Object.entries(job.timings).map(([name, value]) => (
{t(`timing_${name}`)} {value}s
))}
)} {job?.error &&
{job.error}
}
); } // ============================================================ // Transcript panel // ============================================================ function TranscriptPanel({ job, t }) { return (

{t("transcript")}

{!job?.transcript?.length &&

{t("transcriptEmpty")}

}
{job?.transcript?.length > 0 && (
{job.transcript.map((segment) => (
{formatTime(segment.start_seconds)} – {formatTime(segment.end_seconds)}

{segment.text}

))}
)}
); } // ============================================================ // Clips panel // ============================================================ function ClipsPanel({ clips, t, onOpenEditor, onPatch, onDelete, onApprove, onRegenerate }) { return (

{t("clips")}

{clips.length} {t("readyClips")}

{clips.length === 0 ? (

{t("noClips")}

{t("noClipsText")}

) : (
{clips.map((clip) => ( ))}
)}
); } // ============================================================ // Clip card // ============================================================ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegenerate }) { const duration = Math.max(1, clip.end_seconds - clip.start_seconds); return (
{clip.video_url ? (
{/* Title + score */}

{clip.title}

{clip.reason}

{Math.round(clip.score)}
{/* Duration row */}
{duration.toFixed(1)}s {formatTime(clip.start_seconds)} – {formatTime(clip.end_seconds)}
{/* Subtitle snippet */} {clip.subtitle_text && (

{clip.subtitle_text}

)} {/* Action row — primary CTA on top, icon group below */}
{clip.download_url && ( )}
); } // ============================================================ // Clip editor page — NLE 4-panel layout (Premiere-style) // ============================================================ function ClipEditorPage({ clip, clips, job, health, t, onBack, onSelectClip, onPatch, onDelete, onApprove, onRegenerate, onPolishSubtitles, onTranslateSubtitles, onAutoTimeSubtitles, captionStyle, onCaptionStyleChange, }) { const videoRef = useRef(null); const [playhead, setPlayhead] = useState(clip.start_seconds); const [isPlaying, setIsPlaying] = useState(false); const [selectedCueIndex, setSelectedCueIndex] = useState(0); // DRAFT state for in-flight drag (no API calls during mousemove) const [cueDraft, setCueDraft] = useState(null); // { index, cue: {start_seconds, end_seconds} } | null const [captionDraft, setCaptionDraft] = useState(null); // null | { x, y } const [aiBusy, setAiBusy] = useState({ polish: false, translate: false, autoTime: false }); const effStart = clip.start_seconds; const effEnd = clip.end_seconds; const duration = Math.max(0.5, effEnd - effStart); const effCaptionStyle = captionDraft ? { ...captionStyle, ...captionDraft } : captionStyle; // Cue source: explicit subtitle_cues from backend if present, else auto-distribute const baseCues = useMemo(() => { if (Array.isArray(clip.subtitle_cues) && clip.subtitle_cues.length) { return clip.subtitle_cues.map((cue) => ({ start_seconds: Number(cue.start_seconds || 0), end_seconds: Number(cue.end_seconds || 0), text: String(cue.text || ""), })); } return getSubtitleCues(clip, duration, captionStyle); }, [clip, duration, captionStyle]); // Apply draft (one cue's timing) on top of base cues const cues = useMemo(() => { if (!cueDraft) return baseCues; return baseCues.map((cue, index) => index === cueDraft.index ? { ...cue, start_seconds: cueDraft.cue.start_seconds, end_seconds: cueDraft.cue.end_seconds } : cue ); }, [baseCues, cueDraft]); const metadataModel = clip.metadata?.model || "unknown"; const sourceKind = job?.source?.kind || "video"; const timelineDuration = Math.max( effEnd + 5, ...(clips || []).map((c) => Number(c.end_seconds || 0)), ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)), 1 ); // Range-locked playback: video plays only within [effStart, effEnd] useEffect(() => { const video = videoRef.current; if (!video) return; function onTimeUpdate() { const ct = video.currentTime; if (ct >= effEnd - 0.05) { video.pause(); try { video.currentTime = effStart; } catch { /* ignore */ } } else if (ct < effStart - 0.5) { try { video.currentTime = effStart; } catch { /* ignore */ } } setPlayhead(video.currentTime); } function onPlay() { setIsPlaying(true); } function onPause() { setIsPlaying(false); } video.addEventListener("timeupdate", onTimeUpdate); video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); return () => { video.removeEventListener("timeupdate", onTimeUpdate); video.removeEventListener("play", onPlay); video.removeEventListener("pause", onPause); }; }, [effStart, effEnd]); // When the clip itself changes (selected from bin), seek to its start useEffect(() => { const video = videoRef.current; if (!video) return; function onLoaded() { try { video.currentTime = clip.start_seconds; } catch { /* ignore */ } setPlayhead(clip.start_seconds); } if (video.readyState >= 1) onLoaded(); else video.addEventListener("loadedmetadata", onLoaded, { once: true }); video.load(); return () => video.removeEventListener("loadedmetadata", onLoaded); }, [clip.id, clip.video_url, clip.start_seconds]); // Active cue at current playhead const playheadInClip = clamp(playhead - effStart, 0, duration); const autoActive = cues.findIndex( (cue) => playheadInClip >= cue.start_seconds && playheadInClip < cue.end_seconds ); const activeIndex = selectedCueIndex >= 0 && selectedCueIndex < cues.length ? selectedCueIndex : Math.max(0, autoActive); const activeCueText = cues[activeIndex]?.text || clip.subtitle_text || clip.title || ""; // ─── Mutations ────────────────────────────────────────────── function commitCaption(patch) { setCaptionDraft(null); onCaptionStyleChange(patch); } function persistCues(nextCues) { onPatch(clip.id, { subtitle_cues: nextCues.map((cue) => ({ start_seconds: roundTime(Number(cue.start_seconds || 0)), end_seconds: roundTime(Number(cue.end_seconds || 0)), text: String(cue.text || ""), })), subtitle_text: nextCues.map((cue) => cue.text).join(" "), }); } function commitCueTiming(index, partial) { setCueDraft(null); const next = baseCues.map((cue, i) => i === index ? { ...cue, start_seconds: partial.start_seconds, end_seconds: partial.end_seconds } : cue ); persistCues(next); } function patchCueText(index, text) { const next = baseCues.map((cue, i) => (i === index ? { ...cue, text } : cue)); persistCues(next); } function addCue() { const last = baseCues[baseCues.length - 1]; const startNew = last ? Math.min(last.end_seconds + 0.5, duration - 1) : 0; const endNew = clamp(startNew + 2, startNew + 0.5, duration); const next = [ ...baseCues, { start_seconds: startNew, end_seconds: endNew, text: "" }, ]; persistCues(next); setSelectedCueIndex(next.length - 1); } function removeCue(index) { const next = baseCues.filter((_, i) => i !== index); persistCues(next); setSelectedCueIndex(Math.max(0, Math.min(index, next.length - 1))); } function setClipLength(seconds) { onPatch(clip.id, { end_seconds: roundTime( clamp(clip.start_seconds + seconds, clip.start_seconds + 1, timelineDuration) ), }); } function extendClip(deltaSeconds) { onPatch(clip.id, { end_seconds: roundTime( clamp(clip.end_seconds + deltaSeconds, clip.start_seconds + 1, timelineDuration) ), }); } function addSkipRange(rangeStart, rangeEnd) { const start = clamp(Number(rangeStart), 0, duration); const end = clamp(Number(rangeEnd), start + 0.2, duration); const existing = Array.isArray(clip.skip_ranges) ? clip.skip_ranges : []; onPatch(clip.id, { skip_ranges: [...existing, { start_seconds: roundTime(start), end_seconds: roundTime(end) }], }); } function removeSkipRange(index) { const existing = Array.isArray(clip.skip_ranges) ? clip.skip_ranges : []; onPatch(clip.id, { skip_ranges: existing.filter((_, i) => i !== index), }); } async function runAiAction(kind, fn) { setAiBusy((b) => ({ ...b, [kind]: true })); try { await fn(); } finally { setAiBusy((b) => ({ ...b, [kind]: false })); } } function seekTo(seconds) { const video = videoRef.current; const target = clamp(seconds, effStart, effEnd); if (video) { try { video.currentTime = target; } catch { /* ignore */ } } setPlayhead(target); } function togglePlay() { const video = videoRef.current; if (!video) return; if (video.paused) { if (video.currentTime < effStart || video.currentTime >= effEnd - 0.05) { try { video.currentTime = effStart; } catch { /* ignore */ } } video.play(); } else { video.pause(); } } return (

{clip.title}

{formatTime(effStart)} – {formatTime(effEnd)}  · {" "} {duration.toFixed(1)}s  ·  {Math.round(clip.score)} {t("score")}

{clip.download_url && ( {t("download")} )}
seekTo(playhead + delta)} onCaptionDraftChange={setCaptionDraft} onCaptionCommit={commitCaption} t={t} /> setCueDraft({ index, cue: cuePartial }) } onCueCommit={commitCueTiming} t={t} /> commitCueTiming(index, partial) } onAddCue={addCue} onRemoveCue={removeCue} onSeek={seekTo} aiBusy={aiBusy} onPolish={() => runAiAction("polish", () => onPolishSubtitles(clip)) } onTranslate={(targetLang) => runAiAction("translate", () => onTranslateSubtitles(clip, targetLang)) } onAutoTime={() => runAiAction("autoTime", () => onAutoTimeSubtitles(clip)) } onSetClipLength={setClipLength} onExtendClip={extendClip} onAddSkipRange={addSkipRange} onRemoveSkipRange={removeSkipRange} onRegenerate={onRegenerate} t={t} />
); } // ============================================================ // Media Bin (left column) // ============================================================ function MediaBinPanel({ clips, activeId, onSelect, t }) { return ( ); } // ============================================================ // Preview Stage (center top) — video + draggable caption // ============================================================ function PreviewStage({ clip, videoRef, captionStyle, activeCueText, isPlaying, playhead, effStart, effEnd, onTogglePlay, onSeekDelta, onCaptionDraftChange, onCaptionCommit, t, }) { const stageRef = useRef(null); function handleCaptionDragStart(event) { event.preventDefault(); const stage = stageRef.current; if (!stage) return; const rect = stage.getBoundingClientRect(); function compute(ev) { const x = clamp(((ev.clientX - rect.left) / rect.width) * 100, 4, 96); const y = clamp(((ev.clientY - rect.top) / rect.height) * 100, 6, 94); return { x: Math.round(x), y: Math.round(y) }; } function onMove(ev) { onCaptionDraftChange(compute(ev)); } function onUp(ev) { onCaptionCommit(compute(ev)); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); } window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); } const playheadInClip = clamp(playhead - effStart, 0, Math.max(0.5, effEnd - effStart)); const clipDuration = Math.max(0.5, effEnd - effStart); return (

{t("preview")}

{clip.video_url ? (
{formatTime(playheadInClip)} / {formatTime(clipDuration)}
{t("dragToPosition")}
); } // ============================================================ // Caption Overlay — draggable, positioned by captionStyle.x/y // ============================================================ function CaptionOverlay({ text, settings, onMouseDown }) { const words = (text || "").split(/\s+/).filter(Boolean); const litCount = Math.max(1, Math.ceil(words.length * 0.45)); const x = typeof settings.x === "number" ? settings.x : 50; const y = typeof settings.y === "number" ? settings.y : 100 - (typeof settings.position === "number" ? settings.position : 18); const style = { left: `${x}%`, top: `${y}%`, color: settings.fillColor, fontFamily: `"${settings.fontFamily}", Inter, ui-sans-serif, system-ui, sans-serif`, fontSize: `${settings.fontSize}px`, WebkitTextStroke: `${settings.strokeWidth}px ${settings.strokeColor}`, textShadow: `0 3px 12px ${settings.strokeColor}`, }; return (
{settings.animation === "highlight" && words.length ? words.map((word, index) => ( {word} {index < words.length - 1 ? " " : ""} )) : text || "—"}
); } // ============================================================ // Timeline Editor (center bottom) — read-only V1 + draggable T1 cues // ============================================================ function TimelineEditor({ clip, cues, timelineDuration, playhead, effStart, effEnd, selectedCueIndex, onSelectCue, onSeek, onCueDraftChange, onCueCommit, t, }) { const laneRef = useRef(null); const cueLaneRef = useRef(null); const ticks = useMemo(() => { const result = []; const step = Math.max(5, Math.ceil(timelineDuration / 12 / 5) * 5); for (let s = 0; s <= timelineDuration; s += step) { result.push(s); } return result; }, [timelineDuration]); const clipLeftPct = (effStart / timelineDuration) * 100; const clipWidthPct = ((effEnd - effStart) / timelineDuration) * 100; const playheadPct = clamp((playhead / timelineDuration) * 100, 0, 100); function rectOf(ref) { return ref.current ? ref.current.getBoundingClientRect() : null; } // Drag a T1 cue edge or body. `edge` is "left" | "right" | "body". function startCueDrag(index, edge) { return (event) => { event.preventDefault(); event.stopPropagation(); const rect = rectOf(cueLaneRef); if (!rect) return; const cue = cues[index]; if (!cue) return; const initialStart = cue.start_seconds; const initialEnd = cue.end_seconds; const length = initialEnd - initialStart; const startX = event.clientX; const clipDur = Math.max(0.1, effEnd - effStart); function compute(ev) { if (edge === "body") { const dx = ev.clientX - startX; const deltaSeconds = (dx / rect.width) * timelineDuration; const newStart = clamp(initialStart + deltaSeconds, 0, clipDur - length); return { start_seconds: roundTime(newStart), end_seconds: roundTime(newStart + length), }; } // Edge-relative: convert mouse → absolute seconds in clip const ratio = clamp((ev.clientX - rect.left) / rect.width, 0, 1); const absoluteSeconds = ratio * timelineDuration; const cueLocal = clamp(absoluteSeconds - effStart, 0, clipDur); if (edge === "left") { return { start_seconds: roundTime(clamp(cueLocal, 0, initialEnd - 0.3)), end_seconds: initialEnd, }; } return { start_seconds: initialStart, end_seconds: roundTime(clamp(cueLocal, initialStart + 0.3, clipDur)), }; } function onMove(ev) { onCueDraftChange(index, compute(ev)); } function onUp(ev) { const final = compute(ev); onCueCommit(index, final); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); } window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }; } function handleRulerClick(event) { const rect = rectOf(laneRef); if (!rect) return; const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1); onSeek(ratio * timelineDuration); } return (

{t("timelineTracks")}

{t("dragCueToRetime")}
{ticks.map((tick) => { const left = (tick / timelineDuration) * 100; const isMajor = tick % 30 === 0; return ( {isMajor && ( {formatTime(tick)} )} ); })}
V1
{clip.title}
T1
{cues.map((cue, index) => { const cueLeft = ((effStart + cue.start_seconds) / timelineDuration) * 100; const cueWidth = ((cue.end_seconds - cue.start_seconds) / timelineDuration) * 100; return (
{ // Suppress click if a drag occurred (mouse moved) if (e.defaultPrevented) return; onSelectCue(index); }} title={cue.text} > {cue.text || "—"}
); })}
A1
{Array.from({ length: 80 }).map((_, index) => ( ))}
); } // ============================================================ // AI Assistant Panel (right top) // ============================================================ function AIAssistantPanel({ clip, health, t, onRegenerate, onDelete }) { const visualNote = clip.metadata?.visual_note; const visualScore = clip.metadata?.visual_score; const visualModel = clip.metadata?.visual_model; const textModel = clip.metadata?.model; const gpuActive = health && health.demo_mode === false; const acceleratorName = health?.accelerator?.device_name || (gpuActive ? "MI300X" : t("gpuPending")); return ( ); } // ============================================================ // Editor Inspector (right bottom) — metadata + caption + active cue // ============================================================ function EditorInspector({ clip, metadataModel, sourceKind, captionStyle, onCaptionStyleChange, cues, activeIndex, onSelectCue, onPatchCueText, onPatchCueTiming, onAddCue, onRemoveCue, onSeek, aiBusy, onPolish, onTranslate, onAutoTime, onSetClipLength, onExtendClip, onAddSkipRange, onRemoveSkipRange, onRegenerate, t, }) { const clipDuration = Math.max(0.5, clip.end_seconds - clip.start_seconds); const skipRanges = Array.isArray(clip.skip_ranges) ? clip.skip_ranges : []; return ( ); } // ============================================================ // Subtitle Editor — full per-cue control + AI subtitle actions // ============================================================ function SubtitleEditor({ clip, cues, activeIndex, clipDuration, onSelectCue, onPatchCueText, onPatchCueTiming, onAddCue, onRemoveCue, onSeek, aiBusy, onPolish, onTranslate, onAutoTime, t, }) { const [translateLang, setTranslateLang] = useState("English"); return (

{t("subtitleCues")}

{cues.length}
{cues.map((cue, index) => (
onSelectCue(index)} >
onPatchCueTiming(index, { start_seconds: v, end_seconds: cue.end_seconds, }) } /> onPatchCueTiming(index, { start_seconds: cue.start_seconds, end_seconds: v, }) } />