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