JakgritB Claude Opus 4.7 commited on
Commit
f9d069e
·
1 Parent(s): 76e8eb8

feat(frontend): rewrite editor as Premiere-style NLE + fix card overflow

Browse files

Replace the two-column editor with a 4-panel NLE shell that frames the AI
as if it were operating Premiere Pro on the user's behalf:

- Media Bin (left): all clips in the job, click to switch the editor target
- Preview Stage (center top): video + draggable caption overlay
positionable anywhere on the canvas via drag (captionStyle.x/y)
- Timeline (center bottom): ruler with playhead, V1 with drag-to-trim
edge handles + drag-to-move body, T1 caption blocks (clickable to
select), A1 deterministic faux-waveform
- AI Assistant (right top): clip.reason as the AI's explanation + quick
actions (regenerate, tighten to 30s, fit to 60s, delete)
- Inspector (right bottom): score/status/source/model + active cue
textarea + caption style controls

Dashboard fix: split .clip-actions into a primary CTA row + flex-wrap
icon row so action buttons no longer overflow the card on narrow grids.
.clip-grid bumped to minmax(280px, 1fr).

NLE translation keys added across all 5 languages (en/th/ja/zh/ko).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Files changed (2) hide show
  1. frontend/src/App.jsx +764 -420
  2. frontend/src/styles.css +760 -5
frontend/src/App.jsx CHANGED
@@ -5,22 +5,34 @@ import {
5
  Clock3,
6
  Download,
7
  Film,
 
8
  Gauge,
9
  Languages,
 
10
  Link as LinkIcon,
11
  Loader2,
 
12
  Moon,
 
 
13
  PanelRightOpen,
 
 
14
  RefreshCcw,
15
  Scissors,
 
 
16
  SlidersHorizontal,
17
  Sparkles,
18
  Sun,
19
  Trash2,
 
20
  Upload,
 
21
  Wand2,
 
22
  } from "lucide-react";
23
- import React, { useEffect, useMemo, useState } from "react";
24
 
25
  const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
26
 
@@ -61,6 +73,8 @@ const defaultCaptionStyle = {
61
  strokeColor: "#080b12",
62
  strokeWidth: 4,
63
  position: 18,
 
 
64
  cueDensity: "short",
65
  animation: "highlight",
66
  };
@@ -264,6 +278,15 @@ const en = {
264
  languageOption_Chinese: "Chinese",
265
  languageOption_Korean: "Korean",
266
  languageOption_Auto: "Auto-detect",
 
 
 
 
 
 
 
 
 
267
  };
268
 
