JakgritB commited on
Commit
c481310
·
1 Parent(s): 08d2fa9

feat(frontend): redesign editor workspace controls

Browse files
Files changed (2) hide show
  1. frontend/src/App.jsx +474 -35
  2. frontend/src/styles.css +285 -15
frontend/src/App.jsx CHANGED
@@ -50,6 +50,53 @@ const NICHES = [
50
  const CLIP_STYLES = ["informative", "funny", "dramatic", "educational", "commentary"];
51
  const LANGUAGE_OPTIONS = ["Thai", "English", "Japanese", "Chinese", "Korean", "Auto"];
52
  const PLATFORM_OPTIONS = ["tiktok", "youtube_shorts", "instagram_reels"];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  const defaultProfile = {
55
  niche: "education",
@@ -132,6 +179,36 @@ const en = {
132
  clipRange: "Clip range",
133
  subtitleCues: "Subtitle cues",
134
  subtitleCueHelp: "Short cues read better on TikTok, Reels, and Shorts.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  editorTools: "Editing tools",
136
  rangeStart: "Range start",
137
  rangeEnd: "Range end",
@@ -262,6 +339,36 @@ const translations = {
262
  clipRange: "ช่วงคลิป",
263
  subtitleCues: "จังหวะซับ",
264
  subtitleCueHelp: "ซับสั้น ๆ อ่านง่ายกว่าสำหรับ TikTok, Reels และ Shorts",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  editorTools: "เครื่องมือตัดต่อ",
266
  rangeStart: "ตำแหน่งเริ่ม",
267
  rangeEnd: "ตำแหน่งจบ",
@@ -384,6 +491,36 @@ const translations = {
384
  subtitles: "字幕",
385
  subtitleCues: "字幕キュー",
386
  subtitleCueHelp: "短い字幕キューのほうがTikTok、Reels、Shortsで読みやすくなります。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  editorTools: "編集ツール",
388
  rangeStart: "開始位置",
389
  rangeEnd: "終了位置",
@@ -511,6 +648,36 @@ const translations = {
511
  subtitles: "字幕",
512
  subtitleCues: "字幕片段",
513
  subtitleCueHelp: "短字幕片段更适合 TikTok、Reels 和 Shorts。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  editorTools: "编辑工具",
515
  rangeStart: "开始位置",
516
  rangeEnd: "结束位置",
@@ -639,6 +806,36 @@ const translations = {
639
  subtitles: "자막",
640
  subtitleCues: "자막 큐",
641
  subtitleCueHelp: "짧은 자막 큐가 TikTok, Reels, Shorts에서 더 읽기 쉽습니다.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  editorTools: "편집 도구",
643
  rangeStart: "시작 위치",
644
  rangeEnd: "종료 위치",
@@ -714,6 +911,13 @@ function App() {
714
  const [error, setError] = useState("");
715
  const [isSubmitting, setIsSubmitting] = useState(false);
716
  const [editorClipId, setEditorClipId] = useState(null);
 
 
 
 
 
 
 
717
  const [language, setLanguage] = useState(() => localStorage.getItem("elevenclip.language") || "en");
718
  const [theme, setTheme] = useState(() => {
719
  const saved = localStorage.getItem("elevenclip.theme");
@@ -736,6 +940,9 @@ function App() {
736
  [job?.clips]
737
  );
738
  const editorClip = activeClips.find((clip) => clip.id === editorClipId);
 
 
 
739
 
740
  useEffect(() => {
741
  document.documentElement.dataset.theme = theme;
@@ -746,6 +953,10 @@ function App() {
746
  localStorage.setItem("elevenclip.language", language);
747
  }, [language]);
748
 
 
 
 
 
749
  useEffect(() => {
750
  fetchJson("/health").then(setHealth).catch(() => setHealth(null));
751
  }, []);
@@ -791,15 +1002,23 @@ function App() {
791
  }
792
 
793
  async function patchClip(clipId, patch) {
794
- const nextClip = await fetchJson(`/api/jobs/${job.id}/clips/${clipId}`, {
795
- method: "PATCH",
796
- headers: { "Content-Type": "application/json" },
797
- body: JSON.stringify(patch),
798
- });
799
  setJob((current) => ({
800
  ...current,
801
- clips: current.clips.map((clip) => (clip.id === clipId ? nextClip : clip)),
802
  }));
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  }
804
 
805
  async function regenerateClip(clip) {
@@ -822,6 +1041,13 @@ function App() {
822
  return (value) => setProfile((current) => ({ ...current, [key]: value }));
823
  }
824
 
 
 
 
 
 
 
 
825
  return (
826
  <main className="app-shell">
827
  <AppHeader
@@ -844,6 +1070,8 @@ function App() {
844
  onDelete={(clip) => patchClip(clip.id, { deleted: true })}
845
  onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })}
846
  onRegenerate={regenerateClip}
 
 
847
  />
848
  ) : (
849
  <div className="workspace-grid">
@@ -975,7 +1203,7 @@ function ProfileForm({
975
  value={profile.channel_description}
976
  onChange={setProfileValue("channel_description")}
977
  placeholder={t("channelDescriptionPlaceholder")}
978
- rows={4}
979
  />
980
 
981
  <div className="form-grid-two">
@@ -1241,23 +1469,14 @@ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegen
1241
  </span>
1242
  </div>
1243
 
1244
- <textarea
1245
- defaultValue={clip.subtitle_text}
1246
- aria-label={t("subtitles")}
1247
- rows={3}
1248
- onBlur={(event) => {
1249
- if (event.target.value !== clip.subtitle_text) {
1250
- onPatch(clip.id, { subtitle_text: event.target.value });
1251
- }
1252
- }}
1253
- />
1254
 
1255
  <div className="clip-actions">
1256
- <button type="button" title={t("openEditor")} onClick={() => onOpenEditor(clip)}>
1257
  <PanelRightOpen size={16} />
1258
  {t("openEditor")}
1259
  </button>
1260
- <button type="button" title={t("approve")} onClick={() => onApprove(clip)}>
1261
  <Check size={16} />
1262
  {clip.approved ? t("approved") : t("approve")}
1263
  </button>
@@ -1278,11 +1497,23 @@ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegen
1278
  );
1279
  }
1280
 
1281
- function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, onRegenerate }) {
 
 
 
 
 
 
 
 
 
 
 
1282
  const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
1283
- const cues = getSubtitleCues(clip, duration);
1284
  const metadataModel = clip.metadata?.model || "unknown";
1285
  const sourceKind = job?.source?.kind || "video";
 
1286
  const timelineDuration = Math.max(
1287
  clip.end_seconds,
1288
  ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
@@ -1332,6 +1563,29 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
1332
  </div>
1333
 
1334
  <div className="editor-grid">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1335
  <section className="panel editor-main">
1336
  <div className="panel-heading compact">
1337
  <div>
@@ -1344,6 +1598,7 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
1344
  </div>
1345
  <div className="editor-preview">
1346
  {clip.video_url ? <video controls src={`${API_BASE}${clip.video_url}`} /> : <Film size={44} />}
 
1347
  </div>
1348
 
1349
  <div className="range-editor">
@@ -1432,6 +1687,8 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
1432
  </div>
1433
  </div>
1434
 
 
 
1435
  <div className="subtitle-editor">
1436
  <div className="panel-heading compact">
1437
  <div>
@@ -1514,6 +1771,8 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
1514
  </button>
1515
  </div>
1516
 
 
 
1517
  <TranscriptMini transcript={job?.transcript || []} clip={clip} t={t} />
1518
  </aside>
1519
  </div>
@@ -1521,6 +1780,183 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
1521
  );
1522
  }
1523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1524
  function TranscriptMini({ transcript, clip, t }) {
1525
  const rows = transcript.filter(
1526
  (segment) => segment.end_seconds >= clip.start_seconds && segment.start_seconds <= clip.end_seconds
@@ -1616,16 +2052,8 @@ async function fetchJson(path, options) {
1616
  return response.json();
1617
  }
1618
 
1619
- function getSubtitleCues(clip, duration) {
1620
- const fromMetadata = clip.metadata?.subtitle_cues;
1621
- if (Array.isArray(fromMetadata) && fromMetadata.length > 0) {
1622
- return fromMetadata.map((cue) => ({
1623
- start_seconds: Number(cue.start_seconds || 0),
1624
- end_seconds: Number(cue.end_seconds || cue.start_seconds || 0),
1625
- text: cue.text || "",
1626
- }));
1627
- }
1628
- return splitSubtitleText(clip.subtitle_text || "").map((text, index, all) => {
1629
  const cueDuration = duration / Math.max(all.length, 1);
1630
  return {
1631
  start_seconds: roundTime(index * cueDuration),
@@ -1635,14 +2063,21 @@ function getSubtitleCues(clip, duration) {
1635
  });
1636
  }
1637
 
1638
- function splitSubtitleText(text) {
1639
  const clean = text.trim().replace(/\s+/g, " ");
1640
  if (!clean) return [""];
 
 
 
 
 
 
 
1641
  const words = clean.split(" ");
1642
  if (words.length <= 1) {
1643
  const chunks = [];
1644
- for (let index = 0; index < clean.length; index += 38) {
1645
- chunks.push(clean.slice(index, index + 38));
1646
  }
1647
  return chunks;
1648
  }
@@ -1651,7 +2086,7 @@ function splitSubtitleText(text) {
1651
  words.forEach((word) => {
1652
  const candidate = [...current, word].join(" ");
1653
  const punctuationBreak = current.length > 0 && /[,.!?;:]$/.test(current[current.length - 1]);
1654
- if (current.length > 0 && (candidate.length > 42 || current.length >= 7 || punctuationBreak)) {
1655
  chunks.push(current.join(" "));
1656
  current = [word];
1657
  } else {
@@ -1670,6 +2105,10 @@ function clamp(value, min, max) {
1670
  return Math.min(Math.max(value, min), max);
1671
  }
1672
 
 
 
 
 
1673
  function formatTime(value) {
1674
  const safeValue = Number.isFinite(Number(value)) ? Number(value) : 0;
1675
  const minutes = Math.floor(safeValue / 60);
 
50
  const CLIP_STYLES = ["informative", "funny", "dramatic", "educational", "commentary"];
51
  const LANGUAGE_OPTIONS = ["Thai", "English", "Japanese", "Chinese", "Korean", "Auto"];
52
  const PLATFORM_OPTIONS = ["tiktok", "youtube_shorts", "instagram_reels"];
53
+ const FONT_OPTIONS = ["Inter", "Noto Sans Thai", "Poppins", "Montserrat", "Arial", "Impact"];
54
+ const CUE_DENSITIES = ["word", "short", "medium", "long"];
55
+ const CAPTION_ANIMATIONS = ["none", "highlight", "pop", "bounce"];
56
+
57
+ const defaultCaptionStyle = {
58
+ fontFamily: "Inter",
59
+ fontSize: 38,
60
+ fillColor: "#ffffff",
61
+ strokeColor: "#080b12",
62
+ strokeWidth: 4,
63
+ position: 18,
64
+ cueDensity: "short",
65
+ animation: "highlight",
66
+ };
67
+
68
+ const captionPresets = {
69
+ clean: {
70
+ fontFamily: "Inter",
71
+ fontSize: 34,
72
+ fillColor: "#ffffff",
73
+ strokeColor: "#111827",
74
+ strokeWidth: 3,
75
+ position: 18,
76
+ cueDensity: "medium",
77
+ animation: "none",
78
+ },
79
+ bold: {
80
+ fontFamily: "Impact",
81
+ fontSize: 46,
82
+ fillColor: "#fef08a",
83
+ strokeColor: "#020617",
84
+ strokeWidth: 5,
85
+ position: 20,
86
+ cueDensity: "short",
87
+ animation: "pop",
88
+ },
89
+ karaoke: {
90
+ fontFamily: "Poppins",
91
+ fontSize: 40,
92
+ fillColor: "#ffffff",
93
+ strokeColor: "#020617",
94
+ strokeWidth: 4,
95
+ position: 22,
96
+ cueDensity: "word",
97
+ animation: "highlight",
98
+ },
99
+ };
100
 
101
  const defaultProfile = {
102
  niche: "education",
 
179
  clipRange: "Clip range",
180
  subtitleCues: "Subtitle cues",
181
  subtitleCueHelp: "Short cues read better on TikTok, Reels, and Shorts.",
182
+ timelineTracks: "Timeline tracks",
183
+ videoTrack: "Video",
184
+ subtitleTrack: "Subtitles",
185
+ audioTrack: "Audio",
186
+ toolSelect: "Select",
187
+ toolTrim: "Trim",
188
+ toolCaptions: "Captions",
189
+ toolStyle: "Style",
190
+ toolExport: "Export",
191
+ captionStyle: "Caption style",
192
+ captionPreset: "Preset",
193
+ presetClean: "Clean",
194
+ presetBold: "Bold",
195
+ presetKaraoke: "Karaoke",
196
+ font: "Font",
197
+ fontSize: "Size",
198
+ fillColor: "Fill",
199
+ strokeColor: "Outline",
200
+ strokeWidth: "Outline width",
201
+ captionPosition: "Caption height",
202
+ captionLength: "Caption length",
203
+ animation: "Animation",
204
+ density_word: "Word by word",
205
+ density_short: "Short phrases",
206
+ density_medium: "Medium phrases",
207
+ density_long: "Long lines",
208
+ animation_none: "None",
209
+ animation_highlight: "Color highlight",
210
+ animation_pop: "Pop",
211
+ animation_bounce: "Bounce",
212
  editorTools: "Editing tools",
213
  rangeStart: "Range start",
214
  rangeEnd: "Range end",
 
339
  clipRange: "ช่วงคลิป",
340
  subtitleCues: "จังหวะซับ",
341
  subtitleCueHelp: "ซับสั้น ๆ อ่านง่ายกว่าสำหรับ TikTok, Reels และ Shorts",
342
+ timelineTracks: "แทร็กตัดต่อ",
343
+ videoTrack: "วิดีโอ",
344
+ subtitleTrack: "ซับ",
345
+ audioTrack: "เสียง",
346
+ toolSelect: "เลือก",
347
+ toolTrim: "ตัด",
348
+ toolCaptions: "ซับ",
349
+ toolStyle: "สไตล์",
350
+ toolExport: "ส่งออก",
351
+ captionStyle: "สไตล์ซับ",
352
+ captionPreset: "พรีเซ็ต",
353
+ presetClean: "เรียบ",
354
+ presetBold: "เด่น",
355
+ presetKaraoke: "คาราโอเกะ",
356
+ font: "ฟอนต์",
357
+ fontSize: "ขนาด",
358
+ fillColor: "สีตัวอักษร",
359
+ strokeColor: "สีขอบ",
360
+ strokeWidth: "ความหนาขอบ",
361
+ captionPosition: "ตำแหน่งซับ",
362
+ captionLength: "ความยาวข้อความ",
363
+ animation: "อนิเมชัน",
364
+ density_word: "ทีละคำ",
365
+ density_short: "วลีสั้น",
366
+ density_medium: "วลีกลาง",
367
+ density_long: "บรรทัดยาว",
368
+ animation_none: "ไม่มี",
369
+ animation_highlight: "ไฮไลต์สี",
370
+ animation_pop: "เด้งเข้า",
371
+ animation_bounce: "เด้งตามจังหวะ",
372
  editorTools: "เครื่องมือตัดต่อ",
373
  rangeStart: "ตำแหน่งเริ่ม",
374
  rangeEnd: "ตำแหน่งจบ",
 
491
  subtitles: "字幕",
492
  subtitleCues: "字幕キュー",
493
  subtitleCueHelp: "短い字幕キューのほうがTikTok、Reels、Shortsで読みやすくなります。",
494
+ timelineTracks: "タイムライントラック",
495
+ videoTrack: "動画",
496
+ subtitleTrack: "字幕",
497
+ audioTrack: "音声",
498
+ toolSelect: "選択",
499
+ toolTrim: "トリム",
500
+ toolCaptions: "字幕",
501
+ toolStyle: "スタイル",
502
+ toolExport: "書き出し",
503
+ captionStyle: "字幕スタイル",
504
+ captionPreset: "プリセット",
505
+ presetClean: "クリーン",
506
+ presetBold: "太字",
507
+ presetKaraoke: "カラオケ",
508
+ font: "フォント",
509
+ fontSize: "サイズ",
510
+ fillColor: "塗り",
511
+ strokeColor: "縁取り",
512
+ strokeWidth: "縁取り幅",
513
+ captionPosition: "字幕位置",
514
+ captionLength: "字幕の長さ",
515
+ animation: "アニメーション",
516
+ density_word: "単語ごと",
517
+ density_short: "短いフレーズ",
518
+ density_medium: "中くらい",
519
+ density_long: "長い行",
520
+ animation_none: "なし",
521
+ animation_highlight: "色ハイライト",
522
+ animation_pop: "ポップ",
523
+ animation_bounce: "バウンス",
524
  editorTools: "編集ツール",
525
  rangeStart: "開始位置",
526
  rangeEnd: "終了位置",
 
648
  subtitles: "字幕",
649
  subtitleCues: "字幕片段",
650
  subtitleCueHelp: "短字幕片段更适合 TikTok、Reels 和 Shorts。",
651
+ timelineTracks: "时间线轨道",
652
+ videoTrack: "视频",
653
+ subtitleTrack: "字幕",
654
+ audioTrack: "音频",
655
+ toolSelect: "选择",
656
+ toolTrim: "裁剪",
657
+ toolCaptions: "字幕",
658
+ toolStyle: "样式",
659
+ toolExport: "导出",
660
+ captionStyle: "字幕样式",
661
+ captionPreset: "预设",
662
+ presetClean: "简洁",
663
+ presetBold: "醒目",
664
+ presetKaraoke: "卡拉 OK",
665
+ font: "字体",
666
+ fontSize: "大小",
667
+ fillColor: "填充",
668
+ strokeColor: "描边",
669
+ strokeWidth: "描边宽度",
670
+ captionPosition: "字幕高度",
671
+ captionLength: "字幕长度",
672
+ animation: "动画",
673
+ density_word: "逐词",
674
+ density_short: "短词组",
675
+ density_medium: "中等词组",
676
+ density_long: "长句",
677
+ animation_none: "无",
678
+ animation_highlight: "颜色高亮",
679
+ animation_pop: "弹出",
680
+ animation_bounce: "弹跳",
681
  editorTools: "编辑工具",
682
  rangeStart: "开始位置",
683
  rangeEnd: "结束位置",
 
806
  subtitles: "자막",
807
  subtitleCues: "자막 큐",
808
  subtitleCueHelp: "짧은 자막 큐가 TikTok, Reels, Shorts에서 더 읽기 쉽습니다.",
809
+ timelineTracks: "타임라인 트랙",
810
+ videoTrack: "비디오",
811
+ subtitleTrack: "자막",
812
+ audioTrack: "오디오",
813
+ toolSelect: "선택",
814
+ toolTrim: "자르기",
815
+ toolCaptions: "자막",
816
+ toolStyle: "스타일",
817
+ toolExport: "내보내기",
818
+ captionStyle: "자막 스타일",
819
+ captionPreset: "프리셋",
820
+ presetClean: "깔끔",
821
+ presetBold: "강조",
822
+ presetKaraoke: "노래방",
823
+ font: "글꼴",
824
+ fontSize: "크기",
825
+ fillColor: "채우기",
826
+ strokeColor: "외곽선",
827
+ strokeWidth: "외곽선 두께",
828
+ captionPosition: "자막 높이",
829
+ captionLength: "자막 길이",
830
+ animation: "애니메이션",
831
+ density_word: "단어별",
832
+ density_short: "짧은 구문",
833
+ density_medium: "중간 구문",
834
+ density_long: "긴 줄",
835
+ animation_none: "없음",
836
+ animation_highlight: "색상 강조",
837
+ animation_pop: "팝",
838
+ animation_bounce: "바운스",
839
  editorTools: "편집 도구",
840
  rangeStart: "시작 위치",
841
  rangeEnd: "종료 위치",
 
911
  const [error, setError] = useState("");
912
  const [isSubmitting, setIsSubmitting] = useState(false);
913
  const [editorClipId, setEditorClipId] = useState(null);
914
+ const [captionStyles, setCaptionStyles] = useState(() => {
915
+ try {
916
+ return JSON.parse(localStorage.getItem("elevenclip.captionStyles") || "{}");
917
+ } catch {
918
+ return {};
919
+ }
920
+ });
921
  const [language, setLanguage] = useState(() => localStorage.getItem("elevenclip.language") || "en");
922
  const [theme, setTheme] = useState(() => {
923
  const saved = localStorage.getItem("elevenclip.theme");
 
940
  [job?.clips]
941
  );
942
  const editorClip = activeClips.find((clip) => clip.id === editorClipId);
943
+ const editorCaptionStyle = editorClip
944
+ ? { ...defaultCaptionStyle, ...(captionStyles[editorClip.id] || {}) }
945
+ : defaultCaptionStyle;
946
 
947
  useEffect(() => {
948
  document.documentElement.dataset.theme = theme;
 
953
  localStorage.setItem("elevenclip.language", language);
954
  }, [language]);
955
 
956
+ useEffect(() => {
957
+ localStorage.setItem("elevenclip.captionStyles", JSON.stringify(captionStyles));
958
+ }, [captionStyles]);
959
+
960
  useEffect(() => {
961
  fetchJson("/health").then(setHealth).catch(() => setHealth(null));
962
  }, []);
 
1002
  }
1003
 
1004
  async function patchClip(clipId, patch) {
 
 
 
 
 
1005
  setJob((current) => ({
1006
  ...current,
1007
+ clips: current.clips.map((clip) => (clip.id === clipId ? { ...clip, ...patch } : clip)),
1008
  }));
1009
+ try {
1010
+ const nextClip = await fetchJson(`/api/jobs/${job.id}/clips/${clipId}`, {
1011
+ method: "PATCH",
1012
+ headers: { "Content-Type": "application/json" },
1013
+ body: JSON.stringify(patch),
1014
+ });
1015
+ setJob((current) => ({
1016
+ ...current,
1017
+ clips: current.clips.map((clip) => (clip.id === clipId ? nextClip : clip)),
1018
+ }));
1019
+ } catch (exc) {
1020
+ setError(exc.message);
1021
+ }
1022
  }
1023
 
1024
  async function regenerateClip(clip) {
 
1041
  return (value) => setProfile((current) => ({ ...current, [key]: value }));
1042
  }
1043
 
1044
+ function updateCaptionStyle(clipId, patch) {
1045
+ setCaptionStyles((current) => ({
1046
+ ...current,
1047
+ [clipId]: { ...defaultCaptionStyle, ...(current[clipId] || {}), ...patch },
1048
+ }));
1049
+ }
1050
+
1051
  return (
1052
  <main className="app-shell">
1053
  <AppHeader
 
1070
  onDelete={(clip) => patchClip(clip.id, { deleted: true })}
1071
  onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })}
1072
  onRegenerate={regenerateClip}
1073
+ captionStyle={editorCaptionStyle}
1074
+ onCaptionStyleChange={(patch) => updateCaptionStyle(editorClip.id, patch)}
1075
  />
1076
  ) : (
1077
  <div className="workspace-grid">
 
1203
  value={profile.channel_description}
1204
  onChange={setProfileValue("channel_description")}
1205
  placeholder={t("channelDescriptionPlaceholder")}
1206
+ rows={3}
1207
  />
1208
 
1209
  <div className="form-grid-two">
 
1469
  </span>
1470
  </div>
1471
 
1472
+ <p className="subtitle-snippet">{clip.subtitle_text}</p>
 
 
 
 
 
 
 
 
 
1473
 
1474
  <div className="clip-actions">
1475
+ <button className="action-primary" type="button" title={t("openEditor")} onClick={() => onOpenEditor(clip)}>
1476
  <PanelRightOpen size={16} />
1477
  {t("openEditor")}
1478
  </button>
1479
+ <button className="action-approve" type="button" title={t("approve")} onClick={() => onApprove(clip)}>
1480
  <Check size={16} />
1481
  {clip.approved ? t("approved") : t("approve")}
1482
  </button>
 
1497
  );
1498
  }
1499
 
1500
+ function ClipEditorPage({
1501
+ clip,
1502
+ job,
1503
+ t,
1504
+ onBack,
1505
+ onPatch,
1506
+ onDelete,
1507
+ onApprove,
1508
+ onRegenerate,
1509
+ captionStyle,
1510
+ onCaptionStyleChange,
1511
+ }) {
1512
  const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
1513
+ const cues = getSubtitleCues(clip, duration, captionStyle);
1514
  const metadataModel = clip.metadata?.model || "unknown";
1515
  const sourceKind = job?.source?.kind || "video";
1516
+ const activeCue = cues[0]?.text || clip.subtitle_text || clip.title;
1517
  const timelineDuration = Math.max(
1518
  clip.end_seconds,
1519
  ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
 
1563
  </div>
1564
 
1565
  <div className="editor-grid">
1566
+ <aside className="tool-rail" aria-label={t("editorTools")}>
1567
+ <button type="button" className="active" title={t("toolSelect")}>
1568
+ <PanelRightOpen size={18} />
1569
+ <span>{t("toolSelect")}</span>
1570
+ </button>
1571
+ <button type="button" title={t("toolTrim")}>
1572
+ <Scissors size={18} />
1573
+ <span>{t("toolTrim")}</span>
1574
+ </button>
1575
+ <button type="button" title={t("toolCaptions")}>
1576
+ <Captions size={18} />
1577
+ <span>{t("toolCaptions")}</span>
1578
+ </button>
1579
+ <button type="button" title={t("toolStyle")}>
1580
+ <SlidersHorizontal size={18} />
1581
+ <span>{t("toolStyle")}</span>
1582
+ </button>
1583
+ <button type="button" title={t("toolExport")}>
1584
+ <Download size={18} />
1585
+ <span>{t("toolExport")}</span>
1586
+ </button>
1587
+ </aside>
1588
+
1589
  <section className="panel editor-main">
1590
  <div className="panel-heading compact">
1591
  <div>
 
1598
  </div>
1599
  <div className="editor-preview">
1600
  {clip.video_url ? <video controls src={`${API_BASE}${clip.video_url}`} /> : <Film size={44} />}
1601
+ <CaptionPreview text={activeCue} settings={captionStyle} />
1602
  </div>
1603
 
1604
  <div className="range-editor">
 
1687
  </div>
1688
  </div>
1689
 
1690
+ <TimelineTracks clip={clip} cues={cues} duration={timelineDuration} t={t} />
1691
+
1692
  <div className="subtitle-editor">
1693
  <div className="panel-heading compact">
1694
  <div>
 
1771
  </button>
1772
  </div>
1773
 
1774
+ <CaptionStylePanel t={t} settings={captionStyle} onChange={onCaptionStyleChange} />
1775
+
1776
  <TranscriptMini transcript={job?.transcript || []} clip={clip} t={t} />
1777
  </aside>
1778
  </div>
 
1780
  );
1781
  }
1782
 
1783
+ function CaptionPreview({ text, settings }) {
1784
+ const words = text.split(/\s+/).filter(Boolean);
1785
+ const litCount = Math.max(1, Math.ceil(words.length * 0.45));
1786
+ const style = {
1787
+ bottom: `${settings.position}%`,
1788
+ color: settings.fillColor,
1789
+ fontFamily: `"${settings.fontFamily}", Inter, ui-sans-serif, system-ui, sans-serif`,
1790
+ fontSize: `${settings.fontSize}px`,
1791
+ WebkitTextStroke: `${settings.strokeWidth}px ${settings.strokeColor}`,
1792
+ textShadow: `0 3px 12px ${settings.strokeColor}`,
1793
+ };
1794
+
1795
+ return (
1796
+ <div className={`caption-preview ${settings.animation}`} style={style}>
1797
+ {settings.animation === "highlight"
1798
+ ? words.map((word, index) => (
1799
+ <span key={`${word}-${index}`} className={index < litCount ? "lit" : ""}>
1800
+ {word}
1801
+ {index < words.length - 1 ? " " : ""}
1802
+ </span>
1803
+ ))
1804
+ : text}
1805
+ </div>
1806
+ );
1807
+ }
1808
+
1809
+ function TimelineTracks({ clip, cues, duration, t }) {
1810
+ const clipLeft = clamp((clip.start_seconds / duration) * 100, 0, 100);
1811
+ const clipWidth = clamp(((clip.end_seconds - clip.start_seconds) / duration) * 100, 4, 100);
1812
+ const subtitleItems = cues.slice(0, 8);
1813
+ return (
1814
+ <div className="timeline-workbench">
1815
+ <div className="panel-heading compact">
1816
+ <div>
1817
+ <h2>{t("timelineTracks")}</h2>
1818
+ <p>
1819
+ {formatTime(clip.start_seconds)} - {formatTime(clip.end_seconds)}
1820
+ </p>
1821
+ </div>
1822
+ <Film size={18} />
1823
+ </div>
1824
+ <div className="track-stack">
1825
+ <TrackRow label={t("videoTrack")}>
1826
+ <div className="track-clip video" style={{ left: `${clipLeft}%`, width: `${clipWidth}%` }}>
1827
+ {clip.title}
1828
+ </div>
1829
+ </TrackRow>
1830
+ <TrackRow label={t("subtitleTrack")}>
1831
+ {subtitleItems.map((cue, index) => (
1832
+ <div
1833
+ className="track-clip subtitle"
1834
+ key={`${cue.start_seconds}-${index}`}
1835
+ style={{
1836
+ left: `${clamp(((clip.start_seconds + cue.start_seconds) / duration) * 100, 0, 100)}%`,
1837
+ width: `${clamp(((cue.end_seconds - cue.start_seconds) / duration) * 100, 3, 45)}%`,
1838
+ }}
1839
+ >
1840
+ {cue.text}
1841
+ </div>
1842
+ ))}
1843
+ </TrackRow>
1844
+ <TrackRow label={t("audioTrack")}>
1845
+ <div className="waveform">
1846
+ {Array.from({ length: 42 }).map((_, index) => (
1847
+ <span key={index} style={{ height: `${18 + ((index * 17) % 34)}%` }} />
1848
+ ))}
1849
+ </div>
1850
+ </TrackRow>
1851
+ </div>
1852
+ </div>
1853
+ );
1854
+ }
1855
+
1856
+ function TrackRow({ label, children }) {
1857
+ return (
1858
+ <div className="track-row">
1859
+ <span>{label}</span>
1860
+ <div className="track-lane">{children}</div>
1861
+ </div>
1862
+ );
1863
+ }
1864
+
1865
+ function CaptionStylePanel({ t, settings, onChange }) {
1866
+ return (
1867
+ <section className="caption-style-panel">
1868
+ <div className="panel-heading compact">
1869
+ <div>
1870
+ <h2>{t("captionStyle")}</h2>
1871
+ </div>
1872
+ <Captions size={18} />
1873
+ </div>
1874
+
1875
+ <div className="preset-row">
1876
+ {Object.entries(captionPresets).map(([key, preset]) => (
1877
+ <button type="button" key={key} onClick={() => onChange(preset)}>
1878
+ {t(`preset${capitalize(key)}`)}
1879
+ </button>
1880
+ ))}
1881
+ </div>
1882
+
1883
+ <div className="style-grid">
1884
+ <SelectField
1885
+ label={t("font")}
1886
+ value={settings.fontFamily}
1887
+ onChange={(value) => onChange({ fontFamily: value })}
1888
+ options={FONT_OPTIONS.map((value) => ({ value, label: value }))}
1889
+ />
1890
+ <SelectField
1891
+ label={t("captionLength")}
1892
+ value={settings.cueDensity}
1893
+ onChange={(value) => onChange({ cueDensity: value })}
1894
+ options={CUE_DENSITIES.map((value) => ({ value, label: t(`density_${value}`) }))}
1895
+ />
1896
+ <SelectField
1897
+ label={t("animation")}
1898
+ value={settings.animation}
1899
+ onChange={(value) => onChange({ animation: value })}
1900
+ options={CAPTION_ANIMATIONS.map((value) => ({ value, label: t(`animation_${value}`) }))}
1901
+ />
1902
+ </div>
1903
+
1904
+ <div className="color-grid">
1905
+ <ColorField label={t("fillColor")} value={settings.fillColor} onChange={(fillColor) => onChange({ fillColor })} />
1906
+ <ColorField
1907
+ label={t("strokeColor")}
1908
+ value={settings.strokeColor}
1909
+ onChange={(strokeColor) => onChange({ strokeColor })}
1910
+ />
1911
+ </div>
1912
+
1913
+ <RangeControl label={t("fontSize")} value={settings.fontSize} min={24} max={64} onChange={(fontSize) => onChange({ fontSize })} />
1914
+ <RangeControl
1915
+ label={t("strokeWidth")}
1916
+ value={settings.strokeWidth}
1917
+ min={0}
1918
+ max={8}
1919
+ onChange={(strokeWidth) => onChange({ strokeWidth })}
1920
+ />
1921
+ <RangeControl
1922
+ label={t("captionPosition")}
1923
+ value={settings.position}
1924
+ min={8}
1925
+ max={38}
1926
+ onChange={(position) => onChange({ position })}
1927
+ />
1928
+ </section>
1929
+ );
1930
+ }
1931
+
1932
+ function ColorField({ label, value, onChange }) {
1933
+ return (
1934
+ <label className="color-field">
1935
+ <span>{label}</span>
1936
+ <input type="color" value={value} onChange={(event) => onChange(event.target.value)} />
1937
+ </label>
1938
+ );
1939
+ }
1940
+
1941
+ function RangeControl({ label, value, min, max, onChange }) {
1942
+ return (
1943
+ <label className="range-control">
1944
+ <span>
1945
+ {label}
1946
+ <strong>{value}</strong>
1947
+ </span>
1948
+ <input
1949
+ type="range"
1950
+ min={min}
1951
+ max={max}
1952
+ step="1"
1953
+ value={value}
1954
+ onChange={(event) => onChange(Number(event.target.value))}
1955
+ />
1956
+ </label>
1957
+ );
1958
+ }
1959
+
1960
  function TranscriptMini({ transcript, clip, t }) {
1961
  const rows = transcript.filter(
1962
  (segment) => segment.end_seconds >= clip.start_seconds && segment.start_seconds <= clip.end_seconds
 
2052
  return response.json();
2053
  }
2054
 
2055
+ function getSubtitleCues(clip, duration, settings = defaultCaptionStyle) {
2056
+ return splitSubtitleText(clip.subtitle_text || "", settings.cueDensity).map((text, index, all) => {
 
 
 
 
 
 
 
 
2057
  const cueDuration = duration / Math.max(all.length, 1);
2058
  return {
2059
  start_seconds: roundTime(index * cueDuration),
 
2063
  });
2064
  }
2065
 
2066
+ function splitSubtitleText(text, density = "short") {
2067
  const clean = text.trim().replace(/\s+/g, " ");
2068
  if (!clean) return [""];
2069
+ const limits = {
2070
+ word: { words: 1, chars: 18 },
2071
+ short: { words: 4, chars: 30 },
2072
+ medium: { words: 7, chars: 46 },
2073
+ long: { words: 12, chars: 78 },
2074
+ };
2075
+ const limit = limits[density] || limits.short;
2076
  const words = clean.split(" ");
2077
  if (words.length <= 1) {
2078
  const chunks = [];
2079
+ for (let index = 0; index < clean.length; index += limit.chars) {
2080
+ chunks.push(clean.slice(index, index + limit.chars));
2081
  }
2082
  return chunks;
2083
  }
 
2086
  words.forEach((word) => {
2087
  const candidate = [...current, word].join(" ");
2088
  const punctuationBreak = current.length > 0 && /[,.!?;:]$/.test(current[current.length - 1]);
2089
+ if (current.length > 0 && (candidate.length > limit.chars || current.length >= limit.words || punctuationBreak)) {
2090
  chunks.push(current.join(" "));
2091
  current = [word];
2092
  } else {
 
2105
  return Math.min(Math.max(value, min), max);
2106
  }
2107
 
2108
+ function capitalize(value) {
2109
+ return value.charAt(0).toUpperCase() + value.slice(1);
2110
+ }
2111
+
2112
  function formatTime(value) {
2113
  const safeValue = Number.isFinite(Number(value)) ? Number(value) : 0;
2114
  const minutes = Math.floor(safeValue / 60);
frontend/src/styles.css CHANGED
@@ -249,10 +249,10 @@ button:disabled {
249
 
250
  .workspace-grid {
251
  display: grid;
252
- grid-template-columns: minmax(320px, 400px) minmax(0, 1fr);
253
- gap: 18px;
254
  align-items: start;
255
- padding: 20px clamp(16px, 4vw, 44px) 44px;
256
  }
257
 
258
  .center-column,
@@ -280,10 +280,8 @@ button:disabled {
280
  }
281
 
282
  .input-panel {
283
- position: sticky;
284
- top: 96px;
285
- max-height: calc(100vh - 120px);
286
- overflow: auto;
287
  }
288
 
289
  .results-column {
@@ -555,7 +553,7 @@ textarea:focus,
555
 
556
  .clip-grid {
557
  display: grid;
558
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
559
  gap: 14px;
560
  align-items: start;
561
  }
@@ -565,12 +563,13 @@ textarea:focus,
565
  border: 1px solid var(--border);
566
  border-radius: var(--radius);
567
  background: var(--surface-muted);
 
568
  }
569
 
570
  .clip-video {
571
  display: grid;
572
  aspect-ratio: 9 / 16;
573
- max-height: 340px;
574
  place-items: center;
575
  background: #050b16;
576
  color: white;
@@ -639,13 +638,42 @@ textarea:focus,
639
  }
640
 
641
  .clip-actions {
642
- flex-wrap: wrap;
 
643
  gap: 8px;
644
  }
645
 
646
  .clip-actions button,
647
  .download-button {
 
648
  padding: 0 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  }
650
 
651
  .download-button {
@@ -661,8 +689,8 @@ textarea:focus,
661
 
662
  .editor-shell {
663
  display: grid;
664
- gap: 18px;
665
- padding: 20px clamp(16px, 4vw, 44px) 44px;
666
  }
667
 
668
  .editor-topbar {
@@ -685,11 +713,43 @@ textarea:focus,
685
 
686
  .editor-grid {
687
  display: grid;
688
- grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
689
- gap: 18px;
690
  align-items: start;
691
  }
692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  .editor-main,
694
  .inspector-panel {
695
  display: grid;
@@ -698,8 +758,10 @@ textarea:focus,
698
 
699
  .editor-preview {
700
  display: grid;
 
701
  min-height: 520px;
702
  max-height: 68vh;
 
703
  place-items: center;
704
  border-radius: var(--radius);
705
  background: #050b16;
@@ -713,8 +775,54 @@ textarea:focus,
713
  object-fit: contain;
714
  }
715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  .range-editor,
717
- .subtitle-editor {
 
 
718
  display: grid;
719
  gap: 12px;
720
  border: 1px solid var(--border);
@@ -854,6 +962,81 @@ textarea:focus,
854
  font-variant-numeric: tabular-nums;
855
  }
856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
  .cue-list {
858
  display: grid;
859
  gap: 10px;
@@ -900,6 +1083,69 @@ textarea:focus,
900
  color: var(--danger);
901
  }
902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  .mini-transcript {
904
  display: grid;
905
  gap: 10px;
@@ -937,6 +1183,14 @@ textarea:focus,
937
  grid-template-columns: minmax(290px, 340px) minmax(0, 1fr);
938
  }
939
 
 
 
 
 
 
 
 
 
940
  .results-column {
941
  grid-column: 1 / -1;
942
  }
@@ -958,6 +1212,16 @@ textarea:focus,
958
  grid-template-columns: 1fr;
959
  }
960
 
 
 
 
 
 
 
 
 
 
 
961
  .input-panel {
962
  position: static;
963
  max-height: none;
@@ -981,10 +1245,16 @@ textarea:focus,
981
  .range-sliders,
982
  .timeline,
983
  .cue-row,
 
984
  .transcript-row {
985
  grid-template-columns: 1fr;
986
  }
987
 
 
 
 
 
 
988
  .progress-percent {
989
  font-size: 1.5rem;
990
  }
 
249
 
250
  .workspace-grid {
251
  display: grid;
252
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
253
+ gap: 14px;
254
  align-items: start;
255
+ padding: 18px clamp(16px, 3vw, 40px) 36px;
256
  }
257
 
258
  .center-column,
 
280
  }
281
 
282
  .input-panel {
283
+ position: static;
284
+ overflow: visible;
 
 
285
  }
286
 
287
  .results-column {
 
553
 
554
  .clip-grid {
555
  display: grid;
556
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
557
  gap: 14px;
558
  align-items: start;
559
  }
 
563
  border: 1px solid var(--border);
564
  border-radius: var(--radius);
565
  background: var(--surface-muted);
566
+ min-width: 0;
567
  }
568
 
569
  .clip-video {
570
  display: grid;
571
  aspect-ratio: 9 / 16;
572
+ max-height: 300px;
573
  place-items: center;
574
  background: #050b16;
575
  color: white;
 
638
  }
639
 
640
  .clip-actions {
641
+ display: grid;
642
+ grid-template-columns: minmax(0, 1fr) auto auto auto auto;
643
  gap: 8px;
644
  }
645
 
646
  .clip-actions button,
647
  .download-button {
648
+ min-width: 38px;
649
  padding: 0 10px;
650
+ white-space: nowrap;
651
+ }
652
+
653
+ .clip-actions button:not(.action-primary):not(.action-approve),
654
+ .download-button {
655
+ width: 38px;
656
+ padding: 0;
657
+ }
658
+
659
+ .clip-actions .action-approve {
660
+ min-width: 92px;
661
+ }
662
+
663
+ .subtitle-snippet {
664
+ display: -webkit-box;
665
+ min-height: 54px;
666
+ margin: 0;
667
+ overflow: hidden;
668
+ -webkit-box-orient: vertical;
669
+ -webkit-line-clamp: 3;
670
+ border: 1px solid var(--border);
671
+ border-radius: 7px;
672
+ background: var(--surface);
673
+ color: var(--text);
674
+ font-size: 0.84rem;
675
+ line-height: 1.45;
676
+ padding: 10px;
677
  }
678
 
679
  .download-button {
 
689
 
690
  .editor-shell {
691
  display: grid;
692
+ gap: 14px;
693
+ padding: 18px clamp(16px, 3vw, 40px) 32px;
694
  }
695
 
696
  .editor-topbar {
 
713
 
714
  .editor-grid {
715
  display: grid;
716
+ grid-template-columns: 74px minmax(0, 1fr) minmax(330px, 420px);
717
+ gap: 14px;
718
  align-items: start;
719
  }
720
 
721
+ .tool-rail {
722
+ position: sticky;
723
+ top: 96px;
724
+ display: grid;
725
+ gap: 8px;
726
+ border: 1px solid var(--border);
727
+ border-radius: var(--radius);
728
+ background: color-mix(in srgb, var(--surface) 96%, transparent);
729
+ padding: 8px;
730
+ box-shadow: var(--shadow);
731
+ }
732
+
733
+ .tool-rail button {
734
+ display: grid;
735
+ min-height: 58px;
736
+ place-items: center;
737
+ gap: 4px;
738
+ border: 1px solid transparent;
739
+ border-radius: 7px;
740
+ background: transparent;
741
+ color: var(--text-muted);
742
+ font-size: 0.68rem;
743
+ font-weight: 800;
744
+ }
745
+
746
+ .tool-rail button.active,
747
+ .tool-rail button:hover {
748
+ border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
749
+ background: var(--primary-soft);
750
+ color: var(--primary-strong);
751
+ }
752
+
753
  .editor-main,
754
  .inspector-panel {
755
  display: grid;
 
758
 
759
  .editor-preview {
760
  display: grid;
761
+ position: relative;
762
  min-height: 520px;
763
  max-height: 68vh;
764
+ overflow: hidden;
765
  place-items: center;
766
  border-radius: var(--radius);
767
  background: #050b16;
 
775
  object-fit: contain;
776
  }
777
 
778
+ .caption-preview {
779
+ position: absolute;
780
+ left: 50%;
781
+ width: min(82%, 760px);
782
+ transform: translateX(-50%);
783
+ color: white;
784
+ font-weight: 900;
785
+ letter-spacing: 0;
786
+ line-height: 1.08;
787
+ text-align: center;
788
+ text-transform: none;
789
+ pointer-events: none;
790
+ }
791
+
792
+ .caption-preview .lit {
793
+ color: var(--accent);
794
+ }
795
+
796
+ .caption-preview.pop {
797
+ animation: caption-pop 900ms ease-in-out infinite alternate;
798
+ }
799
+
800
+ .caption-preview.bounce {
801
+ animation: caption-bounce 760ms ease-in-out infinite alternate;
802
+ }
803
+
804
+ @keyframes caption-pop {
805
+ from {
806
+ transform: translateX(-50%) scale(0.98);
807
+ }
808
+ to {
809
+ transform: translateX(-50%) scale(1.04);
810
+ }
811
+ }
812
+
813
+ @keyframes caption-bounce {
814
+ from {
815
+ transform: translateX(-50%) translateY(0);
816
+ }
817
+ to {
818
+ transform: translateX(-50%) translateY(-8px);
819
+ }
820
+ }
821
+
822
  .range-editor,
823
+ .subtitle-editor,
824
+ .timeline-workbench,
825
+ .caption-style-panel {
826
  display: grid;
827
  gap: 12px;
828
  border: 1px solid var(--border);
 
962
  font-variant-numeric: tabular-nums;
963
  }
964
 
965
+ .track-stack {
966
+ display: grid;
967
+ gap: 8px;
968
+ }
969
+
970
+ .track-row {
971
+ display: grid;
972
+ grid-template-columns: 92px minmax(0, 1fr);
973
+ gap: 10px;
974
+ align-items: center;
975
+ }
976
+
977
+ .track-row > span {
978
+ color: var(--text-muted);
979
+ font-size: 0.75rem;
980
+ font-weight: 850;
981
+ }
982
+
983
+ .track-lane {
984
+ position: relative;
985
+ min-height: 42px;
986
+ overflow: hidden;
987
+ border: 1px solid var(--border);
988
+ border-radius: 7px;
989
+ background:
990
+ repeating-linear-gradient(
991
+ 90deg,
992
+ transparent 0,
993
+ transparent 11.8%,
994
+ color-mix(in srgb, var(--border) 70%, transparent) 12%,
995
+ transparent 12.2%
996
+ ),
997
+ var(--surface);
998
+ }
999
+
1000
+ .track-clip {
1001
+ position: absolute;
1002
+ top: 7px;
1003
+ bottom: 7px;
1004
+ display: flex;
1005
+ min-width: 54px;
1006
+ align-items: center;
1007
+ overflow: hidden;
1008
+ border-radius: 6px;
1009
+ color: #042f2e;
1010
+ font-size: 0.72rem;
1011
+ font-weight: 850;
1012
+ padding: 0 8px;
1013
+ text-overflow: ellipsis;
1014
+ white-space: nowrap;
1015
+ }
1016
+
1017
+ .track-clip.video {
1018
+ background: linear-gradient(90deg, #5eead4, #22c55e);
1019
+ }
1020
+
1021
+ .track-clip.subtitle {
1022
+ background: #fde68a;
1023
+ color: #422006;
1024
+ }
1025
+
1026
+ .waveform {
1027
+ position: absolute;
1028
+ inset: 8px 10px;
1029
+ display: flex;
1030
+ align-items: center;
1031
+ gap: 4px;
1032
+ }
1033
+
1034
+ .waveform span {
1035
+ width: 4px;
1036
+ border-radius: 999px;
1037
+ background: color-mix(in srgb, var(--primary) 70%, var(--text-soft));
1038
+ }
1039
+
1040
  .cue-list {
1041
  display: grid;
1042
  gap: 10px;
 
1083
  color: var(--danger);
1084
  }
1085
 
1086
+ .caption-style-panel {
1087
+ background: color-mix(in srgb, var(--surface-muted) 92%, var(--surface));
1088
+ }
1089
+
1090
+ .preset-row {
1091
+ display: grid;
1092
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1093
+ gap: 8px;
1094
+ }
1095
+
1096
+ .preset-row button {
1097
+ min-height: 36px;
1098
+ border: 1px solid var(--border);
1099
+ border-radius: 7px;
1100
+ background: var(--surface);
1101
+ color: var(--text);
1102
+ font-weight: 800;
1103
+ }
1104
+
1105
+ .style-grid,
1106
+ .color-grid {
1107
+ display: grid;
1108
+ gap: 10px;
1109
+ }
1110
+
1111
+ .color-grid {
1112
+ grid-template-columns: 1fr 1fr;
1113
+ }
1114
+
1115
+ .color-field,
1116
+ .range-control {
1117
+ display: grid;
1118
+ gap: 7px;
1119
+ }
1120
+
1121
+ .color-field span,
1122
+ .range-control span {
1123
+ display: flex;
1124
+ justify-content: space-between;
1125
+ color: var(--text-muted);
1126
+ font-size: 0.76rem;
1127
+ font-weight: 800;
1128
+ }
1129
+
1130
+ .range-control strong {
1131
+ color: var(--text);
1132
+ font-variant-numeric: tabular-nums;
1133
+ }
1134
+
1135
+ .color-field input {
1136
+ width: 100%;
1137
+ height: 38px;
1138
+ border: 1px solid var(--border);
1139
+ border-radius: 7px;
1140
+ background: var(--surface);
1141
+ padding: 4px;
1142
+ }
1143
+
1144
+ .range-control input {
1145
+ width: 100%;
1146
+ accent-color: var(--primary);
1147
+ }
1148
+
1149
  .mini-transcript {
1150
  display: grid;
1151
  gap: 10px;
 
1183
  grid-template-columns: minmax(290px, 340px) minmax(0, 1fr);
1184
  }
1185
 
1186
+ .editor-grid {
1187
+ grid-template-columns: 64px minmax(0, 1fr);
1188
+ }
1189
+
1190
+ .inspector-panel {
1191
+ grid-column: 1 / -1;
1192
+ }
1193
+
1194
  .results-column {
1195
  grid-column: 1 / -1;
1196
  }
 
1212
  grid-template-columns: 1fr;
1213
  }
1214
 
1215
+ .tool-rail {
1216
+ position: static;
1217
+ display: flex;
1218
+ overflow-x: auto;
1219
+ }
1220
+
1221
+ .tool-rail button {
1222
+ min-width: 72px;
1223
+ }
1224
+
1225
  .input-panel {
1226
  position: static;
1227
  max-height: none;
 
1245
  .range-sliders,
1246
  .timeline,
1247
  .cue-row,
1248
+ .track-row,
1249
  .transcript-row {
1250
  grid-template-columns: 1fr;
1251
  }
1252
 
1253
+ .color-grid,
1254
+ .preset-row {
1255
+ grid-template-columns: 1fr;
1256
+ }
1257
+
1258
  .progress-percent {
1259
  font-size: 1.5rem;
1260
  }