| 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, |
| y: 82, |
| 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", |
| |
| 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", |
| |
| 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", |
| |
| clipEdit: "Clip length", |
| clipLengthLabel: "Set length", |
| clipExtendLabel: "Extend", |
| clipSkipLabel: "Cut middle out", |
| clipSkipAdd: "Cut", |
| clipRebuildBtn: "Rebuild clip", |
| from: "from", |
| to: "to", |
| |
| 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: "ตรวจจับอัตโนมัติ", |
| |
| 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: "自動検出", |
| |
| 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: "自动检测", |
| |
| 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: "자동 감지", |
| |
| 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 대기", |
| }, |
| }; |
|
|
| |
| |
| |
| 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); |
|
|
| |
| const [view, setView] = useState("dashboard"); |
| 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] |
| ); |
|
|
| |
| const editorClip = |
| view === "editor" && editorClipId |
| ? activeClips.find((clip) => clip.id === editorClipId) || null |
| : null; |
|
|
| const editorCaptionStyle = editorClip |
| ? { ...defaultCaptionStyle, ...(captionStyles[editorClip.id] || {}) } |
| : defaultCaptionStyle; |
|
|
| |
| 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)), |
| })); |
| } |
|
|
| |
| 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 ( |
| <main className="app-shell"> |
| <AppHeader |
| job={job} |
| health={health} |
| language={language} |
| setLanguage={setLanguage} |
| theme={theme} |
| setTheme={setTheme} |
| t={t} |
| compact={view === "editor"} |
| /> |
| |
| {view === "editor" && editorClipId && editorClip ? ( |
| <ClipEditorPage |
| clip={editorClip} |
| clips={activeClips} |
| job={job} |
| health={health} |
| t={t} |
| onBack={closeEditor} |
| onSelectClip={openEditor} |
| onPatch={patchClip} |
| onDelete={(clip) => { |
| 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)} |
| /> |
| ) : ( |
| <Dashboard |
| profile={profile} |
| setProfile={setProfile} |
| setProfileValue={setProfileValue} |
| sourceMode={sourceMode} |
| setSourceMode={setSourceMode} |
| youtubeUrl={youtubeUrl} |
| setYoutubeUrl={setYoutubeUrl} |
| file={file} |
| setFile={setFile} |
| error={error} |
| isSubmitting={isSubmitting} |
| submitJob={submitJob} |
| job={job} |
| activeClips={activeClips} |
| t={t} |
| onOpenEditor={openEditor} |
| onPatch={patchClip} |
| onDelete={(clip) => patchClip(clip.id, { deleted: true })} |
| onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })} |
| onRegenerate={regenerateClip} |
| /> |
| )} |
| </main> |
| ); |
| } |
|
|
| |
| |
| |
| function Dashboard({ |
| profile, |
| setProfile, |
| setProfileValue, |
| sourceMode, |
| setSourceMode, |
| youtubeUrl, |
| setYoutubeUrl, |
| file, |
| setFile, |
| error, |
| isSubmitting, |
| submitJob, |
| job, |
| activeClips, |
| t, |
| onOpenEditor, |
| onPatch, |
| onDelete, |
| onApprove, |
| onRegenerate, |
| }) { |
| return ( |
| <div className="workspace-grid"> |
| {/* Sidebar — profile form */} |
| <div className="sidebar-column"> |
| <section className="panel input-panel"> |
| <ProfileForm |
| t={t} |
| profile={profile} |
| setProfile={setProfile} |
| setProfileValue={setProfileValue} |
| sourceMode={sourceMode} |
| setSourceMode={setSourceMode} |
| youtubeUrl={youtubeUrl} |
| setYoutubeUrl={setYoutubeUrl} |
| file={file} |
| setFile={setFile} |
| error={error} |
| isSubmitting={isSubmitting} |
| submitJob={submitJob} |
| /> |
| </section> |
| </div> |
| |
| {/* Main content column */} |
| <div className="main-column"> |
| <ProgressPanel job={job} t={t} /> |
| <TranscriptPanel job={job} t={t} /> |
| <ClipsPanel |
| clips={activeClips} |
| t={t} |
| onOpenEditor={onOpenEditor} |
| onPatch={onPatch} |
| onDelete={onDelete} |
| onApprove={onApprove} |
| onRegenerate={onRegenerate} |
| /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <header className={`app-header ${compact ? "compact" : ""}`}> |
| <div className="brand-block"> |
| <div className="brand-mark"> |
| <Scissors size={20} /> |
| </div> |
| <div> |
| <h1>ElevenClip.AI</h1> |
| {!compact && <p>{t("appSubtitle")}</p>} |
| </div> |
| </div> |
| |
| <div className="header-actions"> |
| <span className={`mode-pill ${modeClass}`}>{modeLabel}</span> |
| <StatusPill status={status} t={t} /> |
| <label className="toolbar-select" title={t("language")}> |
| <Languages size={14} /> |
| <select value={language} onChange={(event) => setLanguage(event.target.value)}> |
| {LANGUAGES.map((item) => ( |
| <option key={item.code} value={item.code}> |
| {item.label} |
| </option> |
| ))} |
| </select> |
| </label> |
| <button |
| className="icon-button" |
| type="button" |
| title={t("theme")} |
| onClick={() => setTheme(theme === "dark" ? "light" : "dark")} |
| > |
| {theme === "dark" ? <Sun size={16} /> : <Moon size={16} />} |
| </button> |
| </div> |
| </header> |
| ); |
| } |
|
|
| |
| |
| |
| function StatusPill({ status, t }) { |
| return <span className={`status-pill ${status}`}>{t(status)}</span>; |
| } |
|
|
| |
| |
| |
| function ProfileForm({ |
| t, |
| profile, |
| setProfile, |
| setProfileValue, |
| sourceMode, |
| setSourceMode, |
| youtubeUrl, |
| setYoutubeUrl, |
| file, |
| setFile, |
| error, |
| isSubmitting, |
| submitJob, |
| }) { |
| return ( |
| <form className="form-stack" onSubmit={submitJob}> |
| <div className="panel-heading"> |
| <div> |
| <h2>{t("channelProfile")}</h2> |
| <p>{t("channelProfileText")}</p> |
| </div> |
| <div className="panel-heading-icon"> |
| <SlidersHorizontal size={16} /> |
| </div> |
| </div> |
| |
| <SelectField |
| label={t("niche")} |
| helper={t("nicheHelp")} |
| value={profile.niche} |
| onChange={setProfileValue("niche")} |
| options={NICHES.map((value) => ({ value, label: t(`niche_${value}`) }))} |
| /> |
| {profile.niche === "other" && ( |
| <TextField |
| label={t("customNiche")} |
| value={profile.niche_custom} |
| onChange={setProfileValue("niche_custom")} |
| placeholder={t("customNichePlaceholder")} |
| /> |
| )} |
| <TextAreaField |
| label={t("channelDescription")} |
| helper={t("channelDescriptionHelp")} |
| value={profile.channel_description} |
| onChange={setProfileValue("channel_description")} |
| placeholder={t("channelDescriptionPlaceholder")} |
| rows={3} |
| /> |
| |
| <div className="form-grid-two"> |
| <SelectField |
| label={t("clipStyle")} |
| value={profile.clip_style} |
| onChange={setProfileValue("clip_style")} |
| options={CLIP_STYLES.map((value) => ({ value, label: t(`style_${value}`) }))} |
| /> |
| <SelectField |
| label={t("clipLength")} |
| value={profile.clip_length_seconds} |
| onChange={(value) => |
| setProfile((current) => ({ ...current, clip_length_seconds: Number(value) })) |
| } |
| options={[30, 45, 60, 90, 120].map((value) => ({ value, label: String(value) }))} |
| /> |
| </div> |
| |
| <SelectField |
| label={t("clipCount")} |
| helper={t("clipCountHelp")} |
| value={profile.clip_count} |
| onChange={(value) => setProfile((current) => ({ ...current, clip_count: Number(value) }))} |
| options={[3, 5, 10].map((value) => ({ value, label: String(value) }))} |
| /> |
| |
| <div className="form-grid-two"> |
| <SelectField |
| label={t("primaryLanguage")} |
| value={profile.primary_language} |
| onChange={setProfileValue("primary_language")} |
| options={LANGUAGE_OPTIONS.map((value) => ({ |
| value, |
| label: t(`languageOption_${value}`), |
| }))} |
| /> |
| <SelectField |
| label={t("platform")} |
| value={profile.target_platform} |
| onChange={setProfileValue("target_platform")} |
| options={PLATFORM_OPTIONS.map((value) => ({ value, label: t(`platform_${value}`) }))} |
| /> |
| </div> |
| |
| <div className="divider" /> |
| |
| <div className="panel-heading compact"> |
| <div> |
| <h2>{t("videoInput")}</h2> |
| </div> |
| <div className="panel-heading-icon"> |
| <Film size={16} /> |
| </div> |
| </div> |
| |
| <div className="segmented"> |
| <button |
| type="button" |
| className={sourceMode === "youtube" ? "active" : ""} |
| onClick={() => setSourceMode("youtube")} |
| > |
| <LinkIcon size={14} /> |
| {t("youtube")} |
| </button> |
| <button |
| type="button" |
| className={sourceMode === "upload" ? "active" : ""} |
| onClick={() => setSourceMode("upload")} |
| > |
| <Upload size={14} /> |
| {t("upload")} |
| </button> |
| </div> |
| |
| {sourceMode === "youtube" ? ( |
| <TextField |
| label={t("youtubeUrl")} |
| value={youtubeUrl} |
| onChange={setYoutubeUrl} |
| placeholder={t("youtubePlaceholder")} |
| /> |
| ) : ( |
| <label className="field-block"> |
| <span className="field-label">{t("videoFile")}</span> |
| <input |
| className="file-input" |
| type="file" |
| accept="video/mp4,video/quicktime,video/*" |
| onChange={(event) => setFile(event.target.files?.[0] || null)} |
| /> |
| {file && <span className="file-name">{file.name}</span>} |
| </label> |
| )} |
| |
| {error && <div className="error-box">{error}</div>} |
| |
| <button |
| className="primary-button" |
| style={{ width: "100%" }} |
| disabled={isSubmitting || (sourceMode === "youtube" ? !youtubeUrl : !file)} |
| type="submit" |
| > |
| {isSubmitting ? <Loader2 className="spin" size={16} /> : <Wand2 size={16} />} |
| {t("startPipeline")} |
| </button> |
| </form> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <section className="panel progress-panel"> |
| <div className="panel-heading"> |
| <div> |
| <h2>{t("pipeline")}</h2> |
| <p>{message}</p> |
| </div> |
| <strong className="progress-percent">{progress}%</strong> |
| </div> |
| |
| <div className="progress-track"> |
| <div className="progress-bar" style={{ width: `${progress}%` }} /> |
| </div> |
| |
| <div className="step-list" aria-label={t("currentStep")}> |
| {steps.map(([id, label], index) => ( |
| <div |
| key={id} |
| className={`step-item${index < stepIndex ? " done" : ""}${ |
| index === stepIndex ? " active" : "" |
| }`} |
| > |
| <span>{index + 1}</span> |
| <p>{label}</p> |
| </div> |
| ))} |
| </div> |
| |
| <p className="helper-text" style={{ marginTop: 12 }}> |
| {t("progressNote")} |
| </p> |
| |
| {job?.timings && Object.keys(job.timings).length > 0 && ( |
| <div className="timing-grid"> |
| {Object.entries(job.timings).map(([name, value]) => ( |
| <div key={name}> |
| <span>{t(`timing_${name}`)}</span> |
| <strong>{value}s</strong> |
| </div> |
| ))} |
| </div> |
| )} |
| |
| {job?.error && <div className="error-box" style={{ marginTop: 12 }}>{job.error}</div>} |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| function TranscriptPanel({ job, t }) { |
| return ( |
| <section className="panel transcript-panel"> |
| <div className="panel-heading compact"> |
| <div> |
| <h2>{t("transcript")}</h2> |
| {!job?.transcript?.length && <p>{t("transcriptEmpty")}</p>} |
| </div> |
| <div className="panel-heading-icon"> |
| <Captions size={16} /> |
| </div> |
| </div> |
| {job?.transcript?.length > 0 && ( |
| <div className="transcript-list"> |
| {job.transcript.map((segment) => ( |
| <div key={segment.id} className="transcript-row"> |
| <span> |
| {formatTime(segment.start_seconds)} – {formatTime(segment.end_seconds)} |
| </span> |
| <p>{segment.text}</p> |
| </div> |
| ))} |
| </div> |
| )} |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| function ClipsPanel({ clips, t, onOpenEditor, onPatch, onDelete, onApprove, onRegenerate }) { |
| return ( |
| <section className="panel clips-panel"> |
| <div className="panel-heading"> |
| <div> |
| <h2>{t("clips")}</h2> |
| <p> |
| {clips.length} {t("readyClips")} |
| </p> |
| </div> |
| <div className="panel-heading-icon"> |
| <Film size={16} /> |
| </div> |
| </div> |
| |
| {clips.length === 0 ? ( |
| <div className="empty-state"> |
| <Film size={32} /> |
| <h3>{t("noClips")}</h3> |
| <p>{t("noClipsText")}</p> |
| </div> |
| ) : ( |
| <div className="clip-grid"> |
| {clips.map((clip) => ( |
| <ClipCard |
| key={clip.id} |
| clip={clip} |
| t={t} |
| onOpenEditor={onOpenEditor} |
| onPatch={onPatch} |
| onDelete={onDelete} |
| onApprove={onApprove} |
| onRegenerate={onRegenerate} |
| /> |
| ))} |
| </div> |
| )} |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegenerate }) { |
| const duration = Math.max(1, clip.end_seconds - clip.start_seconds); |
|
|
| return ( |
| <article className="clip-card"> |
| <div className="clip-video"> |
| {clip.video_url ? ( |
| <video controls src={`${API_BASE}${clip.video_url}`} /> |
| ) : ( |
| <Film size={28} /> |
| )} |
| </div> |
| |
| <div className="clip-body"> |
| {/* Title + score */} |
| <div className="clip-meta"> |
| <div style={{ minWidth: 0 }}> |
| <h3 className="clip-title">{clip.title}</h3> |
| <p className="clip-reason">{clip.reason}</p> |
| </div> |
| <span className="score-badge"> |
| <Gauge size={12} /> |
| {Math.round(clip.score)} |
| </span> |
| </div> |
| |
| {/* Duration row */} |
| <div className="clip-duration-row"> |
| <span> |
| <Clock3 size={12} /> |
| {duration.toFixed(1)}s |
| </span> |
| <span> |
| {formatTime(clip.start_seconds)} – {formatTime(clip.end_seconds)} |
| </span> |
| </div> |
| |
| {/* Subtitle snippet */} |
| {clip.subtitle_text && ( |
| <p className="subtitle-snippet">{clip.subtitle_text}</p> |
| )} |
| |
| {/* Action row — primary CTA on top, icon group below */} |
| <div className="clip-actions"> |
| <button |
| className="btn btn-primary" |
| type="button" |
| title={t("openEditor")} |
| onClick={() => onOpenEditor(clip)} |
| > |
| <PanelRightOpen size={14} /> |
| {t("openEditor")} |
| </button> |
| |
| <div className="clip-actions-icons"> |
| <button |
| className={`btn btn-icon ${clip.approved ? "btn-success" : ""}`} |
| type="button" |
| title={clip.approved ? t("approved") : t("approve")} |
| onClick={() => onApprove(clip)} |
| > |
| <Check size={14} /> |
| </button> |
| |
| <button |
| className="btn btn-icon" |
| type="button" |
| title={t("regenerate")} |
| onClick={() => onRegenerate(clip)} |
| > |
| <RefreshCcw size={14} /> |
| </button> |
| |
| <button |
| className="btn btn-icon btn-danger" |
| type="button" |
| title={t("delete")} |
| onClick={() => onDelete(clip)} |
| > |
| <Trash2 size={14} /> |
| </button> |
| |
| {clip.download_url && ( |
| <a |
| className="btn btn-icon" |
| href={`${API_BASE}${clip.download_url}`} |
| title={t("download")} |
| style={{ borderColor: "var(--primary-dim)", background: "var(--primary-glow)", color: "var(--primary)" }} |
| > |
| <Download size={14} /> |
| </a> |
| )} |
| </div> |
| </div> |
| </div> |
| </article> |
| ); |
| } |
|
|
| |
| |
| |
| 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); |
|
|
| |
| const [cueDraft, setCueDraft] = useState(null); |
| const [captionDraft, setCaptionDraft] = useState(null); |
| 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; |
|
|
| |
| 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]); |
|
|
| |
| 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 |
| ); |
|
|
| |
| 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 { |
| |
| } |
| } else if (ct < effStart - 0.5) { |
| try { |
| video.currentTime = effStart; |
| } catch { |
| |
| } |
| } |
| 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]); |
|
|
| |
| useEffect(() => { |
| const video = videoRef.current; |
| if (!video) return; |
| function onLoaded() { |
| try { |
| video.currentTime = clip.start_seconds; |
| } catch { |
| |
| } |
| 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]); |
|
|
| |
| 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 || ""; |
|
|
| |
| 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 { |
| |
| } |
| } |
| 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 { |
| |
| } |
| } |
| video.play(); |
| } else { |
| video.pause(); |
| } |
| } |
|
|
| return ( |
| <div className="editor-shell nle"> |
| <div className="editor-topbar"> |
| <button className="ghost-button" type="button" onClick={onBack}> |
| <ArrowLeft size={16} /> |
| {t("backToDashboard")} |
| </button> |
| |
| <div className="editor-topbar-info"> |
| <h2>{clip.title}</h2> |
| <p> |
| {formatTime(effStart)} – {formatTime(effEnd)} · {" "} |
| {duration.toFixed(1)}s · {Math.round(clip.score)} {t("score")} |
| </p> |
| </div> |
| |
| <div className="editor-topbar-actions"> |
| <button |
| className={`btn ${clip.approved ? "btn-success" : ""}`} |
| type="button" |
| onClick={() => onApprove(clip)} |
| > |
| <Check size={14} /> |
| {clip.approved ? t("approved") : t("approve")} |
| </button> |
| {clip.download_url && ( |
| <a |
| className="btn btn-primary" |
| href={`${API_BASE}${clip.download_url}`} |
| title={t("download")} |
| > |
| <Download size={14} /> |
| {t("download")} |
| </a> |
| )} |
| </div> |
| </div> |
| |
| <div className="editor-grid-nle"> |
| <MediaBinPanel |
| clips={clips || []} |
| activeId={clip.id} |
| onSelect={onSelectClip} |
| t={t} |
| /> |
| |
| <PreviewStage |
| clip={clip} |
| videoRef={videoRef} |
| captionStyle={effCaptionStyle} |
| activeCueText={activeCueText} |
| isPlaying={isPlaying} |
| playhead={playhead} |
| effStart={effStart} |
| effEnd={effEnd} |
| onTogglePlay={togglePlay} |
| onSeekDelta={(delta) => seekTo(playhead + delta)} |
| onCaptionDraftChange={setCaptionDraft} |
| onCaptionCommit={commitCaption} |
| t={t} |
| /> |
| |
| <AIAssistantPanel |
| clip={clip} |
| health={health} |
| t={t} |
| onRegenerate={onRegenerate} |
| onDelete={onDelete} |
| /> |
| |
| <TimelineEditor |
| clip={clip} |
| cues={cues} |
| timelineDuration={timelineDuration} |
| playhead={playhead} |
| effStart={effStart} |
| effEnd={effEnd} |
| selectedCueIndex={activeIndex} |
| onSelectCue={setSelectedCueIndex} |
| onSeek={seekTo} |
| onCueDraftChange={(index, cuePartial) => |
| setCueDraft({ index, cue: cuePartial }) |
| } |
| onCueCommit={commitCueTiming} |
| t={t} |
| /> |
| |
| <EditorInspector |
| clip={clip} |
| metadataModel={metadataModel} |
| sourceKind={sourceKind} |
| captionStyle={captionStyle} |
| onCaptionStyleChange={onCaptionStyleChange} |
| cues={baseCues} |
| activeIndex={activeIndex} |
| onSelectCue={setSelectedCueIndex} |
| onPatchCueText={patchCueText} |
| onPatchCueTiming={(index, partial) => |
| 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} |
| /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function MediaBinPanel({ clips, activeId, onSelect, t }) { |
| return ( |
| <aside className="nle-panel nle-bin"> |
| <div className="nle-panel-head"> |
| <h3> |
| {t("mediaBin")} <span style={{ color: "var(--text-soft)", fontWeight: 500, marginLeft: 6 }}>· {clips.length}</span> |
| </h3> |
| <span className="nle-panel-icon"> |
| <FolderOpen size={12} /> |
| </span> |
| </div> |
| <div className="nle-panel-body"> |
| <div className="nle-bin-list"> |
| {clips.map((c) => ( |
| <button |
| type="button" |
| key={c.id} |
| className={`nle-bin-item ${c.id === activeId ? "active" : ""}`} |
| onClick={() => onSelect && onSelect(c)} |
| > |
| <div className="nle-bin-thumb"> |
| {c.video_url ? ( |
| <video src={`${API_BASE}${c.video_url}`} muted preload="metadata" /> |
| ) : ( |
| <Film size={18} /> |
| )} |
| </div> |
| <div className="nle-bin-meta"> |
| <span className="nle-bin-title">{c.title}</span> |
| <span className="nle-bin-sub"> |
| {formatTime(c.start_seconds)}–{formatTime(c.end_seconds)} |
| <span className="nle-bin-score"> |
| <Gauge size={10} /> |
| {Math.round(c.score)} |
| </span> |
| {c.approved && ( |
| <Check size={10} style={{ color: "var(--success)" }} /> |
| )} |
| </span> |
| </div> |
| </button> |
| ))} |
| </div> |
| </div> |
| </aside> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <section className="nle-panel nle-preview"> |
| <div className="nle-panel-head"> |
| <h3>{t("preview")}</h3> |
| <span className="nle-panel-icon"> |
| <Maximize2 size={12} /> |
| </span> |
| </div> |
| <div className="preview-stage" ref={stageRef}> |
| <div className="preview-stage-canvas"> |
| {clip.video_url ? ( |
| <video |
| ref={videoRef} |
| src={`${API_BASE}${clip.video_url}`} |
| playsInline |
| preload="metadata" |
| muted |
| /> |
| ) : ( |
| <Film size={56} style={{ color: "var(--text-soft)" }} /> |
| )} |
| <CaptionOverlay |
| text={activeCueText} |
| settings={captionStyle} |
| onMouseDown={handleCaptionDragStart} |
| /> |
| </div> |
| </div> |
| <div className="preview-toolbar"> |
| <div className="preview-toolbar-left"> |
| <button |
| className="btn btn-icon" |
| type="button" |
| onClick={() => onSeekDelta(-1)} |
| title={t("trimStartBack")} |
| > |
| <SkipBack size={14} /> |
| </button> |
| <button |
| className="btn btn-icon btn-primary" |
| type="button" |
| onClick={onTogglePlay} |
| > |
| {isPlaying ? <Pause size={14} /> : <Play size={14} />} |
| </button> |
| <button |
| className="btn btn-icon" |
| type="button" |
| onClick={() => onSeekDelta(1)} |
| title={t("trimStartForward")} |
| > |
| <SkipForward size={14} /> |
| </button> |
| </div> |
| <div className="preview-time"> |
| <strong>{formatTime(playheadInClip)}</strong> / {formatTime(clipDuration)} |
| </div> |
| <div className="preview-toolbar-right"> |
| <span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}> |
| <Move size={11} style={{ verticalAlign: "-2px", marginRight: 4 }} /> |
| {t("dragToPosition")} |
| </span> |
| </div> |
| </div> |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <div |
| className={`caption-overlay ${settings.animation}`} |
| style={style} |
| onMouseDown={onMouseDown} |
| > |
| {settings.animation === "highlight" && words.length |
| ? words.map((word, index) => ( |
| <span key={`${word}-${index}`} className={index < litCount ? "lit" : ""}> |
| {word} |
| {index < words.length - 1 ? " " : ""} |
| </span> |
| )) |
| : text || "—"} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| 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), |
| }; |
| } |
| |
| 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 ( |
| <section className="nle-panel nle-timeline"> |
| <div className="nle-panel-head"> |
| <h3>{t("timelineTracks")}</h3> |
| <span className="nle-panel-icon"> |
| <Layers size={12} /> |
| </span> |
| </div> |
| <div className="timeline-toolbar"> |
| <span> |
| <Captions size={11} style={{ verticalAlign: "-2px", marginRight: 4 }} /> |
| {t("dragCueToRetime")} |
| </span> |
| </div> |
| <div className="timeline-area"> |
| <div |
| className="timeline-ruler" |
| onClick={handleRulerClick} |
| ref={laneRef} |
| style={{ cursor: "pointer" }} |
| > |
| {ticks.map((tick) => { |
| const left = (tick / timelineDuration) * 100; |
| const isMajor = tick % 30 === 0; |
| return ( |
| <React.Fragment key={tick}> |
| <span |
| className={`timeline-tick ${isMajor ? "major" : ""}`} |
| style={{ left: `${left}%` }} |
| /> |
| {isMajor && ( |
| <span |
| className="timeline-tick-label" |
| style={{ left: `${left}%` }} |
| > |
| {formatTime(tick)} |
| </span> |
| )} |
| </React.Fragment> |
| ); |
| })} |
| <div |
| className="timeline-playhead" |
| style={{ left: `${playheadPct}%` }} |
| /> |
| </div> |
| <div className="timeline-stack"> |
| <div className="timeline-track"> |
| <div className="timeline-track-label">V1</div> |
| <div className="timeline-track-lane video"> |
| <div |
| className="timeline-clip readonly" |
| style={{ |
| left: `${clipLeftPct}%`, |
| width: `${clipWidthPct}%`, |
| }} |
| title={clip.title} |
| > |
| <span className="timeline-clip-label">{clip.title}</span> |
| </div> |
| <div |
| className="timeline-playhead" |
| style={{ left: `${playheadPct}%` }} |
| /> |
| </div> |
| </div> |
| <div className="timeline-track"> |
| <div className="timeline-track-label">T1</div> |
| <div className="timeline-track-lane" ref={cueLaneRef}> |
| {cues.map((cue, index) => { |
| const cueLeft = |
| ((effStart + cue.start_seconds) / timelineDuration) * 100; |
| const cueWidth = |
| ((cue.end_seconds - cue.start_seconds) / timelineDuration) * 100; |
| return ( |
| <div |
| key={`cue-${index}`} |
| className={`timeline-caption-block ${ |
| index === selectedCueIndex ? "selected" : "" |
| }`} |
| style={{ |
| left: `${clamp(cueLeft, 0, 100)}%`, |
| width: `${clamp(cueWidth, 1.4, 100 - cueLeft)}%`, |
| }} |
| onMouseDown={startCueDrag(index, "body")} |
| onClick={(e) => { |
| // Suppress click if a drag occurred (mouse moved) |
| if (e.defaultPrevented) return; |
| onSelectCue(index); |
| }} |
| title={cue.text} |
| > |
| <span |
| className="cue-handle left" |
| onMouseDown={startCueDrag(index, "left")} |
| /> |
| <span className="cue-text">{cue.text || "—"}</span> |
| <span |
| className="cue-handle right" |
| onMouseDown={startCueDrag(index, "right")} |
| /> |
| </div> |
| ); |
| })} |
| <div |
| className="timeline-playhead" |
| style={{ left: `${playheadPct}%` }} |
| /> |
| </div> |
| </div> |
| <div className="timeline-track"> |
| <div className="timeline-track-label">A1</div> |
| <div className="timeline-track-lane audio"> |
| <div className="timeline-waveform"> |
| {Array.from({ length: 80 }).map((_, index) => ( |
| <span |
| key={index} |
| style={{ |
| height: `${24 + ((index * 17 + clip.id.length * 11) % 70)}%`, |
| }} |
| /> |
| ))} |
| </div> |
| <div |
| className="timeline-playhead" |
| style={{ left: `${playheadPct}%` }} |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <aside className="nle-panel nle-ai"> |
| <div className="nle-panel-head"> |
| <h3> |
| {t("aiAssistant")}{" "} |
| <span |
| className={`gpu-tag ${gpuActive ? "active" : "pending"}`} |
| title={acceleratorName} |
| > |
| <span className="gpu-dot" /> |
| {gpuActive ? t("gpuActive") : t("gpuDemo")} |
| </span> |
| </h3> |
| <span className="nle-panel-icon"> |
| <Sparkles size={12} /> |
| </span> |
| </div> |
| <div className="nle-panel-body"> |
| {/* Why AI picked this clip (Qwen text) */} |
| <div className="ai-card"> |
| <div className="ai-card-head"> |
| <span className="ai-card-tag"> |
| <Wand2 size={10} /> Qwen2.5 |
| </span> |
| <span className="ai-card-sub">{t("aiReasonHead")}</span> |
| </div> |
| <p className="ai-card-body">{clip.reason || t("aiReason")}</p> |
| {textModel && ( |
| <p className="ai-card-foot"> |
| {t("model")}: {textModel} |
| </p> |
| )} |
| </div> |
| |
| {/* Visual analysis (Qwen-VL) */} |
| {visualNote && ( |
| <div className="ai-card vision"> |
| <div className="ai-card-head"> |
| <span className="ai-card-tag vision"> |
| <Sparkles size={10} /> Qwen2-VL |
| </span> |
| <span className="ai-card-sub">{t("aiVisualHead")}</span> |
| {typeof visualScore === "number" && ( |
| <span className="ai-card-score"> |
| {Math.round(visualScore)} |
| </span> |
| )} |
| </div> |
| <p className="ai-card-body">{visualNote}</p> |
| {visualModel && ( |
| <p className="ai-card-foot"> |
| {t("model")}: {visualModel} |
| </p> |
| )} |
| </div> |
| )} |
| |
| <div className="ai-actions compact"> |
| <button |
| type="button" |
| className="ai-action" |
| onClick={() => onRegenerate(clip)} |
| > |
| <span className="ai-action-icon"> |
| <RefreshCcw size={14} /> |
| </span> |
| <span className="ai-action-text"> |
| <strong>{t("aiRedoAll")}</strong> |
| <small>{t("aiActionRedoSub")}</small> |
| </span> |
| </button> |
| <button |
| type="button" |
| className="ai-action danger" |
| onClick={() => onDelete(clip)} |
| > |
| <span |
| className="ai-action-icon" |
| style={{ background: "var(--danger-soft)", color: "var(--danger)" }} |
| > |
| <Trash2 size={14} /> |
| </span> |
| <span className="ai-action-text"> |
| <strong>{t("aiDeleteClip")}</strong> |
| <small>{t("aiActionDeleteSub")}</small> |
| </span> |
| </button> |
| </div> |
| </div> |
| </aside> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <aside className="nle-panel nle-inspector"> |
| <div className="nle-panel-head"> |
| <h3>{t("inspector")}</h3> |
| <span className="nle-panel-icon"> |
| <SlidersHorizontal size={12} /> |
| </span> |
| </div> |
| <div className="nle-panel-body"> |
| <div className="inspector-stack"> |
| <section> |
| <dl className="inspector-meta"> |
| <div> |
| <dt>{t("score")}</dt> |
| <dd className="score-value">{Math.round(clip.score)}</dd> |
| </div> |
| <div> |
| <dt>{t("status")}</dt> |
| <dd>{clip.approved ? t("approved") : t("notApproved")}</dd> |
| </div> |
| <div> |
| <dt>{t("source")}</dt> |
| <dd>{t(`source_${sourceKind}`)}</dd> |
| </div> |
| <div> |
| <dt>{t("model")}</dt> |
| <dd style={{ fontSize: "0.74rem" }}>{metadataModel}</dd> |
| </div> |
| </dl> |
| </section> |
| |
| <SubtitleEditor |
| clip={clip} |
| cues={cues} |
| activeIndex={activeIndex} |
| clipDuration={clipDuration} |
| onSelectCue={onSelectCue} |
| onPatchCueText={onPatchCueText} |
| onPatchCueTiming={onPatchCueTiming} |
| onAddCue={onAddCue} |
| onRemoveCue={onRemoveCue} |
| onSeek={onSeek} |
| aiBusy={aiBusy} |
| onPolish={onPolish} |
| onTranslate={onTranslate} |
| onAutoTime={onAutoTime} |
| t={t} |
| /> |
| |
| <ClipEditPanel |
| clip={clip} |
| clipDuration={clipDuration} |
| skipRanges={skipRanges} |
| onSetClipLength={onSetClipLength} |
| onExtendClip={onExtendClip} |
| onAddSkipRange={onAddSkipRange} |
| onRemoveSkipRange={onRemoveSkipRange} |
| onRegenerate={onRegenerate} |
| t={t} |
| /> |
| |
| <section> |
| <h4> |
| <Captions size={11} style={{ verticalAlign: "-2px", marginRight: 5 }} /> |
| {t("captionStyle")} |
| </h4> |
| <CaptionStylePanel |
| t={t} |
| settings={captionStyle} |
| onChange={onCaptionStyleChange} |
| /> |
| </section> |
| </div> |
| </div> |
| </aside> |
| ); |
| } |
|
|
| |
| |
| |
| function SubtitleEditor({ |
| clip, |
| cues, |
| activeIndex, |
| clipDuration, |
| onSelectCue, |
| onPatchCueText, |
| onPatchCueTiming, |
| onAddCue, |
| onRemoveCue, |
| onSeek, |
| aiBusy, |
| onPolish, |
| onTranslate, |
| onAutoTime, |
| t, |
| }) { |
| const [translateLang, setTranslateLang] = useState("English"); |
|
|
| return ( |
| <section className="subtitle-editor"> |
| <div className="subtitle-editor-head"> |
| <h4> |
| <Type size={11} style={{ verticalAlign: "-2px", marginRight: 5 }} /> |
| {t("subtitleCues")} |
| </h4> |
| <span className="subtitle-count">{cues.length}</span> |
| </div> |
| |
| <div className="cue-rows"> |
| {cues.map((cue, index) => ( |
| <div |
| key={`${clip.id}-cue-${index}`} |
| className={`cue-row ${index === activeIndex ? "active" : ""}`} |
| onClick={() => onSelectCue(index)} |
| > |
| <div className="cue-row-times"> |
| <NumberStepper |
| value={cue.start_seconds} |
| min={0} |
| max={Math.max(0, cue.end_seconds - 0.2)} |
| step={0.1} |
| onChange={(v) => |
| onPatchCueTiming(index, { |
| start_seconds: v, |
| end_seconds: cue.end_seconds, |
| }) |
| } |
| /> |
| <span className="cue-row-sep">–</span> |
| <NumberStepper |
| value={cue.end_seconds} |
| min={cue.start_seconds + 0.2} |
| max={clipDuration} |
| step={0.1} |
| onChange={(v) => |
| onPatchCueTiming(index, { |
| start_seconds: cue.start_seconds, |
| end_seconds: v, |
| }) |
| } |
| /> |
| <button |
| type="button" |
| className="cue-row-jump" |
| title={t("seekToCue")} |
| onClick={(e) => { |
| e.stopPropagation(); |
| onSeek(clip.start_seconds + cue.start_seconds); |
| }} |
| > |
| <Play size={11} /> |
| </button> |
| <button |
| type="button" |
| className="cue-row-delete" |
| title={t("delete")} |
| onClick={(e) => { |
| e.stopPropagation(); |
| onRemoveCue(index); |
| }} |
| > |
| <Trash2 size={11} /> |
| </button> |
| </div> |
| <textarea |
| className="cue-row-text" |
| rows={2} |
| value={cue.text} |
| onChange={(e) => onPatchCueText(index, e.target.value)} |
| onClick={(e) => e.stopPropagation()} |
| placeholder={t("cuePlaceholder")} |
| /> |
| </div> |
| ))} |
| </div> |
| |
| <button type="button" className="btn cue-add" onClick={onAddCue}> |
| <span style={{ fontSize: "1rem", lineHeight: 1 }}>+</span> {t("addCue")} |
| </button> |
| |
| <div className="ai-subtitle-actions"> |
| <p className="ai-subtitle-head"> |
| <Sparkles size={11} style={{ verticalAlign: "-2px", marginRight: 5 }} /> |
| {t("aiSubtitleHead")} |
| </p> |
| <div className="ai-subtitle-row"> |
| <button |
| type="button" |
| className="btn btn-primary" |
| disabled={aiBusy?.polish} |
| onClick={onPolish} |
| > |
| {aiBusy?.polish ? <Loader2 size={12} className="spin" /> : <Wand2 size={12} />} |
| {t("aiPolish")} |
| </button> |
| <button |
| type="button" |
| className="btn" |
| disabled={aiBusy?.autoTime} |
| onClick={onAutoTime} |
| title={t("aiAutoTimeHelp")} |
| > |
| {aiBusy?.autoTime ? ( |
| <Loader2 size={12} className="spin" /> |
| ) : ( |
| <Clock3 size={12} /> |
| )} |
| {t("aiAutoTime")} |
| </button> |
| </div> |
| <div className="ai-subtitle-row translate"> |
| <select |
| value={translateLang} |
| onChange={(e) => setTranslateLang(e.target.value)} |
| > |
| {LANGUAGE_OPTIONS.filter((l) => l !== "Auto").map((lang) => ( |
| <option key={lang} value={lang}> |
| {t(`languageOption_${lang}`)} |
| </option> |
| ))} |
| </select> |
| <button |
| type="button" |
| className="btn" |
| disabled={aiBusy?.translate} |
| onClick={() => onTranslate(translateLang)} |
| > |
| {aiBusy?.translate ? ( |
| <Loader2 size={12} className="spin" /> |
| ) : ( |
| <Languages size={12} /> |
| )} |
| {t("aiTranslate")} |
| </button> |
| </div> |
| </div> |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| function ClipEditPanel({ |
| clip, |
| clipDuration, |
| skipRanges, |
| onSetClipLength, |
| onExtendClip, |
| onAddSkipRange, |
| onRemoveSkipRange, |
| onRegenerate, |
| t, |
| }) { |
| const [skipStart, setSkipStart] = useState(0); |
| const [skipEnd, setSkipEnd] = useState(0); |
|
|
| function handleAddSkip() { |
| const start = Math.max(0, Number(skipStart) || 0); |
| const end = Math.max(start + 0.2, Number(skipEnd) || start + 1); |
| if (end <= start) return; |
| onAddSkipRange(start, end); |
| setSkipStart(0); |
| setSkipEnd(0); |
| } |
|
|
| return ( |
| <section className="clip-edit-panel"> |
| <h4> |
| <Scissors size={11} style={{ verticalAlign: "-2px", marginRight: 5 }} /> |
| {t("clipEdit")} |
| </h4> |
| |
| <div className="clip-edit-row"> |
| <span className="clip-edit-label">{t("clipLengthLabel")}</span> |
| <div className="clip-edit-buttons"> |
| {[30, 45, 60, 90].map((sec) => ( |
| <button |
| key={sec} |
| type="button" |
| className="btn btn-icon" |
| onClick={() => onSetClipLength(sec)} |
| title={`${sec}s`} |
| > |
| {sec}s |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| <div className="clip-edit-row"> |
| <span className="clip-edit-label">{t("clipExtendLabel")}</span> |
| <div className="clip-edit-buttons"> |
| {[5, 10, 30].map((sec) => ( |
| <button |
| key={sec} |
| type="button" |
| className="btn btn-icon" |
| onClick={() => onExtendClip(sec)} |
| title={`+${sec}s`} |
| > |
| +{sec}s |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| <div className="clip-edit-row vertical"> |
| <span className="clip-edit-label">{t("clipSkipLabel")}</span> |
| <div className="clip-skip-input"> |
| <input |
| type="number" |
| min="0" |
| max={clipDuration} |
| step="0.1" |
| value={skipStart} |
| placeholder={t("from")} |
| onChange={(e) => setSkipStart(e.target.value)} |
| /> |
| <span>–</span> |
| <input |
| type="number" |
| min="0" |
| max={clipDuration} |
| step="0.1" |
| value={skipEnd} |
| placeholder={t("to")} |
| onChange={(e) => setSkipEnd(e.target.value)} |
| /> |
| <button |
| type="button" |
| className="btn" |
| onClick={handleAddSkip} |
| title={t("clipSkipAdd")} |
| > |
| <Scissors size={11} /> |
| {t("clipSkipAdd")} |
| </button> |
| </div> |
| {skipRanges.length > 0 && ( |
| <ul className="skip-list"> |
| {skipRanges.map((range, index) => ( |
| <li key={`skip-${index}`}> |
| <span> |
| {range.start_seconds.toFixed(1)}s – {range.end_seconds.toFixed(1)}s |
| </span> |
| <button |
| type="button" |
| className="btn btn-icon btn-danger" |
| onClick={() => onRemoveSkipRange(index)} |
| title={t("delete")} |
| > |
| <Trash2 size={10} /> |
| </button> |
| </li> |
| ))} |
| </ul> |
| )} |
| </div> |
| |
| <button |
| type="button" |
| className="btn btn-primary clip-edit-rerender" |
| onClick={() => onRegenerate(clip)} |
| > |
| <RefreshCcw size={12} /> |
| {t("clipRebuildBtn")} |
| </button> |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| function NumberStepper({ value, min, max, step, onChange }) { |
| const safe = Number(value) || 0; |
| function clampVal(v) { |
| return Math.min(max, Math.max(min, Math.round(v * 10) / 10)); |
| } |
| return ( |
| <div className="num-stepper"> |
| <input |
| type="number" |
| value={safe.toFixed(1)} |
| min={min} |
| max={max} |
| step={step} |
| onChange={(e) => onChange(clampVal(Number(e.target.value)))} |
| onClick={(e) => e.stopPropagation()} |
| /> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function CaptionStylePanel({ t, settings, onChange }) { |
| return ( |
| <div className="caption-style-panel"> |
| <div className="preset-row"> |
| {Object.entries(captionPresets).map(([key, preset]) => ( |
| <button type="button" key={key} onClick={() => onChange(preset)}> |
| {t(`preset${capitalize(key)}`)} |
| </button> |
| ))} |
| </div> |
| |
| <div className="style-grid"> |
| <SelectField |
| label={t("font")} |
| value={settings.fontFamily} |
| onChange={(value) => onChange({ fontFamily: value })} |
| options={FONT_OPTIONS.map((value) => ({ value, label: value }))} |
| /> |
| <SelectField |
| label={t("captionLength")} |
| value={settings.cueDensity} |
| onChange={(value) => onChange({ cueDensity: value })} |
| options={CUE_DENSITIES.map((value) => ({ value, label: t(`density_${value}`) }))} |
| /> |
| <SelectField |
| label={t("animation")} |
| value={settings.animation} |
| onChange={(value) => onChange({ animation: value })} |
| options={CAPTION_ANIMATIONS.map((value) => ({ value, label: t(`animation_${value}`) }))} |
| /> |
| </div> |
| |
| <div className="color-grid"> |
| <ColorField |
| label={t("fillColor")} |
| value={settings.fillColor} |
| onChange={(fillColor) => onChange({ fillColor })} |
| /> |
| <ColorField |
| label={t("strokeColor")} |
| value={settings.strokeColor} |
| onChange={(strokeColor) => onChange({ strokeColor })} |
| /> |
| </div> |
| |
| <RangeControl |
| label={t("fontSize")} |
| value={settings.fontSize} |
| min={24} |
| max={64} |
| onChange={(fontSize) => onChange({ fontSize })} |
| /> |
| <RangeControl |
| label={t("strokeWidth")} |
| value={settings.strokeWidth} |
| min={0} |
| max={8} |
| onChange={(strokeWidth) => onChange({ strokeWidth })} |
| /> |
| <RangeControl |
| label={t("captionPosition")} |
| value={settings.position} |
| min={8} |
| max={38} |
| onChange={(position) => onChange({ position })} |
| /> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function TranscriptMini({ transcript, clip, t }) { |
| const rows = transcript.filter( |
| (segment) => |
| segment.end_seconds >= clip.start_seconds && segment.start_seconds <= clip.end_seconds |
| ); |
|
|
| return ( |
| <div className="mini-transcript"> |
| <h3>{t("transcript")}</h3> |
| {rows.length === 0 && ( |
| <p style={{ margin: 0, fontSize: "0.8rem", color: "var(--text-soft)" }}> |
| {t("transcriptEmpty")} |
| </p> |
| )} |
| {rows.map((segment) => ( |
| <div key={segment.id}> |
| <span> |
| {formatTime(segment.start_seconds)} – {formatTime(segment.end_seconds)} |
| </span> |
| <p>{segment.text}</p> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function TextField({ label, value, onChange, placeholder, helper }) { |
| return ( |
| <label className="field-block"> |
| <span className="field-label">{label}</span> |
| <input |
| className="text-input" |
| value={value} |
| placeholder={placeholder} |
| onChange={(event) => onChange(event.target.value)} |
| /> |
| {helper && <span className="helper-text">{helper}</span>} |
| </label> |
| ); |
| } |
|
|
| function TextAreaField({ label, value, onChange, placeholder, helper, rows = 3 }) { |
| return ( |
| <label className="field-block"> |
| <span className="field-label">{label}</span> |
| <textarea |
| value={value} |
| placeholder={placeholder} |
| onChange={(event) => onChange(event.target.value)} |
| rows={rows} |
| /> |
| {helper && <span className="helper-text">{helper}</span>} |
| </label> |
| ); |
| } |
|
|
| function SelectField({ label, value, onChange, options, helper }) { |
| return ( |
| <label className="field-block"> |
| <span className="field-label">{label}</span> |
| <select |
| className="text-input" |
| value={value} |
| onChange={(event) => onChange(event.target.value)} |
| > |
| {options.map((option) => ( |
| <option key={option.value} value={option.value}> |
| {option.label} |
| </option> |
| ))} |
| </select> |
| {helper && <span className="helper-text">{helper}</span>} |
| </label> |
| ); |
| } |
|
|
| function NumberField({ label, value, onChange }) { |
| return ( |
| <label> |
| <span style={{ display: "block", fontSize: "0.74rem", fontWeight: 700, color: "var(--text-muted)", marginBottom: 5 }}> |
| {label} |
| </span> |
| <input |
| type="number" |
| min="0" |
| step="0.5" |
| value={value} |
| onChange={(event) => onChange(event.target.value)} |
| style={{ |
| width: "100%", |
| minHeight: 36, |
| padding: "7px 10px", |
| border: "1px solid var(--border)", |
| borderRadius: "var(--radius-sm)", |
| background: "var(--surface)", |
| color: "var(--text)", |
| outline: "none", |
| fontVariantNumeric: "tabular-nums", |
| }} |
| /> |
| </label> |
| ); |
| } |
|
|
| function ColorField({ label, value, onChange }) { |
| return ( |
| <label className="color-field"> |
| <span>{label}</span> |
| <input type="color" value={value} onChange={(event) => onChange(event.target.value)} /> |
| </label> |
| ); |
| } |
|
|
| function RangeControl({ label, value, min, max, onChange }) { |
| return ( |
| <label className="range-control"> |
| <span> |
| {label} |
| <strong>{value}</strong> |
| </span> |
| <input |
| type="range" |
| min={min} |
| max={max} |
| step="1" |
| value={value} |
| onChange={(event) => onChange(Number(event.target.value))} |
| /> |
| </label> |
| ); |
| } |
|
|
| |
| |
| |
| async function fetchJson(path, options) { |
| const response = await fetch(`${API_BASE}${path}`, options); |
| if (!response.ok) { |
| let detail = response.statusText; |
| try { |
| const payload = await response.json(); |
| detail = payload.detail || detail; |
| } catch { |
| detail = response.statusText; |
| } |
| throw new Error(detail); |
| } |
| return response.json(); |
| } |
|
|
| function getSubtitleCues(clip, duration, settings = defaultCaptionStyle) { |
| return splitSubtitleText(clip.subtitle_text || "", settings.cueDensity).map( |
| (text, index, all) => { |
| const cueDuration = duration / Math.max(all.length, 1); |
| return { |
| start_seconds: roundTime(index * cueDuration), |
| end_seconds: roundTime((index + 1) * cueDuration), |
| text, |
| }; |
| } |
| ); |
| } |
|
|
| function splitSubtitleText(text, density = "short") { |
| const clean = text.trim().replace(/\s+/g, " "); |
| if (!clean) return [""]; |
| const limits = { |
| word: { words: 1, chars: 18 }, |
| short: { words: 4, chars: 30 }, |
| medium: { words: 7, chars: 46 }, |
| long: { words: 12, chars: 78 }, |
| }; |
| const limit = limits[density] || limits.short; |
| const words = clean.split(" "); |
| if (words.length <= 1) { |
| const chunks = []; |
| for (let index = 0; index < clean.length; index += limit.chars) { |
| chunks.push(clean.slice(index, index + limit.chars)); |
| } |
| return chunks; |
| } |
| const chunks = []; |
| let current = []; |
| words.forEach((word) => { |
| const candidate = [...current, word].join(" "); |
| const punctuationBreak = |
| current.length > 0 && /[,.!?;:]$/.test(current[current.length - 1]); |
| if ( |
| current.length > 0 && |
| (candidate.length > limit.chars || |
| current.length >= limit.words || |
| punctuationBreak) |
| ) { |
| chunks.push(current.join(" ")); |
| current = [word]; |
| } else { |
| current.push(word); |
| } |
| }); |
| if (current.length) chunks.push(current.join(" ")); |
| return chunks; |
| } |
|
|
| function roundTime(value) { |
| return Math.round(value * 10) / 10; |
| } |
|
|
| function clamp(value, min, max) { |
| return Math.min(Math.max(value, min), max); |
| } |
|
|
| function capitalize(value) { |
| return value.charAt(0).toUpperCase() + value.slice(1); |
| } |
|
|
| function formatTime(value) { |
| const safeValue = Number.isFinite(Number(value)) ? Number(value) : 0; |
| const minutes = Math.floor(safeValue / 60); |
| const seconds = Math.floor(safeValue % 60); |
| return `${minutes}:${String(seconds).padStart(2, "0")}`; |
| } |
|
|
| export default App; |
|
|