269
  const translations = {
@@ -424,6 +447,15 @@ const translations = {
424
  languageOption_Chinese: "จีน",
425
  languageOption_Korean: "เกาหลี",
426
  languageOption_Auto: "ตรวจจับอัตโนมัติ",
 
 
 
 
 
 
 
 
 
427
  },
428
  ja: {
429
  ...en,
@@ -582,6 +614,15 @@ const translations = {
582
  languageOption_Chinese: "中国語",
583
  languageOption_Korean: "韓国語",
584
  languageOption_Auto: "自動検出",
 
 
 
 
 
 
 
 
 
585
  },
586
  zh: {
587
  ...en,
@@ -739,6 +780,15 @@ const translations = {
739
  languageOption_Chinese: "中文",
740
  languageOption_Korean: "韩语",
741
  languageOption_Auto: "自动检测",
 
 
 
 
 
 
 
 
 
742
  },
743
  ko: {
744
  ...en,
@@ -897,6 +947,15 @@ const translations = {
897
  languageOption_Chinese: "중국어",
898
  languageOption_Korean: "한국어",
899
  languageOption_Auto: "자동 감지",
 
 
 
 
 
 
 
 
 
900
  },
901
  };
902
 
@@ -1083,9 +1142,11 @@ function App() {
1083
  {view === "editor" && editorClipId && editorClip ? (
1084
  <ClipEditorPage
1085
  clip={editorClip}
 
1086
  job={job}
1087
  t={t}
1088
  onBack={closeEditor}
 
1089
  onPatch={patchClip}
1090
  onDelete={(clip) => {
1091
  patchClip(clip.id, { deleted: true });
@@ -1595,61 +1656,57 @@ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegen
1595
  <p className="subtitle-snippet">{clip.subtitle_text}</p>
1596
  )}
1597
 
1598
- {/* Action row */}
1599
  <div className="clip-actions">
1600
- {/* Edit — primary style, full label */}
1601
  <button
1602
  className="btn btn-primary"
1603
  type="button"
1604
  title={t("openEditor")}
1605
  onClick={() => onOpenEditor(clip)}
1606
- style={{ justifyContent: "center" }}
1607
  >
1608
  <PanelRightOpen size={14} />
1609
  {t("openEditor")}
1610
  </button>
1611
 
1612
- {/* Approve toggle */}
1613
- <button
1614
- className={`btn btn-icon ${clip.approved ? "btn-success" : ""}`}
1615
- type="button"
1616
- title={clip.approved ? t("approved") : t("approve")}
1617
- onClick={() => onApprove(clip)}
1618
- >
1619
- <Check size={14} />
1620
- </button>
1621
-
1622
- {/* Regenerate */}
1623
- <button
1624
- className="btn btn-icon"
1625
- type="button"
1626
- title={t("regenerate")}
1627
- onClick={() => onRegenerate(clip)}
1628
- >
1629
- <RefreshCcw size={14} />
1630
- </button>
1631
-
1632
- {/* Delete */}
1633
- <button
1634
- className="btn btn-icon btn-danger"
1635
- type="button"
1636
- title={t("delete")}
1637
- onClick={() => onDelete(clip)}
1638
- >
1639
- <Trash2 size={14} />
1640
- </button>
1641
 
1642
- {/* Download */}
1643
- {clip.download_url && (
1644
- <a
1645
  className="btn btn-icon"
1646
- href={`${API_BASE}${clip.download_url}`}
1647
- title={t("download")}
1648
- style={{ borderColor: "var(--primary-dim)", background: "var(--primary-glow)", color: "var(--primary)" }}
1649
  >
1650
- <Download size={14} />
1651
- </a>
1652
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1653
  </div>
1654
  </div>
1655
  </article>
@@ -1657,13 +1714,15 @@ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegen
1657
  }
1658
 
1659
  // ============================================================
1660
- // Clip editor page — full page replacement
1661
  // ============================================================
1662
  function ClipEditorPage({
1663
  clip,
 
1664
  job,
1665
  t,
1666
  onBack,
 
1667
  onPatch,
1668
  onDelete,
1669
  onApprove,
@@ -1671,47 +1730,84 @@ function ClipEditorPage({
1671
  captionStyle,
1672
  onCaptionStyleChange,
1673
  }) {
 
 
 
 
 
1674
  const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
1675
- const cues = getSubtitleCues(clip, duration, captionStyle);
 
 
 
1676
  const metadataModel = clip.metadata?.model || "unknown";
1677
  const sourceKind = job?.source?.kind || "video";
1678
- const activeCue = cues[0]?.text || clip.subtitle_text || clip.title;
1679
 
1680
  const timelineDuration = Math.max(
1681
- clip.end_seconds,
 
1682
  ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
1683
  1
1684
  );
1685
- const rangeLeft = clamp((clip.start_seconds / timelineDuration) * 100, 0, 100);
1686
- const rangeWidth = clamp(
1687
- ((clip.end_seconds - clip.start_seconds) / timelineDuration) * 100,
1688
- 1,
1689
- 100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1690
  );
 
 
 
 
 
1691
 
1692
  function patchCue(index, text) {
1693
  const next = cues.map((cue, cueIndex) => (cueIndex === index ? { ...cue, text } : cue));
1694
  onPatch(clip.id, { subtitle_text: next.map((cue) => cue.text).join(" ") });
1695
  }
1696
 
1697
- function updateStart(value) {
1698
- const start = clamp(Number(value), 0, Math.max(0, clip.end_seconds - 1));
1699
  onPatch(clip.id, { start_seconds: roundTime(start) });
1700
  }
1701
-
1702
- function updateEnd(value) {
1703
- const end = clamp(Number(value), clip.start_seconds + 1, timelineDuration);
1704
  onPatch(clip.id, { end_seconds: roundTime(end) });
1705
  }
1706
-
1707
- function moveClip(delta) {
1708
- const safeDelta = clamp(delta, -clip.start_seconds, timelineDuration - clip.end_seconds);
1709
  onPatch(clip.id, {
1710
- start_seconds: roundTime(clip.start_seconds + safeDelta),
1711
- end_seconds: roundTime(clip.end_seconds + safeDelta),
1712
  });
1713
  }
1714
-
1715
  function setClipLength(seconds) {
1716
  onPatch(clip.id, {
1717
  end_seconds: roundTime(
@@ -1720,9 +1816,22 @@ function ClipEditorPage({
1720
  });
1721
  }
1722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1723
  return (
1724
- <div className="editor-shell">
1725
- {/* Sticky top bar */}
1726
  <div className="editor-topbar">
1727
  <button className="ghost-button" type="button" onClick={onBack}>
1728
  <ArrowLeft size={16} />
@@ -1732,8 +1841,8 @@ function ClipEditorPage({
1732
  <div className="editor-topbar-info">
1733
  <h2>{clip.title}</h2>
1734
  <p>
1735
- {t("editorText")} &nbsp;&middot;&nbsp; {formatTime(clip.start_seconds)} {" "}
1736
- {formatTime(clip.end_seconds)}
1737
  </p>
1738
  </div>
1739
 
@@ -1759,411 +1868,646 @@ function ClipEditorPage({
1759
  </div>
1760
  </div>
1761
 
1762
- {/* Two-column body */}
1763
- <div className="editor-body">
1764
- {/* Left: video + controls */}
1765
- <div className="editor-left">
1766
- {/* Video preview */}
1767
- <div className="editor-preview">
1768
- {clip.video_url ? (
1769
- <video controls src={`${API_BASE}${clip.video_url}`} />
1770
- ) : (
1771
- <Film size={40} />
1772
- )}
1773
- <CaptionPreview text={activeCue} settings={captionStyle} />
1774
- </div>
1775
-
1776
- {/* Clip range section */}
1777
- <div className="section-panel">
1778
- <div className="panel-heading compact">
1779
- <div>
1780
- <h2>{t("clipRange")}</h2>
1781
- <p>
1782
- {formatTime(clip.start_seconds)} – {formatTime(clip.end_seconds)} &nbsp;·&nbsp;{" "}
1783
- {duration.toFixed(1)}s
1784
- </p>
1785
- </div>
1786
- <div className="panel-heading-icon">
1787
- <Scissors size={14} />
1788
- </div>
1789
- </div>
1790
-
1791
- {/* Quick-trim toolbox */}
1792
- <div className="editor-toolbox">
1793
- <span className="editor-toolbox-label">{t("editorTools")}</span>
1794
- {[
1795
- [t("trimStartBack"), () => updateStart(clip.start_seconds - 0.5)],
1796
- [t("trimStartForward"), () => updateStart(clip.start_seconds + 0.5)],
1797
- [t("trimEndBack"), () => updateEnd(clip.end_seconds - 0.5)],
1798
- [t("trimEndForward"), () => updateEnd(clip.end_seconds + 0.5)],
1799
- [t("moveClipLeft"), () => moveClip(-1)],
1800
- [t("moveClipRight"), () => moveClip(1)],
1801
- [t("setClipLength30"), () => setClipLength(30)],
1802
- [t("setClipLength60"), () => setClipLength(60)],
1803
- [t("setClipLength90"), () => setClipLength(90)],
1804
- ].map(([label, handler]) => (
1805
- <button key={label} className="btn" type="button" onClick={handler}>
1806
- {label}
1807
- </button>
1808
- ))}
1809
- </div>
1810
 
1811
- {/* Timeline visual */}
1812
- <div className="timeline-visual">
1813
- <div className="timeline-fill" />
1814
- <div
1815
- className="timeline-window"
1816
- style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
1817
- />
1818
- {Array.from({ length: 9 }).map((_, index) => (
1819
- <span key={index} style={{ left: `${index * 12.5}%` }} />
1820
- ))}
1821
- </div>
 
1822
 
1823
- {/* Range sliders */}
1824
- <div className="range-sliders">
1825
- <label>
1826
- <span>{t("rangeStart")}</span>
1827
- <input
1828
- type="range"
1829
- min="0"
1830
- max={timelineDuration}
1831
- step="0.5"
1832
- value={clip.start_seconds}
1833
- onChange={(event) => updateStart(event.target.value)}
1834
- />
1835
- </label>
1836
- <label>
1837
- <span>{t("rangeEnd")}</span>
1838
- <input
1839
- type="range"
1840
- min="1"
1841
- max={timelineDuration}
1842
- step="0.5"
1843
- value={clip.end_seconds}
1844
- onChange={(event) => updateEnd(event.target.value)}
1845
- />
1846
- </label>
1847
- </div>
1848
 
1849
- {/* Numeric inputs */}
1850
- <div className="timeline">
1851
- <NumberField
1852
- label={t("start")}
1853
- value={Number(clip.start_seconds).toFixed(1)}
1854
- onChange={updateStart}
1855
- />
1856
- <NumberField
1857
- label={t("end")}
1858
- value={Number(clip.end_seconds).toFixed(1)}
1859
- onChange={updateEnd}
1860
- />
1861
- <strong>{duration.toFixed(1)}s</strong>
1862
- </div>
1863
- </div>
1864
 
1865
- {/* Timeline tracks */}
1866
- <TimelineTracks clip={clip} cues={cues} duration={timelineDuration} t={t} />
 
 
 
 
 
 
 
 
 
 
 
 
 
1867
 
1868
- {/* Subtitle cue editor */}
1869
- <div className="section-panel">
1870
- <div className="panel-heading compact">
1871
- <div>
1872
- <h2>{t("subtitleCues")}</h2>
1873
- <p>{t("subtitleCueHelp")}</p>
1874
- </div>
1875
- <div className="panel-heading-icon">
1876
- <Captions size={14} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1877
  </div>
1878
- </div>
1879
- <div className="cue-list">
1880
- {cues.map((cue, index) => (
1881
- <div className="cue-row" key={`${cue.start_seconds}-${index}`}>
1882
- <span>
1883
- {formatTime(cue.start_seconds)} {formatTime(cue.end_seconds)}
 
1884
  </span>
1885
- <textarea
1886
- defaultValue={cue.text}
1887
- rows={2}
1888
- onBlur={(event) => {
1889
- if (event.target.value !== cue.text) patchCue(index, event.target.value);
1890
- }}
1891
- />
1892
- </div>
1893
- ))}
1894
- </div>
1895
- </div>
1896
- </div>
1897
-
1898
- {/* Right: inspector + caption style + mini transcript */}
1899
- <div className="editor-right">
1900
- {/* Inspector */}
1901
- <div className="section-panel">
1902
- <div className="panel-heading compact">
1903
- <div>
1904
- <h2>{t("inspector")}</h2>
1905
- </div>
1906
- <div className="panel-heading-icon">
1907
- <Sparkles size={14} />
1908
- </div>
1909
- </div>
1910
-
1911
- <dl className="inspector-list">
1912
- <div>
1913
- <dt>{t("title")}</dt>
1914
- <dd>{clip.title}</dd>
1915
  </div>
1916
- <div>
1917
- <dt>{t("reason")}</dt>
1918
- <dd>{clip.reason}</dd>
1919
- </div>
1920
- <div>
1921
- <dt>{t("score")}</dt>
1922
- <dd>{Math.round(clip.score)}</dd>
1923
- </div>
1924
- <div>
1925
- <dt>{t("status")}</dt>
1926
- <dd>{clip.approved ? t("approved") : t("notApproved")}</dd>
1927
- </div>
1928
- <div>
1929
- <dt>{t("model")}</dt>
1930
- <dd>{metadataModel}</dd>
1931
- </div>
1932
- <div>
1933
- <dt>{t("source")}</dt>
1934
- <dd>{t(`source_${sourceKind}`)}</dd>
1935
- </div>
1936
- </dl>
1937
-
1938
- <div className="inspector-actions" style={{ marginTop: 12 }}>
1939
- <button
1940
- className={`btn-primary ${clip.approved ? "btn-success" : "btn-primary"}`}
1941
- style={{
1942
- display: "inline-flex",
1943
- alignItems: "center",
1944
- justifyContent: "center",
1945
- gap: 7,
1946
- minHeight: 38,
1947
- padding: "0 14px",
1948
- border: "1px solid",
1949
- borderRadius: "var(--radius-sm)",
1950
- fontSize: "0.84rem",
1951
- fontWeight: 600,
1952
- cursor: "pointer",
1953
- transition: "all 150ms ease",
1954
- borderColor: clip.approved
1955
- ? "rgba(52,211,153,0.35)"
1956
- : "var(--primary-dim)",
1957
- background: clip.approved ? "var(--success-soft)" : "var(--primary-glow)",
1958
- color: clip.approved ? "var(--success)" : "var(--primary)",
1959
- }}
1960
- type="button"
1961
- onClick={() => onApprove(clip)}
1962
- >
1963
- <Check size={14} />
1964
- {clip.approved ? t("approved") : t("approve")}
1965
- </button>
1966
 
1967
- <button
1968
- className="inspector-actions"
1969
- style={{
1970
- display: "inline-flex",
1971
- alignItems: "center",
1972
- justifyContent: "center",
1973
- gap: 7,
1974
- minHeight: 38,
1975
- padding: "0 14px",
1976
- border: "1px solid var(--border)",
1977
- borderRadius: "var(--radius-sm)",
1978
- background: "var(--surface2)",
1979
- color: "var(--text-muted)",
1980
- fontSize: "0.84rem",
1981
- fontWeight: 600,
1982
- cursor: "pointer",
1983
- transition: "all 150ms ease",
1984
- width: "100%",
1985
- }}
1986
- type="button"
1987
- onClick={() => onRegenerate(clip)}
1988
- >
1989
- <RefreshCcw size={14} />
1990
- {t("regenerate")}
1991
- </button>
1992
-
1993
- {clip.download_url && (
1994
- <a
1995
- style={{
1996
- display: "inline-flex",
1997
- alignItems: "center",
1998
- justifyContent: "center",
1999
- gap: 7,
2000
- minHeight: 38,
2001
- padding: "0 14px",
2002
- border: "1px solid var(--primary-dim)",
2003
- borderRadius: "var(--radius-sm)",
2004
- background: "var(--primary-glow)",
2005
- color: "var(--primary)",
2006
- fontSize: "0.84rem",
2007
- fontWeight: 600,
2008
- textDecoration: "none",
2009
- transition: "all 150ms ease",
2010
- }}
2011
- href={`${API_BASE}${clip.download_url}`}
2012
- >
2013
- <Download size={14} />
2014
- {t("download")}
2015
- </a>
2016
- )}
2017
-
2018
- <button
2019
- style={{
2020
- display: "inline-flex",
2021
- alignItems: "center",
2022
- justifyContent: "center",
2023
- gap: 7,
2024
- minHeight: 38,
2025
- padding: "0 14px",
2026
- border: "1px solid rgba(248,113,113,0.3)",
2027
- borderRadius: "var(--radius-sm)",
2028
- background: "var(--danger-soft)",
2029
- color: "var(--danger)",
2030
- fontSize: "0.84rem",
2031
- fontWeight: 600,
2032
- cursor: "pointer",
2033
- transition: "all 150ms ease",
2034
- width: "100%",
2035
- }}
2036
- type="button"
2037
- onClick={() => onDelete(clip)}
2038
- >
2039
- <Trash2 size={14} />
2040
- {t("delete")}
2041
- </button>
2042
- </div>
2043
- </div>
2044
 
2045
- {/* Caption style panel */}
2046
- <div className="section-panel">
2047
- <div className="panel-heading compact">
2048
- <div>
2049
- <h2>{t("captionStyle")}</h2>
2050
- </div>
2051
- <div className="panel-heading-icon">
2052
- <Captions size={14} />
2053
- </div>
2054
- </div>
2055
- <CaptionStylePanel t={t} settings={captionStyle} onChange={onCaptionStyleChange} />
2056
- </div>
 
 
 
 
 
2057
 
2058
- {/* Mini transcript */}
2059
- <div className="section-panel">
2060
- <TranscriptMini transcript={job?.transcript || []} clip={clip} t={t} />
2061
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2062
  </div>
2063
  </div>
2064
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2065
  );
2066
  }
2067
 
2068
  // ============================================================
2069
- // Caption preview (overlay on video)
2070
  // ============================================================
2071
- function CaptionPreview({ text, settings }) {
2072
- const words = text.split(/\s+/).filter(Boolean);
2073
  const litCount = Math.max(1, Math.ceil(words.length * 0.45));
 
 
 
 
 
2074
  const style = {
2075
- bottom: `${settings.position}%`,
 
2076
  color: settings.fillColor,
2077
  fontFamily: `"${settings.fontFamily}", Inter, ui-sans-serif, system-ui, sans-serif`,
2078
  fontSize: `${settings.fontSize}px`,
2079
  WebkitTextStroke: `${settings.strokeWidth}px ${settings.strokeColor}`,
2080
  textShadow: `0 3px 12px ${settings.strokeColor}`,
2081
  };
2082
-
2083
  return (
2084
- <div className={`caption-preview ${settings.animation}`} style={style}>
2085
- {settings.animation === "highlight"
 
 
 
 
2086
  ? words.map((word, index) => (
2087
  <span key={`${word}-${index}`} className={index < litCount ? "lit" : ""}>
2088
  {word}
2089
  {index < words.length - 1 ? " " : ""}
2090
  </span>
2091
  ))
2092
- : text}
2093
  </div>
2094
  );
2095
  }
2096
 
2097
  // ============================================================
2098
- // Timeline tracks
2099
  // ============================================================
2100
- function TimelineTracks({ clip, cues, duration, t }) {
2101
- const clipLeft = clamp((clip.start_seconds / duration) * 100, 0, 100);
2102
- const clipWidth = clamp(((clip.end_seconds - clip.start_seconds) / duration) * 100, 4, 100);
2103
- const subtitleItems = cues.slice(0, 8);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2104
 
2105
  return (
2106
- <div className="section-panel">
2107
- <div className="panel-heading compact">
2108
- <div>
2109
- <h2>{t("timelineTracks")}</h2>
2110
- <p>
2111
- {formatTime(clip.start_seconds)} – {formatTime(clip.end_seconds)}
2112
- </p>
2113
- </div>
2114
- <div className="panel-heading-icon">
2115
- <Film size={14} />
2116
- </div>
 
2117
  </div>
2118
- <div className="track-stack">
2119
- <TrackRow label={t("videoTrack")}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2120
  <div
2121
- className="track-clip video"
2122
- style={{ left: `${clipLeft}%`, width: `${clipWidth}%` }}
2123
- >
2124
- {clip.title}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2125
  </div>
2126
- </TrackRow>
2127
- <TrackRow label={t("subtitleTrack")}>
2128
- {subtitleItems.map((cue, index) => (
2129
- <div
2130
- className="track-clip subtitle"
2131
- key={`${cue.start_seconds}-${index}`}
2132
- style={{
2133
- left: `${clamp(
2134
- ((clip.start_seconds + cue.start_seconds) / duration) * 100,
2135
- 0,
2136
- 100
2137
- )}%`,
2138
- width: `${clamp(
2139
- ((cue.end_seconds - cue.start_seconds) / duration) * 100,
2140
- 3,
2141
- 45
2142
- )}%`,
2143
- }}
2144
- >
2145
- {cue.text}
 
 
 
 
 
 
 
 
 
2146
  </div>
2147
- ))}
2148
- </TrackRow>
2149
- <TrackRow label={t("audioTrack")}>
2150
- <div className="waveform">
2151
- {Array.from({ length: 42 }).map((_, index) => (
2152
- <span key={index} style={{ height: `${18 + ((index * 17) % 34)}%` }} />
2153
- ))}
2154
  </div>
2155
- </TrackRow>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2156
  </div>
2157
- </div>
2158
  );
2159
  }
2160
 
2161
- function TrackRow({ label, children }) {
 
 
 
2162
  return (
2163
- <div className="track-row">
2164
- <span>{label}</span>
2165
- <div className="track-lane">{children}</div>
2166
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2167
  );
2168
  }
2169
 
 
5
  Clock3,
6
  Download,
7
  Film,
8
+ FolderOpen,
9
  Gauge,
10
  Languages,
11
+ Layers,
12
  Link as LinkIcon,
13
  Loader2,
14
+ Maximize2,
15
  Moon,
16
+ Move,
17
+ Music2,
18
  PanelRightOpen,
19
+ Pause,
20
+ Play,
21
  RefreshCcw,
22
  Scissors,
23
+ SkipBack,
24
+ SkipForward,
25
  SlidersHorizontal,
26
  Sparkles,
27
  Sun,
28
  Trash2,
29
+ Type,
30
  Upload,
31
+ Volume2,
32
  Wand2,
33
+ Zap,
34
  } from "lucide-react";
35
+ import React, { useEffect, useMemo, useRef, useState } from "react";
36
 
37
  const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
38
 
 
73
  strokeColor: "#080b12",
74
  strokeWidth: 4,
75
  position: 18,
76
+ x: 50, // horizontal % of preview stage
77
+ y: 82, // vertical % of preview stage (default near bottom)
78
  cueDensity: "short",
79
  animation: "highlight",
80
  };
 
278
  languageOption_Chinese: "Chinese",
279
  languageOption_Korean: "Korean",
280
  languageOption_Auto: "Auto-detect",
281
+ // NLE editor
282
+ mediaBin: "Clips",
283
+ aiAssistant: "AI Assistant",
284
+ aiReason: "AI hasn't explained yet — try regenerating.",
285
+ aiTighten: "Tighten the cut",
286
+ aiEmphasize: "Emphasize the peak",
287
+ aiRedoAll: "Regenerate this clip",
288
+ dragToTrim: "Drag edges to trim · drag body to move",
289
+ dragToPosition: "Drag caption to reposition",
290
  };
291
 
292
  const translations = {
 
447
  languageOption_Chinese: "จีน",
448
  languageOption_Korean: "เกาหลี",
449
  languageOption_Auto: "ตรวจจับอัตโนมัติ",
450
+ // NLE editor
451
+ mediaBin: "คลิปทั้งหมด",
452
+ aiAssistant: "ผู้ช่วย AI",
453
+ aiReason: "AI ยังไม่ได้อธิบาย ลองสร้างใหม่ดูสิ",
454
+ aiTighten: "ตัดให้กระชับ",
455
+ aiEmphasize: "เน้นจุดเด่น",
456
+ aiRedoAll: "สร้างคลิปนี้ใหม่",
457
+ dragToTrim: "ลากขอบเพื่อ trim · ลากกลางเพื่อย้าย",
458
+ dragToPosition: "ลากข้อความเพื่อย้ายตำแหน่ง",
459
  },
460
  ja: {
461
  ...en,
 
614
  languageOption_Chinese: "中国語",
615
  languageOption_Korean: "韓国語",
616
  languageOption_Auto: "自動検出",
617
+ // NLE editor
618
+ mediaBin: "クリップ一覧",
619
+ aiAssistant: "AIアシスタント",
620
+ aiReason: "AIの説明はまだありません。再生成してみてください。",
621
+ aiTighten: "短くまとめる",
622
+ aiEmphasize: "ハイライトを強調",
623
+ aiRedoAll: "このクリップを再生成",
624
+ dragToTrim: "端をドラッグでトリム · 中央をドラッグで移動",
625
+ dragToPosition: "字幕をドラッグして移動",
626
  },
627
  zh: {
628
  ...en,
 
780
  languageOption_Chinese: "中文",
781
  languageOption_Korean: "韩语",
782
  languageOption_Auto: "自动检测",
783
+ // NLE editor
784
+ mediaBin: "片段列表",
785
+ aiAssistant: "AI 助手",
786
+ aiReason: "AI 还没解释,试试重新生成。",
787
+ aiTighten: "更紧凑",
788
+ aiEmphasize: "突出高潮",
789
+ aiRedoAll: "重新生成此片段",
790
+ dragToTrim: "拖动边缘修剪 · 拖动中央移动",
791
+ dragToPosition: "拖动字幕移动位置",
792
  },
793
  ko: {
794
  ...en,
 
947
  languageOption_Chinese: "중국어",
948
  languageOption_Korean: "한국어",
949
  languageOption_Auto: "자동 감지",
950
+ // NLE editor
951
+ mediaBin: "클립 목록",
952
+ aiAssistant: "AI 어시스턴트",
953
+ aiReason: "AI가 아직 설명하지 않았습니다. 다시 만들어 보세요.",
954
+ aiTighten: "더 타이트하게",
955
+ aiEmphasize: "하이라이트 강조",
956
+ aiRedoAll: "이 클립 다시 만들기",
957
+ dragToTrim: "끝을 드래그해 트림 · 가운데를 드래그해 이동",
958
+ dragToPosition: "자막을 드래그해 이동",
959
  },
960
  };
961
 
 
1142
  {view === "editor" && editorClipId && editorClip ? (
1143
  <ClipEditorPage
1144
  clip={editorClip}
1145
+ clips={activeClips}
1146
  job={job}
1147
  t={t}
1148
  onBack={closeEditor}
1149
+ onSelectClip={openEditor}
1150
  onPatch={patchClip}
1151
  onDelete={(clip) => {
1152
  patchClip(clip.id, { deleted: true });
 
1656
  <p className="subtitle-snippet">{clip.subtitle_text}</p>
1657
  )}
1658
 
1659
+ {/* Action row — primary CTA on top, icon group below */}
1660
  <div className="clip-actions">
 
1661
  <button
1662
  className="btn btn-primary"
1663
  type="button"
1664
  title={t("openEditor")}
1665
  onClick={() => onOpenEditor(clip)}
 
1666
  >
1667
  <PanelRightOpen size={14} />
1668
  {t("openEditor")}
1669
  </button>
1670
 
1671
+ <div className="clip-actions-icons">
1672
+ <button
1673
+ className={`btn btn-icon ${clip.approved ? "btn-success" : ""}`}
1674
+ type="button"
1675
+ title={clip.approved ? t("approved") : t("approve")}
1676
+ onClick={() => onApprove(clip)}
1677
+ >
1678
+ <Check size={14} />
1679
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1680
 
1681
+ <button
 
 
1682
  className="btn btn-icon"
1683
+ type="button"
1684
+ title={t("regenerate")}
1685
+ onClick={() => onRegenerate(clip)}
1686
  >
1687
+ <RefreshCcw size={14} />
1688
+ </button>
1689
+
1690
+ <button
1691
+ className="btn btn-icon btn-danger"
1692
+ type="button"
1693
+ title={t("delete")}
1694
+ onClick={() => onDelete(clip)}
1695
+ >
1696
+ <Trash2 size={14} />
1697
+ </button>
1698
+
1699
+ {clip.download_url && (
1700
+ <a
1701
+ className="btn btn-icon"
1702
+ href={`${API_BASE}${clip.download_url}`}
1703
+ title={t("download")}
1704
+ style={{ borderColor: "var(--primary-dim)", background: "var(--primary-glow)", color: "var(--primary)" }}
1705
+ >
1706
+ <Download size={14} />
1707
+ </a>
1708
+ )}
1709
+ </div>
1710
  </div>
1711
  </div>
1712
  </article>
 
1714
  }
1715
 
1716
  // ============================================================
1717
+ // Clip editor page — NLE 4-panel layout (Premiere-style)
1718
  // ============================================================
1719
  function ClipEditorPage({
1720
  clip,
1721
+ clips,
1722
  job,
1723
  t,
1724
  onBack,
1725
+ onSelectClip,
1726
  onPatch,
1727
  onDelete,
1728
  onApprove,
 
1730
  captionStyle,
1731
  onCaptionStyleChange,
1732
  }) {
1733
+ const videoRef = useRef(null);
1734
+ const [playhead, setPlayhead] = useState(clip.start_seconds);
1735
+ const [isPlaying, setIsPlaying] = useState(false);
1736
+ const [selectedCueIndex, setSelectedCueIndex] = useState(0);
1737
+
1738
  const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
1739
+ const cues = useMemo(
1740
+ () => getSubtitleCues(clip, duration, captionStyle),
1741
+ [clip, duration, captionStyle]
1742
+ );
1743
  const metadataModel = clip.metadata?.model || "unknown";
1744
  const sourceKind = job?.source?.kind || "video";
 
1745
 
1746
  const timelineDuration = Math.max(
1747
+ clip.end_seconds + 5,
1748
+ ...(clips || []).map((c) => Number(c.end_seconds || 0)),
1749
  ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
1750
  1
1751
  );
1752
+
1753
+ // Sync playhead with video element
1754
+ useEffect(() => {
1755
+ const video = videoRef.current;
1756
+ if (!video) return;
1757
+ function onTimeUpdate() {
1758
+ setPlayhead(clip.start_seconds + video.currentTime);
1759
+ }
1760
+ function onPlay() {
1761
+ setIsPlaying(true);
1762
+ }
1763
+ function onPause() {
1764
+ setIsPlaying(false);
1765
+ }
1766
+ video.addEventListener("timeupdate", onTimeUpdate);
1767
+ video.addEventListener("play", onPlay);
1768
+ video.addEventListener("pause", onPause);
1769
+ return () => {
1770
+ video.removeEventListener("timeupdate", onTimeUpdate);
1771
+ video.removeEventListener("play", onPlay);
1772
+ video.removeEventListener("pause", onPause);
1773
+ };
1774
+ }, [clip.id, clip.start_seconds]);
1775
+
1776
+ // Reset playhead when clip changes
1777
+ useEffect(() => {
1778
+ setPlayhead(clip.start_seconds);
1779
+ }, [clip.id, clip.start_seconds]);
1780
+
1781
+ // Determine the active cue at the playhead
1782
+ const playheadInClip = clamp(playhead - clip.start_seconds, 0, duration);
1783
+ const activeCueAuto = cues.findIndex(
1784
+ (cue) => playheadInClip >= cue.start_seconds && playheadInClip < cue.end_seconds
1785
  );
1786
+ const activeIndex =
1787
+ selectedCueIndex >= 0 && selectedCueIndex < cues.length
1788
+ ? selectedCueIndex
1789
+ : Math.max(0, activeCueAuto);
1790
+ const activeCueText = cues[activeIndex]?.text || clip.subtitle_text || clip.title || "";
1791
 
1792
  function patchCue(index, text) {
1793
  const next = cues.map((cue, cueIndex) => (cueIndex === index ? { ...cue, text } : cue));
1794
  onPatch(clip.id, { subtitle_text: next.map((cue) => cue.text).join(" ") });
1795
  }
1796
 
1797
+ function setStart(value) {
1798
+ const start = clamp(Number(value), 0, Math.max(0, clip.end_seconds - 0.5));
1799
  onPatch(clip.id, { start_seconds: roundTime(start) });
1800
  }
1801
+ function setEnd(value) {
1802
+ const end = clamp(Number(value), clip.start_seconds + 0.5, timelineDuration);
 
1803
  onPatch(clip.id, { end_seconds: roundTime(end) });
1804
  }
1805
+ function setRange(start, end) {
 
 
1806
  onPatch(clip.id, {
1807
+ start_seconds: roundTime(start),
1808
+ end_seconds: roundTime(end),
1809
  });
1810
  }
 
1811
  function setClipLength(seconds) {
1812
  onPatch(clip.id, {
1813
  end_seconds: roundTime(
 
1816
  });
1817
  }
1818
 
1819
+ function seekTo(seconds) {
1820
+ const video = videoRef.current;
1821
+ const target = clamp(seconds - clip.start_seconds, 0, duration);
1822
+ if (video) video.currentTime = target;
1823
+ setPlayhead(clip.start_seconds + target);
1824
+ }
1825
+
1826
+ function togglePlay() {
1827
+ const video = videoRef.current;
1828
+ if (!video) return;
1829
+ if (video.paused) video.play();
1830
+ else video.pause();
1831
+ }
1832
+
1833
  return (
1834
+ <div className="editor-shell nle">
 
1835
  <div className="editor-topbar">
1836
  <button className="ghost-button" type="button" onClick={onBack}>
1837
  <ArrowLeft size={16} />
 
1841
  <div className="editor-topbar-info">
1842
  <h2>{clip.title}</h2>
1843
  <p>
1844
+ {formatTime(clip.start_seconds)} {formatTime(clip.end_seconds)} &nbsp;·&nbsp;{" "}
1845
+ {duration.toFixed(1)}s &nbsp;·&nbsp; {Math.round(clip.score)} {t("score")}
1846
  </p>
1847
  </div>
1848
 
 
1868
  </div>
1869
  </div>
1870
 
1871
+ <div className="editor-grid-nle">
1872
+ <MediaBinPanel
1873
+ clips={clips || []}
1874
+ activeId={clip.id}
1875
+ onSelect={onSelectClip}
1876
+ t={t}
1877
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1878
 
1879
+ <PreviewStage
1880
+ clip={clip}
1881
+ videoRef={videoRef}
1882
+ captionStyle={captionStyle}
1883
+ activeCueText={activeCueText}
1884
+ isPlaying={isPlaying}
1885
+ playhead={playhead}
1886
+ onTogglePlay={togglePlay}
1887
+ onSeekDelta={(delta) => seekTo(playhead + delta)}
1888
+ onCaptionStyleChange={onCaptionStyleChange}
1889
+ t={t}
1890
+ />
1891
 
1892
+ <AIAssistantPanel
1893
+ clip={clip}
1894
+ t={t}
1895
+ onRegenerate={onRegenerate}
1896
+ onTighten={() => setClipLength(30)}
1897
+ onFitLength={(secs) => setClipLength(secs)}
1898
+ onDelete={onDelete}
1899
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1900
 
1901
+ <TimelineEditor
1902
+ clip={clip}
1903
+ cues={cues}
1904
+ duration={duration}
1905
+ timelineDuration={timelineDuration}
1906
+ playhead={playhead}
1907
+ selectedCueIndex={activeIndex}
1908
+ onSelectCue={setSelectedCueIndex}
1909
+ onSeek={seekTo}
1910
+ onSetStart={setStart}
1911
+ onSetEnd={setEnd}
1912
+ onSetRange={setRange}
1913
+ t={t}
1914
+ />
 
1915
 
1916
+ <EditorInspector
1917
+ clip={clip}
1918
+ metadataModel={metadataModel}
1919
+ sourceKind={sourceKind}
1920
+ captionStyle={captionStyle}
1921
+ onCaptionStyleChange={onCaptionStyleChange}
1922
+ cues={cues}
1923
+ activeIndex={activeIndex}
1924
+ onPatchCue={patchCue}
1925
+ t={t}
1926
+ />
1927
+ </div>
1928
+ </div>
1929
+ );
1930
+ }
1931
 
1932
+ // ============================================================
1933
+ // Media Bin (left column)
1934
+ // ============================================================
1935
+ function MediaBinPanel({ clips, activeId, onSelect, t }) {
1936
+ return (
1937
+ <aside className="nle-panel nle-bin">
1938
+ <div className="nle-panel-head">
1939
+ <h3>
1940
+ {t("mediaBin")} <span style={{ color: "var(--text-soft)", fontWeight: 500, marginLeft: 6 }}>· {clips.length}</span>
1941
+ </h3>
1942
+ <span className="nle-panel-icon">
1943
+ <FolderOpen size={12} />
1944
+ </span>
1945
+ </div>
1946
+ <div className="nle-panel-body">
1947
+ <div className="nle-bin-list">
1948
+ {clips.map((c) => (
1949
+ <button
1950
+ type="button"
1951
+ key={c.id}
1952
+ className={`nle-bin-item ${c.id === activeId ? "active" : ""}`}
1953
+ onClick={() => onSelect && onSelect(c)}
1954
+ >
1955
+ <div className="nle-bin-thumb">
1956
+ {c.video_url ? (
1957
+ <video src={`${API_BASE}${c.video_url}`} muted preload="metadata" />
1958
+ ) : (
1959
+ <Film size={18} />
1960
+ )}
1961
  </div>
1962
+ <div className="nle-bin-meta">
1963
+ <span className="nle-bin-title">{c.title}</span>
1964
+ <span className="nle-bin-sub">
1965
+ {formatTime(c.start_seconds)}{formatTime(c.end_seconds)}
1966
+ <span className="nle-bin-score">
1967
+ <Gauge size={10} />
1968
+ {Math.round(c.score)}
1969
  </span>
1970
+ {c.approved && (
1971
+ <Check size={10} style={{ color: "var(--success)" }} />
1972
+ )}
1973
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1974
  </div>
1975
+ </button>
1976
+ ))}
1977
+ </div>
1978
+ </div>
1979
+ </aside>
1980
+ );
1981
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1982
 
1983
+ // ============================================================
1984
+ // Preview Stage (center top) — video + draggable caption
1985
+ // ============================================================
1986
+ function PreviewStage({
1987
+ clip,
1988
+ videoRef,
1989
+ captionStyle,
1990
+ activeCueText,
1991
+ isPlaying,
1992
+ playhead,
1993
+ onTogglePlay,
1994
+ onSeekDelta,
1995
+ onCaptionStyleChange,
1996
+ t,
1997
+ }) {
1998
+ const stageRef = useRef(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1999
 
2000
+ function handleCaptionDragStart(event) {
2001
+ event.preventDefault();
2002
+ const stage = stageRef.current;
2003
+ if (!stage) return;
2004
+ const rect = stage.getBoundingClientRect();
2005
+ function onMove(ev) {
2006
+ const x = clamp(((ev.clientX - rect.left) / rect.width) * 100, 4, 96);
2007
+ const y = clamp(((ev.clientY - rect.top) / rect.height) * 100, 6, 94);
2008
+ onCaptionStyleChange({ x: Math.round(x), y: Math.round(y) });
2009
+ }
2010
+ function onUp() {
2011
+ window.removeEventListener("mousemove", onMove);
2012
+ window.removeEventListener("mouseup", onUp);
2013
+ }
2014
+ window.addEventListener("mousemove", onMove);
2015
+ window.addEventListener("mouseup", onUp);
2016
+ }
2017
 
2018
+ return (
2019
+ <section className="nle-panel nle-preview">
2020
+ <div className="nle-panel-head">
2021
+ <h3>{t("preview")}</h3>
2022
+ <span className="nle-panel-icon">
2023
+ <Maximize2 size={12} />
2024
+ </span>
2025
+ </div>
2026
+ <div className="preview-stage" ref={stageRef}>
2027
+ <div className="preview-stage-canvas">
2028
+ {clip.video_url ? (
2029
+ <video ref={videoRef} src={`${API_BASE}${clip.video_url}`} />
2030
+ ) : (
2031
+ <Film size={56} style={{ color: "var(--text-soft)" }} />
2032
+ )}
2033
+ <CaptionOverlay
2034
+ text={activeCueText}
2035
+ settings={captionStyle}
2036
+ onMouseDown={handleCaptionDragStart}
2037
+ />
2038
  </div>
2039
  </div>
2040
+ <div className="preview-toolbar">
2041
+ <div className="preview-toolbar-left">
2042
+ <button
2043
+ className="btn btn-icon"
2044
+ type="button"
2045
+ onClick={() => onSeekDelta(-1)}
2046
+ title={t("trimStartBack")}
2047
+ >
2048
+ <SkipBack size={14} />
2049
+ </button>
2050
+ <button
2051
+ className="btn btn-icon btn-primary"
2052
+ type="button"
2053
+ onClick={onTogglePlay}
2054
+ title={isPlaying ? t("preview") : t("preview")}
2055
+ >
2056
+ {isPlaying ? <Pause size={14} /> : <Play size={14} />}
2057
+ </button>
2058
+ <button
2059
+ className="btn btn-icon"
2060
+ type="button"
2061
+ onClick={() => onSeekDelta(1)}
2062
+ title={t("trimStartForward")}
2063
+ >
2064
+ <SkipForward size={14} />
2065
+ </button>
2066
+ </div>
2067
+ <div className="preview-time">
2068
+ <strong>{formatTime(playhead)}</strong> / {formatTime(clip.end_seconds)}
2069
+ </div>
2070
+ <div className="preview-toolbar-right">
2071
+ <span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}>
2072
+ <Move size={11} style={{ verticalAlign: "-2px", marginRight: 4 }} />
2073
+ {t("dragToPosition")}
2074
+ </span>
2075
+ </div>
2076
+ </div>
2077
+ </section>
2078
  );
2079
  }
2080
 
2081
  // ============================================================
2082
+ // Caption Overlay draggable, positioned by captionStyle.x/y
2083
  // ============================================================
2084
+ function CaptionOverlay({ text, settings, onMouseDown }) {
2085
+ const words = (text || "").split(/\s+/).filter(Boolean);
2086
  const litCount = Math.max(1, Math.ceil(words.length * 0.45));
2087
+ const x = typeof settings.x === "number" ? settings.x : 50;
2088
+ const y =
2089
+ typeof settings.y === "number"
2090
+ ? settings.y
2091
+ : 100 - (typeof settings.position === "number" ? settings.position : 18);
2092
  const style = {
2093
+ left: `${x}%`,
2094
+ top: `${y}%`,
2095
  color: settings.fillColor,
2096
  fontFamily: `"${settings.fontFamily}", Inter, ui-sans-serif, system-ui, sans-serif`,
2097
  fontSize: `${settings.fontSize}px`,
2098
  WebkitTextStroke: `${settings.strokeWidth}px ${settings.strokeColor}`,
2099
  textShadow: `0 3px 12px ${settings.strokeColor}`,
2100
  };
 
2101
  return (
2102
+ <div
2103
+ className={`caption-overlay ${settings.animation}`}
2104
+ style={style}
2105
+ onMouseDown={onMouseDown}
2106
+ >
2107
+ {settings.animation === "highlight" && words.length
2108
  ? words.map((word, index) => (
2109
  <span key={`${word}-${index}`} className={index < litCount ? "lit" : ""}>
2110
  {word}
2111
  {index < words.length - 1 ? " " : ""}
2112
  </span>
2113
  ))
2114
+ : text || "—"}
2115
  </div>
2116
  );
2117
  }
2118
 
2119
  // ============================================================
2120
+ // Timeline Editor (center bottom) — drag-to-trim V1 + ruler + tracks
2121
  // ============================================================
2122
+ function TimelineEditor({
2123
+ clip,
2124
+ cues,
2125
+ duration,
2126
+ timelineDuration,
2127
+ playhead,
2128
+ selectedCueIndex,
2129
+ onSelectCue,
2130
+ onSeek,
2131
+ onSetStart,
2132
+ onSetEnd,
2133
+ onSetRange,
2134
+ t,
2135
+ }) {
2136
+ const laneRef = useRef(null);
2137
+
2138
+ const ticks = useMemo(() => {
2139
+ const result = [];
2140
+ const step = Math.max(5, Math.ceil(timelineDuration / 12 / 5) * 5);
2141
+ for (let s = 0; s <= timelineDuration; s += step) {
2142
+ result.push(s);
2143
+ }
2144
+ return result;
2145
+ }, [timelineDuration]);
2146
+
2147
+ const clipLeftPct = (clip.start_seconds / timelineDuration) * 100;
2148
+ const clipWidthPct = ((clip.end_seconds - clip.start_seconds) / timelineDuration) * 100;
2149
+ const playheadPct = clamp((playhead / timelineDuration) * 100, 0, 100);
2150
+
2151
+ function laneRect() {
2152
+ const lane = laneRef.current;
2153
+ return lane ? lane.getBoundingClientRect() : null;
2154
+ }
2155
+
2156
+ function startEdgeDrag(edge) {
2157
+ return (event) => {
2158
+ event.preventDefault();
2159
+ event.stopPropagation();
2160
+ const rect = laneRect();
2161
+ if (!rect) return;
2162
+ function onMove(ev) {
2163
+ const ratio = clamp((ev.clientX - rect.left) / rect.width, 0, 1);
2164
+ const seconds = roundTime(ratio * timelineDuration);
2165
+ if (edge === "left") {
2166
+ onSetStart(clamp(seconds, 0, clip.end_seconds - 0.5));
2167
+ } else {
2168
+ onSetEnd(clamp(seconds, clip.start_seconds + 0.5, timelineDuration));
2169
+ }
2170
+ }
2171
+ function onUp() {
2172
+ window.removeEventListener("mousemove", onMove);
2173
+ window.removeEventListener("mouseup", onUp);
2174
+ }
2175
+ window.addEventListener("mousemove", onMove);
2176
+ window.addEventListener("mouseup", onUp);
2177
+ };
2178
+ }
2179
+
2180
+ function startBodyDrag(event) {
2181
+ event.preventDefault();
2182
+ const rect = laneRect();
2183
+ if (!rect) return;
2184
+ const startX = event.clientX;
2185
+ const initialStart = clip.start_seconds;
2186
+ const initialEnd = clip.end_seconds;
2187
+ const length = initialEnd - initialStart;
2188
+ function onMove(ev) {
2189
+ const dx = ev.clientX - startX;
2190
+ const deltaSeconds = (dx / rect.width) * timelineDuration;
2191
+ const newStart = clamp(initialStart + deltaSeconds, 0, timelineDuration - length);
2192
+ onSetRange(newStart, newStart + length);
2193
+ }
2194
+ function onUp() {
2195
+ window.removeEventListener("mousemove", onMove);
2196
+ window.removeEventListener("mouseup", onUp);
2197
+ }
2198
+ window.addEventListener("mousemove", onMove);
2199
+ window.addEventListener("mouseup", onUp);
2200
+ }
2201
+
2202
+ function handleRulerClick(event) {
2203
+ const rect = laneRect();
2204
+ if (!rect) return;
2205
+ const ratio = clamp((event.clientX - rect.left) / rect.width, 0, 1);
2206
+ onSeek(ratio * timelineDuration);
2207
+ }
2208
 
2209
  return (
2210
+ <section className="nle-panel nle-timeline">
2211
+ <div className="nle-panel-head">
2212
+ <h3>{t("timelineTracks")}</h3>
2213
+ <span className="nle-panel-icon">
2214
+ <Layers size={12} />
2215
+ </span>
2216
+ </div>
2217
+ <div className="timeline-toolbar">
2218
+ <span>
2219
+ <Scissors size={11} style={{ verticalAlign: "-2px", marginRight: 4 }} />
2220
+ {t("dragToTrim")}
2221
+ </span>
2222
  </div>
2223
+ <div className="timeline-area">
2224
+ <div
2225
+ className="timeline-ruler"
2226
+ onClick={handleRulerClick}
2227
+ style={{ cursor: "pointer" }}
2228
+ >
2229
+ {ticks.map((tick) => {
2230
+ const left = (tick / timelineDuration) * 100;
2231
+ const isMajor = tick % 30 === 0;
2232
+ return (
2233
+ <React.Fragment key={tick}>
2234
+ <span
2235
+ className={`timeline-tick ${isMajor ? "major" : ""}`}
2236
+ style={{ left: `${left}%` }}
2237
+ />
2238
+ {isMajor && (
2239
+ <span
2240
+ className="timeline-tick-label"
2241
+ style={{ left: `${left}%` }}
2242
+ >
2243
+ {formatTime(tick)}
2244
+ </span>
2245
+ )}
2246
+ </React.Fragment>
2247
+ );
2248
+ })}
2249
  <div
2250
+ className="timeline-playhead"
2251
+ style={{ left: `${playheadPct}%` }}
2252
+ />
2253
+ </div>
2254
+ <div className="timeline-stack">
2255
+ <div className="timeline-track">
2256
+ <div className="timeline-track-label">V1</div>
2257
+ <div className="timeline-track-lane video" ref={laneRef}>
2258
+ <div
2259
+ className="timeline-clip"
2260
+ style={{
2261
+ left: `${clipLeftPct}%`,
2262
+ width: `${clipWidthPct}%`,
2263
+ }}
2264
+ onMouseDown={startBodyDrag}
2265
+ title={clip.title}
2266
+ >
2267
+ <span
2268
+ className="timeline-handle left"
2269
+ onMouseDown={startEdgeDrag("left")}
2270
+ />
2271
+ {clip.title}
2272
+ <span
2273
+ className="timeline-handle right"
2274
+ onMouseDown={startEdgeDrag("right")}
2275
+ />
2276
+ </div>
2277
+ <div
2278
+ className="timeline-playhead"
2279
+ style={{ left: `${playheadPct}%` }}
2280
+ />
2281
+ </div>
2282
  </div>
2283
+ <div className="timeline-track">
2284
+ <div className="timeline-track-label">T1</div>
2285
+ <div className="timeline-track-lane">
2286
+ {cues.map((cue, index) => {
2287
+ const cueLeft =
2288
+ ((clip.start_seconds + cue.start_seconds) / timelineDuration) * 100;
2289
+ const cueWidth =
2290
+ ((cue.end_seconds - cue.start_seconds) / timelineDuration) * 100;
2291
+ return (
2292
+ <div
2293
+ key={`cue-${index}`}
2294
+ className={`timeline-caption-block ${
2295
+ index === selectedCueIndex ? "selected" : ""
2296
+ }`}
2297
+ style={{
2298
+ left: `${clamp(cueLeft, 0, 100)}%`,
2299
+ width: `${clamp(cueWidth, 1.4, 100 - cueLeft)}%`,
2300
+ }}
2301
+ onClick={() => onSelectCue(index)}
2302
+ title={cue.text}
2303
+ >
2304
+ {cue.text}
2305
+ </div>
2306
+ );
2307
+ })}
2308
+ <div
2309
+ className="timeline-playhead"
2310
+ style={{ left: `${playheadPct}%` }}
2311
+ />
2312
  </div>
 
 
 
 
 
 
 
2313
  </div>
2314
+ <div className="timeline-track">
2315
+ <div className="timeline-track-label">A1</div>
2316
+ <div className="timeline-track-lane audio">
2317
+ <div className="timeline-waveform">
2318
+ {Array.from({ length: 60 }).map((_, index) => (
2319
+ <span
2320
+ key={index}
2321
+ style={{
2322
+ height: `${30 + ((index * 19 + clip.id.length * 7) % 60)}%`,
2323
+ }}
2324
+ />
2325
+ ))}
2326
+ </div>
2327
+ <div
2328
+ className="timeline-playhead"
2329
+ style={{ left: `${playheadPct}%` }}
2330
+ />
2331
+ </div>
2332
+ </div>
2333
+ </div>
2334
  </div>
2335
+ </section>
2336
  );
2337
  }
2338
 
2339
+ // ============================================================
2340
+ // AI Assistant Panel (right top)
2341
+ // ============================================================
2342
+ function AIAssistantPanel({ clip, t, onRegenerate, onTighten, onFitLength, onDelete }) {
2343
  return (
2344
+ <aside className="nle-panel nle-ai">
2345
+ <div className="nle-panel-head">
2346
+ <h3>{t("aiAssistant")}</h3>
2347
+ <span className="nle-panel-icon">
2348
+ <Sparkles size={12} />
2349
+ </span>
2350
+ </div>
2351
+ <div className="nle-panel-body">
2352
+ <p className="ai-reason">{clip.reason || t("aiReason")}</p>
2353
+ <div className="ai-actions">
2354
+ <button
2355
+ type="button"
2356
+ className="ai-action"
2357
+ onClick={() => onRegenerate(clip)}
2358
+ >
2359
+ <span className="ai-action-icon">
2360
+ <RefreshCcw size={14} />
2361
+ </span>
2362
+ <span className="ai-action-text">
2363
+ <strong>{t("aiRedoAll")}</strong>
2364
+ <small>{t("regenerate")}</small>
2365
+ </span>
2366
+ </button>
2367
+ <button type="button" className="ai-action" onClick={onTighten}>
2368
+ <span className="ai-action-icon">
2369
+ <Scissors size={14} />
2370
+ </span>
2371
+ <span className="ai-action-text">
2372
+ <strong>{t("aiTighten")}</strong>
2373
+ <small>30s</small>
2374
+ </span>
2375
+ </button>
2376
+ <button
2377
+ type="button"
2378
+ className="ai-action"
2379
+ onClick={() => onFitLength(60)}
2380
+ >
2381
+ <span className="ai-action-icon">
2382
+ <Zap size={14} />
2383
+ </span>
2384
+ <span className="ai-action-text">
2385
+ <strong>{t("aiEmphasize")}</strong>
2386
+ <small>60s</small>
2387
+ </span>
2388
+ </button>
2389
+ <button
2390
+ type="button"
2391
+ className="ai-action"
2392
+ onClick={() => onDelete(clip)}
2393
+ >
2394
+ <span
2395
+ className="ai-action-icon"
2396
+ style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
2397
+ >
2398
+ <Trash2 size={14} />
2399
+ </span>
2400
+ <span className="ai-action-text">
2401
+ <strong>{t("delete")}</strong>
2402
+ <small>—</small>
2403
+ </span>
2404
+ </button>
2405
+ </div>
2406
+ </div>
2407
+ </aside>
2408
+ );
2409
+ }
2410
+
2411
+ // ============================================================
2412
+ // Editor Inspector (right bottom) — metadata + caption + active cue
2413
+ // ============================================================
2414
+ function EditorInspector({
2415
+ clip,
2416
+ metadataModel,
2417
+ sourceKind,
2418
+ captionStyle,
2419
+ onCaptionStyleChange,
2420
+ cues,
2421
+ activeIndex,
2422
+ onPatchCue,
2423
+ t,
2424
+ }) {
2425
+ return (
2426
+ <aside className="nle-panel nle-inspector">
2427
+ <div className="nle-panel-head">
2428
+ <h3>{t("inspector")}</h3>
2429
+ <span className="nle-panel-icon">
2430
+ <SlidersHorizontal size={12} />
2431
+ </span>
2432
+ </div>
2433
+ <div className="nle-panel-body">
2434
+ <div className="inspector-stack">
2435
+ <section>
2436
+ <h4>{t("inspector")}</h4>
2437
+ <dl className="inspector-meta">
2438
+ <div>
2439
+ <dt>{t("score")}</dt>
2440
+ <dd className="score-value">{Math.round(clip.score)}</dd>
2441
+ </div>
2442
+ <div>
2443
+ <dt>{t("status")}</dt>
2444
+ <dd>{clip.approved ? t("approved") : t("notApproved")}</dd>
2445
+ </div>
2446
+ <div>
2447
+ <dt>{t("source")}</dt>
2448
+ <dd>{t(`source_${sourceKind}`)}</dd>
2449
+ </div>
2450
+ <div>
2451
+ <dt>{t("model")}</dt>
2452
+ <dd style={{ fontSize: "0.74rem" }}>{metadataModel}</dd>
2453
+ </div>
2454
+ </dl>
2455
+ </section>
2456
+
2457
+ <section>
2458
+ <h4>
2459
+ <Type size={11} style={{ verticalAlign: "-2px", marginRight: 5 }} />
2460
+ {t("subtitleCues")}
2461
+ </h4>
2462
+ {cues.length > 0 && (
2463
+ <textarea
2464
+ key={`cue-${clip.id}-${activeIndex}`}
2465
+ rows={3}
2466
+ defaultValue={cues[activeIndex]?.text || ""}
2467
+ onBlur={(event) => {
2468
+ if (event.target.value !== cues[activeIndex]?.text) {
2469
+ onPatchCue(activeIndex, event.target.value);
2470
+ }
2471
+ }}
2472
+ style={{
2473
+ width: "100%",
2474
+ minHeight: 70,
2475
+ padding: 10,
2476
+ borderRadius: "var(--radius-sm)",
2477
+ border: "1px solid var(--border)",
2478
+ background: "var(--surface2)",
2479
+ color: "var(--text)",
2480
+ fontFamily: "inherit",
2481
+ fontSize: "0.84rem",
2482
+ resize: "vertical",
2483
+ }}
2484
+ />
2485
+ )}
2486
+ <p
2487
+ style={{
2488
+ margin: "8px 0 0",
2489
+ fontSize: "0.72rem",
2490
+ color: "var(--text-muted)",
2491
+ }}
2492
+ >
2493
+ {t("subtitleCueHelp")}
2494
+ </p>
2495
+ </section>
2496
+
2497
+ <section>
2498
+ <h4>
2499
+ <Captions size={11} style={{ verticalAlign: "-2px", marginRight: 5 }} />
2500
+ {t("captionStyle")}
2501
+ </h4>
2502
+ <CaptionStylePanel
2503
+ t={t}
2504
+ settings={captionStyle}
2505
+ onChange={onCaptionStyleChange}
2506
+ />
2507
+ </section>
2508
+ </div>
2509
+ </div>
2510
+ </aside>
2511
  );
2512
  }
2513
 
frontend/src/styles.css CHANGED
@@ -899,8 +899,8 @@ textarea:focus,
899
  ============================================================ */
900
  .clip-grid {
901
  display: grid;
902
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
903
- gap: 14px;
904
  align-items: start;
905
  }
906
 
@@ -1020,12 +1020,29 @@ textarea:focus,
1020
  min-height: 46px;
1021
  }
1022
 
1023
- /* Clip action row */
1024
  .clip-actions {
1025
  display: grid;
1026
- grid-template-columns: 1fr auto auto auto auto;
 
 
 
 
 
 
 
 
 
 
 
 
1027
  gap: 6px;
1028
- align-items: center;
 
 
 
 
 
1029
  }
1030
 
1031
  /* ============================================================
@@ -1107,6 +1124,744 @@ textarea:focus,
1107
  top: 132px;
1108
  }
1109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
  /* ============================================================
1111
  Editor preview
1112
  ============================================================ */
 
899
  ============================================================ */
900
  .clip-grid {
901
  display: grid;
902
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
903
+ gap: 16px;
904
  align-items: start;
905
  }
906
 
 
1020
  min-height: 46px;
1021
  }
1022
 
1023
+ /* Clip action row — primary CTA full-width on top, icon row below */
1024
  .clip-actions {
1025
  display: grid;
1026
+ grid-template-columns: minmax(0, 1fr);
1027
+ gap: 8px;
1028
+ align-items: stretch;
1029
+ }
1030
+
1031
+ .clip-actions > .btn-primary {
1032
+ width: 100%;
1033
+ justify-content: center;
1034
+ }
1035
+
1036
+ .clip-actions-icons {
1037
+ display: flex;
1038
+ flex-wrap: wrap;
1039
  gap: 6px;
1040
+ }
1041
+
1042
+ .clip-actions-icons > .btn {
1043
+ flex: 1 1 auto;
1044
+ min-width: 40px;
1045
+ justify-content: center;
1046
  }
1047
 
1048
  /* ============================================================
 
1124
  top: 132px;
1125
  }
1126
 
1127
+ /* ============================================================
1128
+ NLE 4-panel editor (Premiere-style)
1129
+ ============================================================ */
1130
+ .editor-shell.nle {
1131
+ height: calc(100vh - 72px);
1132
+ min-height: calc(100vh - 72px);
1133
+ overflow: hidden;
1134
+ }
1135
+
1136
+ .editor-grid-nle {
1137
+ display: grid;
1138
+ grid-template-columns: 240px minmax(0, 1fr) 340px;
1139
+ grid-template-rows: minmax(0, 1fr) minmax(220px, 38%);
1140
+ grid-template-areas:
1141
+ "bin preview ai"
1142
+ "bin timeline inspector";
1143
+ gap: 1px;
1144
+ background: var(--border);
1145
+ flex: 1;
1146
+ min-height: 0;
1147
+ overflow: hidden;
1148
+ }
1149
+
1150
+ .nle-panel {
1151
+ background: var(--surface);
1152
+ display: flex;
1153
+ flex-direction: column;
1154
+ min-width: 0;
1155
+ min-height: 0;
1156
+ overflow: hidden;
1157
+ }
1158
+
1159
+ .nle-panel-head {
1160
+ display: flex;
1161
+ align-items: center;
1162
+ justify-content: space-between;
1163
+ gap: 8px;
1164
+ padding: 10px 14px;
1165
+ border-bottom: 1px solid var(--border);
1166
+ background: var(--surface2);
1167
+ flex: 0 0 auto;
1168
+ }
1169
+
1170
+ .nle-panel-head h3 {
1171
+ margin: 0;
1172
+ font-size: 0.78rem;
1173
+ font-weight: 700;
1174
+ letter-spacing: 0.04em;
1175
+ text-transform: uppercase;
1176
+ color: var(--text-muted);
1177
+ }
1178
+
1179
+ .nle-panel-head .nle-panel-icon {
1180
+ display: inline-flex;
1181
+ align-items: center;
1182
+ justify-content: center;
1183
+ width: 22px;
1184
+ height: 22px;
1185
+ border-radius: 6px;
1186
+ background: var(--surface);
1187
+ color: var(--text-muted);
1188
+ border: 1px solid var(--border);
1189
+ }
1190
+
1191
+ .nle-panel-body {
1192
+ flex: 1;
1193
+ min-height: 0;
1194
+ overflow: auto;
1195
+ scrollbar-width: thin;
1196
+ scrollbar-color: var(--border) transparent;
1197
+ }
1198
+
1199
+ /* Media bin (left column) */
1200
+ .nle-bin {
1201
+ grid-area: bin;
1202
+ border-right: 1px solid var(--border);
1203
+ }
1204
+
1205
+ .nle-bin-list {
1206
+ display: flex;
1207
+ flex-direction: column;
1208
+ padding: 8px;
1209
+ gap: 6px;
1210
+ }
1211
+
1212
+ .nle-bin-item {
1213
+ display: grid;
1214
+ grid-template-columns: 48px minmax(0, 1fr);
1215
+ gap: 10px;
1216
+ padding: 8px;
1217
+ border-radius: var(--radius-sm);
1218
+ border: 1px solid transparent;
1219
+ background: var(--surface);
1220
+ cursor: pointer;
1221
+ transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
1222
+ text-align: left;
1223
+ color: inherit;
1224
+ font: inherit;
1225
+ }
1226
+
1227
+ .nle-bin-item:hover {
1228
+ background: var(--surface2);
1229
+ border-color: var(--border);
1230
+ transform: translateX(2px);
1231
+ }
1232
+
1233
+ .nle-bin-item.active {
1234
+ background: var(--primary-glow);
1235
+ border-color: var(--primary-dim);
1236
+ box-shadow: var(--shadow-glow);
1237
+ }
1238
+
1239
+ .nle-bin-thumb {
1240
+ width: 48px;
1241
+ height: 64px;
1242
+ border-radius: 4px;
1243
+ background: #050810;
1244
+ display: grid;
1245
+ place-items: center;
1246
+ color: var(--text-soft);
1247
+ overflow: hidden;
1248
+ position: relative;
1249
+ }
1250
+
1251
+ .nle-bin-thumb video,
1252
+ .nle-bin-thumb img {
1253
+ width: 100%;
1254
+ height: 100%;
1255
+ object-fit: cover;
1256
+ }
1257
+
1258
+ .nle-bin-meta {
1259
+ display: flex;
1260
+ flex-direction: column;
1261
+ gap: 3px;
1262
+ min-width: 0;
1263
+ }
1264
+
1265
+ .nle-bin-title {
1266
+ font-size: 0.78rem;
1267
+ font-weight: 600;
1268
+ color: var(--text);
1269
+ white-space: nowrap;
1270
+ overflow: hidden;
1271
+ text-overflow: ellipsis;
1272
+ }
1273
+
1274
+ .nle-bin-sub {
1275
+ font-size: 0.7rem;
1276
+ color: var(--text-muted);
1277
+ display: flex;
1278
+ align-items: center;
1279
+ gap: 6px;
1280
+ white-space: nowrap;
1281
+ overflow: hidden;
1282
+ }
1283
+
1284
+ .nle-bin-sub .nle-bin-score {
1285
+ display: inline-flex;
1286
+ align-items: center;
1287
+ gap: 3px;
1288
+ padding: 1px 5px;
1289
+ border-radius: 4px;
1290
+ background: var(--accent-soft);
1291
+ color: var(--accent);
1292
+ font-weight: 600;
1293
+ font-size: 0.66rem;
1294
+ }
1295
+
1296
+ /* Preview stage (center top) */
1297
+ .nle-preview {
1298
+ grid-area: preview;
1299
+ border-bottom: 1px solid var(--border);
1300
+ }
1301
+
1302
+ .preview-stage {
1303
+ position: relative;
1304
+ flex: 1;
1305
+ min-height: 0;
1306
+ display: grid;
1307
+ place-items: center;
1308
+ background: #04060c;
1309
+ overflow: hidden;
1310
+ }
1311
+
1312
+ .preview-stage-canvas {
1313
+ position: relative;
1314
+ width: 100%;
1315
+ height: 100%;
1316
+ display: grid;
1317
+ place-items: center;
1318
+ }
1319
+
1320
+ .preview-stage video {
1321
+ max-width: 100%;
1322
+ max-height: 100%;
1323
+ width: auto;
1324
+ height: auto;
1325
+ object-fit: contain;
1326
+ }
1327
+
1328
+ .caption-overlay {
1329
+ position: absolute;
1330
+ transform: translate(-50%, -50%);
1331
+ font-weight: 900;
1332
+ letter-spacing: 0;
1333
+ line-height: 1.15;
1334
+ text-align: center;
1335
+ white-space: pre-wrap;
1336
+ cursor: grab;
1337
+ user-select: none;
1338
+ padding: 4px 10px;
1339
+ border-radius: 4px;
1340
+ border: 1px dashed transparent;
1341
+ transition: border-color 140ms ease, background 140ms ease;
1342
+ max-width: 80%;
1343
+ }
1344
+
1345
+ .caption-overlay:hover {
1346
+ border-color: var(--primary-ring);
1347
+ background: rgba(129, 140, 248, 0.06);
1348
+ }
1349
+
1350
+ .caption-overlay:active,
1351
+ .caption-overlay.dragging {
1352
+ cursor: grabbing;
1353
+ border-color: var(--primary);
1354
+ background: rgba(129, 140, 248, 0.14);
1355
+ }
1356
+
1357
+ .caption-overlay .lit {
1358
+ color: var(--accent);
1359
+ }
1360
+
1361
+ .caption-overlay.pop {
1362
+ animation: caption-pop 900ms ease-in-out infinite alternate;
1363
+ }
1364
+
1365
+ .caption-overlay.bounce {
1366
+ animation: caption-bounce 760ms ease-in-out infinite alternate;
1367
+ }
1368
+
1369
+ .preview-toolbar {
1370
+ display: flex;
1371
+ align-items: center;
1372
+ justify-content: space-between;
1373
+ gap: 12px;
1374
+ padding: 10px 14px;
1375
+ background: var(--surface2);
1376
+ border-top: 1px solid var(--border);
1377
+ flex: 0 0 auto;
1378
+ }
1379
+
1380
+ .preview-toolbar-left,
1381
+ .preview-toolbar-right {
1382
+ display: flex;
1383
+ align-items: center;
1384
+ gap: 6px;
1385
+ }
1386
+
1387
+ .preview-time {
1388
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
1389
+ font-size: 0.78rem;
1390
+ color: var(--text-muted);
1391
+ letter-spacing: 0.02em;
1392
+ }
1393
+
1394
+ .preview-time strong {
1395
+ color: var(--text);
1396
+ font-weight: 700;
1397
+ }
1398
+
1399
+ /* Timeline editor (center bottom) */
1400
+ .nle-timeline {
1401
+ grid-area: timeline;
1402
+ }
1403
+
1404
+ .timeline-toolbar {
1405
+ display: flex;
1406
+ align-items: center;
1407
+ gap: 6px;
1408
+ padding: 6px 12px;
1409
+ border-bottom: 1px solid var(--border);
1410
+ flex: 0 0 auto;
1411
+ background: var(--surface);
1412
+ font-size: 0.74rem;
1413
+ color: var(--text-muted);
1414
+ }
1415
+
1416
+ .timeline-toolbar .timeline-zoom {
1417
+ margin-left: auto;
1418
+ display: inline-flex;
1419
+ gap: 4px;
1420
+ }
1421
+
1422
+ .timeline-zoom button {
1423
+ width: 24px;
1424
+ height: 22px;
1425
+ display: inline-flex;
1426
+ align-items: center;
1427
+ justify-content: center;
1428
+ border: 1px solid var(--border);
1429
+ background: var(--surface2);
1430
+ color: var(--text-muted);
1431
+ border-radius: 4px;
1432
+ font-size: 0.72rem;
1433
+ cursor: pointer;
1434
+ }
1435
+
1436
+ .timeline-zoom button:hover {
1437
+ background: var(--surface);
1438
+ color: var(--text);
1439
+ }
1440
+
1441
+ .timeline-area {
1442
+ flex: 1;
1443
+ min-height: 0;
1444
+ display: flex;
1445
+ flex-direction: column;
1446
+ overflow: hidden;
1447
+ }
1448
+
1449
+ .timeline-ruler {
1450
+ position: relative;
1451
+ height: 22px;
1452
+ border-bottom: 1px solid var(--border);
1453
+ background: var(--surface2);
1454
+ cursor: pointer;
1455
+ flex: 0 0 auto;
1456
+ overflow: hidden;
1457
+ }
1458
+
1459
+ .timeline-tick {
1460
+ position: absolute;
1461
+ top: 0;
1462
+ bottom: 0;
1463
+ width: 1px;
1464
+ background: var(--border);
1465
+ }
1466
+
1467
+ .timeline-tick.major {
1468
+ background: var(--border-strong);
1469
+ }
1470
+
1471
+ .timeline-tick-label {
1472
+ position: absolute;
1473
+ top: 4px;
1474
+ font-size: 0.62rem;
1475
+ color: var(--text-muted);
1476
+ font-family: ui-monospace, monospace;
1477
+ pointer-events: none;
1478
+ transform: translateX(2px);
1479
+ }
1480
+
1481
+ .timeline-stack {
1482
+ position: relative;
1483
+ flex: 1;
1484
+ min-height: 0;
1485
+ display: flex;
1486
+ flex-direction: column;
1487
+ overflow-y: auto;
1488
+ scrollbar-width: thin;
1489
+ }
1490
+
1491
+ .timeline-track {
1492
+ position: relative;
1493
+ display: grid;
1494
+ grid-template-columns: 60px 1fr;
1495
+ border-bottom: 1px solid var(--border);
1496
+ flex: 0 0 auto;
1497
+ }
1498
+
1499
+ .timeline-track-label {
1500
+ display: flex;
1501
+ align-items: center;
1502
+ justify-content: center;
1503
+ background: var(--surface2);
1504
+ border-right: 1px solid var(--border);
1505
+ font-size: 0.7rem;
1506
+ font-weight: 700;
1507
+ color: var(--text-muted);
1508
+ letter-spacing: 0.05em;
1509
+ user-select: none;
1510
+ }
1511
+
1512
+ .timeline-track-lane {
1513
+ position: relative;
1514
+ background: var(--bg);
1515
+ height: 56px;
1516
+ overflow: hidden;
1517
+ }
1518
+
1519
+ .timeline-track-lane.video {
1520
+ height: 64px;
1521
+ }
1522
+
1523
+ .timeline-track-lane.audio {
1524
+ height: 48px;
1525
+ background: linear-gradient(180deg, var(--bg), var(--surface2));
1526
+ }
1527
+
1528
+ .timeline-clip {
1529
+ position: absolute;
1530
+ top: 6px;
1531
+ bottom: 6px;
1532
+ border-radius: 6px;
1533
+ border: 1px solid var(--primary-dim);
1534
+ background: linear-gradient(180deg, rgba(129, 140, 248, 0.32), rgba(129, 140, 248, 0.18));
1535
+ color: var(--text);
1536
+ display: flex;
1537
+ align-items: center;
1538
+ padding: 0 28px;
1539
+ font-size: 0.74rem;
1540
+ font-weight: 600;
1541
+ white-space: nowrap;
1542
+ overflow: hidden;
1543
+ text-overflow: ellipsis;
1544
+ cursor: grab;
1545
+ user-select: none;
1546
+ transition: filter 140ms ease;
1547
+ }
1548
+
1549
+ .timeline-clip:hover {
1550
+ filter: brightness(1.12);
1551
+ }
1552
+
1553
+ .timeline-clip.dragging {
1554
+ cursor: grabbing;
1555
+ filter: brightness(1.2);
1556
+ box-shadow: 0 0 0 1px var(--primary), var(--shadow-md);
1557
+ }
1558
+
1559
+ .timeline-handle {
1560
+ position: absolute;
1561
+ top: 0;
1562
+ bottom: 0;
1563
+ width: 10px;
1564
+ background: var(--primary);
1565
+ cursor: ew-resize;
1566
+ z-index: 2;
1567
+ display: flex;
1568
+ align-items: center;
1569
+ justify-content: center;
1570
+ opacity: 0.8;
1571
+ transition: opacity 140ms ease, background 140ms ease;
1572
+ }
1573
+
1574
+ .timeline-handle::before {
1575
+ content: "";
1576
+ width: 2px;
1577
+ height: 14px;
1578
+ background: rgba(255, 255, 255, 0.85);
1579
+ border-radius: 1px;
1580
+ }
1581
+
1582
+ .timeline-handle:hover,
1583
+ .timeline-handle.dragging {
1584
+ opacity: 1;
1585
+ background: var(--accent);
1586
+ }
1587
+
1588
+ .timeline-handle.left {
1589
+ left: 0;
1590
+ border-radius: 6px 0 0 6px;
1591
+ }
1592
+
1593
+ .timeline-handle.right {
1594
+ right: 0;
1595
+ border-radius: 0 6px 6px 0;
1596
+ }
1597
+
1598
+ .timeline-caption-block {
1599
+ position: absolute;
1600
+ top: 8px;
1601
+ bottom: 8px;
1602
+ border-radius: 4px;
1603
+ background: rgba(245, 158, 11, 0.22);
1604
+ border: 1px solid rgba(245, 158, 11, 0.55);
1605
+ color: var(--accent);
1606
+ font-size: 0.68rem;
1607
+ font-weight: 600;
1608
+ padding: 0 6px;
1609
+ display: flex;
1610
+ align-items: center;
1611
+ white-space: nowrap;
1612
+ overflow: hidden;
1613
+ text-overflow: ellipsis;
1614
+ cursor: pointer;
1615
+ }
1616
+
1617
+ .timeline-caption-block:hover {
1618
+ background: rgba(245, 158, 11, 0.35);
1619
+ }
1620
+
1621
+ .timeline-caption-block.selected {
1622
+ background: var(--accent);
1623
+ color: #1a1300;
1624
+ border-color: var(--accent);
1625
+ }
1626
+
1627
+ .timeline-waveform {
1628
+ position: absolute;
1629
+ inset: 4px 0;
1630
+ display: flex;
1631
+ align-items: center;
1632
+ gap: 2px;
1633
+ padding: 0 4px;
1634
+ }
1635
+
1636
+ .timeline-waveform span {
1637
+ flex: 1 1 0;
1638
+ background: var(--text-muted);
1639
+ opacity: 0.55;
1640
+ border-radius: 1px;
1641
+ min-width: 2px;
1642
+ }
1643
+
1644
+ .timeline-playhead {
1645
+ position: absolute;
1646
+ top: 0;
1647
+ bottom: 0;
1648
+ width: 2px;
1649
+ background: var(--danger);
1650
+ pointer-events: none;
1651
+ z-index: 5;
1652
+ box-shadow: 0 0 8px rgba(248, 113, 113, 0.65);
1653
+ }
1654
+
1655
+ .timeline-playhead::before {
1656
+ content: "";
1657
+ position: absolute;
1658
+ top: -2px;
1659
+ left: -5px;
1660
+ border-left: 6px solid transparent;
1661
+ border-right: 6px solid transparent;
1662
+ border-top: 8px solid var(--danger);
1663
+ }
1664
+
1665
+ /* AI Assistant (right top) */
1666
+ .nle-ai {
1667
+ grid-area: ai;
1668
+ border-left: 1px solid var(--border);
1669
+ border-bottom: 1px solid var(--border);
1670
+ }
1671
+
1672
+ .ai-reason {
1673
+ margin: 0;
1674
+ padding: 14px 16px;
1675
+ font-size: 0.86rem;
1676
+ color: var(--text);
1677
+ line-height: 1.55;
1678
+ border-bottom: 1px solid var(--border);
1679
+ background: linear-gradient(135deg, rgba(129, 140, 248, 0.08), transparent);
1680
+ }
1681
+
1682
+ .ai-reason::before {
1683
+ content: "";
1684
+ display: inline-block;
1685
+ width: 4px;
1686
+ height: 14px;
1687
+ background: var(--primary);
1688
+ border-radius: 2px;
1689
+ margin-right: 10px;
1690
+ vertical-align: -2px;
1691
+ }
1692
+
1693
+ .ai-actions {
1694
+ display: flex;
1695
+ flex-direction: column;
1696
+ gap: 8px;
1697
+ padding: 14px 16px;
1698
+ }
1699
+
1700
+ .ai-action {
1701
+ display: flex;
1702
+ align-items: center;
1703
+ gap: 10px;
1704
+ padding: 10px 12px;
1705
+ border-radius: var(--radius-sm);
1706
+ border: 1px solid var(--border);
1707
+ background: var(--surface2);
1708
+ color: var(--text);
1709
+ font-size: 0.82rem;
1710
+ font-weight: 600;
1711
+ text-align: left;
1712
+ cursor: pointer;
1713
+ transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
1714
+ }
1715
+
1716
+ .ai-action:hover {
1717
+ background: var(--surface);
1718
+ border-color: var(--primary-dim);
1719
+ transform: translateX(2px);
1720
+ }
1721
+
1722
+ .ai-action:active {
1723
+ transform: translateX(2px) scale(0.98);
1724
+ }
1725
+
1726
+ .ai-action-icon {
1727
+ display: inline-flex;
1728
+ align-items: center;
1729
+ justify-content: center;
1730
+ width: 28px;
1731
+ height: 28px;
1732
+ border-radius: 6px;
1733
+ background: var(--primary-glow);
1734
+ color: var(--primary);
1735
+ flex: 0 0 auto;
1736
+ }
1737
+
1738
+ .ai-action-text {
1739
+ display: flex;
1740
+ flex-direction: column;
1741
+ gap: 2px;
1742
+ min-width: 0;
1743
+ }
1744
+
1745
+ .ai-action-text strong {
1746
+ font-size: 0.84rem;
1747
+ font-weight: 700;
1748
+ color: var(--text);
1749
+ }
1750
+
1751
+ .ai-action-text small {
1752
+ font-size: 0.7rem;
1753
+ color: var(--text-muted);
1754
+ font-weight: 500;
1755
+ }
1756
+
1757
+ /* Inspector (right bottom) */
1758
+ .nle-inspector {
1759
+ grid-area: inspector;
1760
+ border-left: 1px solid var(--border);
1761
+ }
1762
+
1763
+ .inspector-stack {
1764
+ display: flex;
1765
+ flex-direction: column;
1766
+ gap: 0;
1767
+ }
1768
+
1769
+ .inspector-stack > section {
1770
+ padding: 14px 16px;
1771
+ border-bottom: 1px solid var(--border);
1772
+ }
1773
+
1774
+ .inspector-stack > section:last-child {
1775
+ border-bottom: none;
1776
+ }
1777
+
1778
+ .inspector-stack h4 {
1779
+ margin: 0 0 10px;
1780
+ font-size: 0.72rem;
1781
+ font-weight: 700;
1782
+ letter-spacing: 0.05em;
1783
+ text-transform: uppercase;
1784
+ color: var(--text-muted);
1785
+ }
1786
+
1787
+ .inspector-meta {
1788
+ display: grid;
1789
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
1790
+ gap: 10px 16px;
1791
+ margin: 0;
1792
+ }
1793
+
1794
+ .inspector-meta dt {
1795
+ font-size: 0.66rem;
1796
+ letter-spacing: 0.03em;
1797
+ text-transform: uppercase;
1798
+ color: var(--text-muted);
1799
+ margin: 0;
1800
+ }
1801
+
1802
+ .inspector-meta dd {
1803
+ margin: 2px 0 0;
1804
+ font-size: 0.84rem;
1805
+ color: var(--text);
1806
+ font-weight: 600;
1807
+ word-break: break-word;
1808
+ }
1809
+
1810
+ .inspector-meta dd.score-value {
1811
+ color: var(--accent);
1812
+ font-size: 1.1rem;
1813
+ }
1814
+
1815
+ /* NLE responsive collapse */
1816
+ @media (max-width: 1280px) {
1817
+ .editor-grid-nle {
1818
+ grid-template-columns: 200px minmax(0, 1fr) 300px;
1819
+ }
1820
+ }
1821
+
1822
+ @media (max-width: 1080px) {
1823
+ .editor-shell.nle {
1824
+ height: auto;
1825
+ min-height: calc(100vh - 72px);
1826
+ overflow: visible;
1827
+ }
1828
+
1829
+ .editor-grid-nle {
1830
+ grid-template-columns: minmax(0, 1fr);
1831
+ grid-template-rows: auto auto auto auto auto;
1832
+ grid-template-areas:
1833
+ "preview"
1834
+ "timeline"
1835
+ "ai"
1836
+ "inspector"
1837
+ "bin";
1838
+ overflow: visible;
1839
+ }
1840
+
1841
+ .nle-panel {
1842
+ overflow: visible;
1843
+ }
1844
+
1845
+ .nle-bin,
1846
+ .nle-ai,
1847
+ .nle-inspector {
1848
+ border-left: none;
1849
+ border-right: none;
1850
+ }
1851
+
1852
+ .nle-preview {
1853
+ border-bottom: 1px solid var(--border);
1854
+ }
1855
+
1856
+ .preview-stage {
1857
+ min-height: 320px;
1858
+ }
1859
+
1860
+ .timeline-area {
1861
+ min-height: 240px;
1862
+ }
1863
+ }
1864
+
1865
  /* ============================================================
1866
  Editor preview
1867
  ============================================================ */