JakgritB Claude Opus 4.7 commited on
Commit
6f3ea5d
·
1 Parent(s): 6af4843

fix(editor): real-time drag via draft state + range-locked playback

Browse files

The previous editor fired an API patch on every mousemove during V1
trim/move and caption drag. The result was UI flicker, race conditions,
and the impression that the editor "didn't work."

Real-time editing now works through a clean two-state model:

- trimDraft: { start_seconds, end_seconds } | null
- captionDraft: { x, y } | null
- Effective values (effStart/effEnd/effCaptionStyle) prefer drafts when
present, fall back to committed clip/captionStyle.

Drag flow:
- mousedown captures initial state and registers window mousemove/mouseup.
- mousemove → setTrimDraft (or setCaptionDraft) — local React state only,
no network. Sub-millisecond updates with no thrashing.
- mouseup → onTrimCommit (or onCaptionCommit) fires exactly one onPatch
(or one onCaptionStyleChange), then clears the draft.

Video playback is now range-locked:
- On clip change: video.load() + seek to clip.start_seconds.
- timeupdate listener: pauses + rewinds to effStart when currentTime
reaches effEnd - 0.05; auto-corrects if currentTime drifts below
effStart.
- togglePlay seeks back to effStart if the playhead was outside the
range, so Play always plays the clip range, not the source video.
- Preview time displays clip-local position (0:00 at clip start),
matching how a creator thinks about a short.

V1 timeline visibility:
- Solid indigo gradient (was washed-out 0.18 alpha, nearly invisible
on dark theme — likely cause of the user's "where is V1?" report).
- Clip text wrapped in a .timeline-clip-label span with text-shadow
for legibility against the gradient.
- V1 lane height bumped 64→72px; clip box-shadow + hover/dragging
states give clear depth.
- Waveform doubled to 80 bars with id-based seeded heights.

Caption overlay reads from effCaptionStyle so dragging shows live
position; commits run through onCaptionStyleChange which persists to
localStorage.

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

Files changed (2) hide show
  1. frontend/src/App.jsx +160 -76
  2. frontend/src/styles.css +24 -12
frontend/src/App.jsx CHANGED
@@ -1761,27 +1761,58 @@ function ClipEditorPage({
1761
  const [isPlaying, setIsPlaying] = useState(false);
1762
  const [selectedCueIndex, setSelectedCueIndex] = useState(0);
1763
 
1764
- const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
 
 
 
 
 
 
 
 
 
 
 
1765
  const cues = useMemo(
1766
- () => getSubtitleCues(clip, duration, captionStyle),
1767
- [clip, duration, captionStyle]
 
 
 
 
 
1768
  );
1769
  const metadataModel = clip.metadata?.model || "unknown";
1770
  const sourceKind = job?.source?.kind || "video";
1771
 
1772
  const timelineDuration = Math.max(
1773
- clip.end_seconds + 5,
1774
  ...(clips || []).map((c) => Number(c.end_seconds || 0)),
1775
  ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
1776
  1
1777
  );
1778
 
1779
- // Sync playhead with video element + force first-frame render
1780
  useEffect(() => {
1781
  const video = videoRef.current;
1782
  if (!video) return;
1783
  function onTimeUpdate() {
1784
- setPlayhead(clip.start_seconds + video.currentTime);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1785
  }
1786
  function onPlay() {
1787
  setIsPlaying(true);
@@ -1789,62 +1820,63 @@ function ClipEditorPage({
1789
  function onPause() {
1790
  setIsPlaying(false);
1791
  }
1792
- function onLoadedMetadata() {
1793
- // Seek 0.05s in to force first frame to render in <video>
1794
- try {
1795
- video.currentTime = 0.05;
1796
- } catch {
1797
- /* ignore */
1798
- }
1799
- }
1800
  video.addEventListener("timeupdate", onTimeUpdate);
1801
  video.addEventListener("play", onPlay);
1802
  video.addEventListener("pause", onPause);
1803
- video.addEventListener("loadedmetadata", onLoadedMetadata);
1804
- video.load();
1805
  return () => {
1806
  video.removeEventListener("timeupdate", onTimeUpdate);
1807
  video.removeEventListener("play", onPlay);
1808
  video.removeEventListener("pause", onPause);
1809
- video.removeEventListener("loadedmetadata", onLoadedMetadata);
1810
  };
1811
- }, [clip.id, clip.start_seconds, clip.video_url]);
1812
 
1813
- // Reset playhead when clip changes
1814
  useEffect(() => {
1815
- setPlayhead(clip.start_seconds);
1816
- }, [clip.id, clip.start_seconds]);
 
 
 
 
 
 
 
 
 
 
 
 
 
1817
 
1818
- // Determine the active cue at the playhead
1819
- const playheadInClip = clamp(playhead - clip.start_seconds, 0, duration);
1820
- const activeCueAuto = cues.findIndex(
1821
  (cue) => playheadInClip >= cue.start_seconds && playheadInClip < cue.end_seconds
1822
  );
1823
  const activeIndex =
1824
  selectedCueIndex >= 0 && selectedCueIndex < cues.length
1825
  ? selectedCueIndex
1826
- : Math.max(0, activeCueAuto);
1827
  const activeCueText = cues[activeIndex]?.text || clip.subtitle_text || clip.title || "";
1828
 
1829
- function patchCue(index, text) {
1830
- const next = cues.map((cue, cueIndex) => (cueIndex === index ? { ...cue, text } : cue));
1831
- onPatch(clip.id, { subtitle_text: next.map((cue) => cue.text).join(" ") });
1832
- }
1833
-
1834
- function setStart(value) {
1835
- const start = clamp(Number(value), 0, Math.max(0, clip.end_seconds - 0.5));
1836
- onPatch(clip.id, { start_seconds: roundTime(start) });
1837
- }
1838
- function setEnd(value) {
1839
- const end = clamp(Number(value), clip.start_seconds + 0.5, timelineDuration);
1840
- onPatch(clip.id, { end_seconds: roundTime(end) });
1841
- }
1842
- function setRange(start, end) {
1843
  onPatch(clip.id, {
1844
  start_seconds: roundTime(start),
1845
  end_seconds: roundTime(end),
1846
  });
1847
  }
 
 
 
 
 
 
 
 
 
 
1848
  function setClipLength(seconds) {
1849
  onPatch(clip.id, {
1850
  end_seconds: roundTime(
@@ -1852,19 +1884,33 @@ function ClipEditorPage({
1852
  ),
1853
  });
1854
  }
1855
-
1856
  function seekTo(seconds) {
1857
  const video = videoRef.current;
1858
- const target = clamp(seconds - clip.start_seconds, 0, duration);
1859
- if (video) video.currentTime = target;
1860
- setPlayhead(clip.start_seconds + target);
 
 
 
 
 
 
1861
  }
1862
-
1863
  function togglePlay() {
1864
  const video = videoRef.current;
1865
  if (!video) return;
1866
- if (video.paused) video.play();
1867
- else video.pause();
 
 
 
 
 
 
 
 
 
 
1868
  }
1869
 
1870
  return (
@@ -1878,7 +1924,7 @@ function ClipEditorPage({
1878
  <div className="editor-topbar-info">
1879
  <h2>{clip.title}</h2>
1880
  <p>
1881
- {formatTime(clip.start_seconds)} – {formatTime(clip.end_seconds)} &nbsp;·&nbsp;{" "}
1882
  {duration.toFixed(1)}s &nbsp;·&nbsp; {Math.round(clip.score)} {t("score")}
1883
  </p>
1884
  </div>
@@ -1916,13 +1962,16 @@ function ClipEditorPage({
1916
  <PreviewStage
1917
  clip={clip}
1918
  videoRef={videoRef}
1919
- captionStyle={captionStyle}
1920
  activeCueText={activeCueText}
1921
  isPlaying={isPlaying}
1922
  playhead={playhead}
 
 
1923
  onTogglePlay={togglePlay}
1924
  onSeekDelta={(delta) => seekTo(playhead + delta)}
1925
- onCaptionStyleChange={onCaptionStyleChange}
 
1926
  t={t}
1927
  />
1928
 
@@ -1941,12 +1990,14 @@ function ClipEditorPage({
1941
  duration={duration}
1942
  timelineDuration={timelineDuration}
1943
  playhead={playhead}
 
 
 
1944
  selectedCueIndex={activeIndex}
1945
  onSelectCue={setSelectedCueIndex}
1946
  onSeek={seekTo}
1947
- onSetStart={setStart}
1948
- onSetEnd={setEnd}
1949
- onSetRange={setRange}
1950
  t={t}
1951
  />
1952
 
@@ -2027,9 +2078,12 @@ function PreviewStage({
2027
  activeCueText,
2028
  isPlaying,
2029
  playhead,
 
 
2030
  onTogglePlay,
2031
  onSeekDelta,
2032
- onCaptionStyleChange,
 
2033
  t,
2034
  }) {
2035
  const stageRef = useRef(null);
@@ -2039,12 +2093,16 @@ function PreviewStage({
2039
  const stage = stageRef.current;
2040
  if (!stage) return;
2041
  const rect = stage.getBoundingClientRect();
2042
- function onMove(ev) {
2043
  const x = clamp(((ev.clientX - rect.left) / rect.width) * 100, 4, 96);
2044
  const y = clamp(((ev.clientY - rect.top) / rect.height) * 100, 6, 94);
2045
- onCaptionStyleChange({ x: Math.round(x), y: Math.round(y) });
2046
  }
2047
- function onUp() {
 
 
 
 
2048
  window.removeEventListener("mousemove", onMove);
2049
  window.removeEventListener("mouseup", onUp);
2050
  }
@@ -2052,6 +2110,9 @@ function PreviewStage({
2052
  window.addEventListener("mouseup", onUp);
2053
  }
2054
 
 
 
 
2055
  return (
2056
  <section className="nle-panel nle-preview">
2057
  <div className="nle-panel-head">
@@ -2094,7 +2155,6 @@ function PreviewStage({
2094
  className="btn btn-icon btn-primary"
2095
  type="button"
2096
  onClick={onTogglePlay}
2097
- title={isPlaying ? t("preview") : t("preview")}
2098
  >
2099
  {isPlaying ? <Pause size={14} /> : <Play size={14} />}
2100
  </button>
@@ -2108,7 +2168,7 @@ function PreviewStage({
2108
  </button>
2109
  </div>
2110
  <div className="preview-time">
2111
- <strong>{formatTime(playhead)}</strong> / {formatTime(clip.end_seconds)}
2112
  </div>
2113
  <div className="preview-toolbar-right">
2114
  <span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}>
@@ -2168,12 +2228,14 @@ function TimelineEditor({
2168
  duration,
2169
  timelineDuration,
2170
  playhead,
 
 
 
2171
  selectedCueIndex,
2172
  onSelectCue,
2173
  onSeek,
2174
- onSetStart,
2175
- onSetEnd,
2176
- onSetRange,
2177
  t,
2178
  }) {
2179
  const laneRef = useRef(null);
@@ -2187,8 +2249,8 @@ function TimelineEditor({
2187
  return result;
2188
  }, [timelineDuration]);
2189
 
2190
- const clipLeftPct = (clip.start_seconds / timelineDuration) * 100;
2191
- const clipWidthPct = ((clip.end_seconds - clip.start_seconds) / timelineDuration) * 100;
2192
  const playheadPct = clamp((playhead / timelineDuration) * 100, 0, 100);
2193
 
2194
  function laneRect() {
@@ -2202,16 +2264,29 @@ function TimelineEditor({
2202
  event.stopPropagation();
2203
  const rect = laneRect();
2204
  if (!rect) return;
2205
- function onMove(ev) {
 
 
 
2206
  const ratio = clamp((ev.clientX - rect.left) / rect.width, 0, 1);
2207
  const seconds = roundTime(ratio * timelineDuration);
2208
  if (edge === "left") {
2209
- onSetStart(clamp(seconds, 0, clip.end_seconds - 0.5));
2210
- } else {
2211
- onSetEnd(clamp(seconds, clip.start_seconds + 0.5, timelineDuration));
 
2212
  }
 
 
 
 
2213
  }
2214
- function onUp() {
 
 
 
 
 
2215
  window.removeEventListener("mousemove", onMove);
2216
  window.removeEventListener("mouseup", onUp);
2217
  }
@@ -2221,6 +2296,7 @@ function TimelineEditor({
2221
  }
2222
 
2223
  function startBodyDrag(event) {
 
2224
  event.preventDefault();
2225
  const rect = laneRect();
2226
  if (!rect) return;
@@ -2228,13 +2304,21 @@ function TimelineEditor({
2228
  const initialStart = clip.start_seconds;
2229
  const initialEnd = clip.end_seconds;
2230
  const length = initialEnd - initialStart;
2231
- function onMove(ev) {
2232
  const dx = ev.clientX - startX;
2233
  const deltaSeconds = (dx / rect.width) * timelineDuration;
2234
  const newStart = clamp(initialStart + deltaSeconds, 0, timelineDuration - length);
2235
- onSetRange(newStart, newStart + length);
 
 
 
 
 
 
2236
  }
2237
- function onUp() {
 
 
2238
  window.removeEventListener("mousemove", onMove);
2239
  window.removeEventListener("mouseup", onUp);
2240
  }
@@ -2299,7 +2383,7 @@ function TimelineEditor({
2299
  <div className="timeline-track-label">V1</div>
2300
  <div className="timeline-track-lane video" ref={laneRef}>
2301
  <div
2302
- className="timeline-clip"
2303
  style={{
2304
  left: `${clipLeftPct}%`,
2305
  width: `${clipWidthPct}%`,
@@ -2311,7 +2395,7 @@ function TimelineEditor({
2311
  className="timeline-handle left"
2312
  onMouseDown={startEdgeDrag("left")}
2313
  />
2314
- {clip.title}
2315
  <span
2316
  className="timeline-handle right"
2317
  onMouseDown={startEdgeDrag("right")}
@@ -2328,7 +2412,7 @@ function TimelineEditor({
2328
  <div className="timeline-track-lane">
2329
  {cues.map((cue, index) => {
2330
  const cueLeft =
2331
- ((clip.start_seconds + cue.start_seconds) / timelineDuration) * 100;
2332
  const cueWidth =
2333
  ((cue.end_seconds - cue.start_seconds) / timelineDuration) * 100;
2334
  return (
@@ -2358,11 +2442,11 @@ function TimelineEditor({
2358
  <div className="timeline-track-label">A1</div>
2359
  <div className="timeline-track-lane audio">
2360
  <div className="timeline-waveform">
2361
- {Array.from({ length: 60 }).map((_, index) => (
2362
  <span
2363
  key={index}
2364
  style={{
2365
- height: `${30 + ((index * 19 + clip.id.length * 7) % 60)}%`,
2366
  }}
2367
  />
2368
  ))}
 
1761
  const [isPlaying, setIsPlaying] = useState(false);
1762
  const [selectedCueIndex, setSelectedCueIndex] = useState(0);
1763
 
1764
+ // DRAFT state for in-flight drag (no API calls during mousemove)
1765
+ const [trimDraft, setTrimDraft] = useState(null); // null | { start_seconds, end_seconds }
1766
+ const [captionDraft, setCaptionDraft] = useState(null); // null | { x, y }
1767
+
1768
+ // Effective values: drafts override committed clip values until release
1769
+ const effStart = trimDraft ? trimDraft.start_seconds : clip.start_seconds;
1770
+ const effEnd = trimDraft ? trimDraft.end_seconds : clip.end_seconds;
1771
+ const duration = Math.max(0.5, effEnd - effStart);
1772
+ const effCaptionStyle = captionDraft
1773
+ ? { ...captionStyle, ...captionDraft }
1774
+ : captionStyle;
1775
+
1776
  const cues = useMemo(
1777
+ () =>
1778
+ getSubtitleCues(
1779
+ { ...clip, start_seconds: effStart, end_seconds: effEnd },
1780
+ duration,
1781
+ captionStyle
1782
+ ),
1783
+ [clip, effStart, effEnd, duration, captionStyle]
1784
  );
1785
  const metadataModel = clip.metadata?.model || "unknown";
1786
  const sourceKind = job?.source?.kind || "video";
1787
 
1788
  const timelineDuration = Math.max(
1789
+ effEnd + 5,
1790
  ...(clips || []).map((c) => Number(c.end_seconds || 0)),
1791
  ...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
1792
  1
1793
  );
1794
 
1795
+ // Range-locked playback: video plays only within [effStart, effEnd]
1796
  useEffect(() => {
1797
  const video = videoRef.current;
1798
  if (!video) return;
1799
  function onTimeUpdate() {
1800
+ const ct = video.currentTime;
1801
+ if (ct >= effEnd - 0.05) {
1802
+ video.pause();
1803
+ try {
1804
+ video.currentTime = effStart;
1805
+ } catch {
1806
+ /* ignore */
1807
+ }
1808
+ } else if (ct < effStart - 0.5) {
1809
+ try {
1810
+ video.currentTime = effStart;
1811
+ } catch {
1812
+ /* ignore */
1813
+ }
1814
+ }
1815
+ setPlayhead(video.currentTime);
1816
  }
1817
  function onPlay() {
1818
  setIsPlaying(true);
 
1820
  function onPause() {
1821
  setIsPlaying(false);
1822
  }
 
 
 
 
 
 
 
 
1823
  video.addEventListener("timeupdate", onTimeUpdate);
1824
  video.addEventListener("play", onPlay);
1825
  video.addEventListener("pause", onPause);
 
 
1826
  return () => {
1827
  video.removeEventListener("timeupdate", onTimeUpdate);
1828
  video.removeEventListener("play", onPlay);
1829
  video.removeEventListener("pause", onPause);
 
1830
  };
1831
+ }, [effStart, effEnd]);
1832
 
1833
+ // When the clip itself changes (selected from bin), seek to its start
1834
  useEffect(() => {
1835
+ const video = videoRef.current;
1836
+ if (!video) return;
1837
+ function onLoaded() {
1838
+ try {
1839
+ video.currentTime = clip.start_seconds;
1840
+ } catch {
1841
+ /* ignore */
1842
+ }
1843
+ setPlayhead(clip.start_seconds);
1844
+ }
1845
+ if (video.readyState >= 1) onLoaded();
1846
+ else video.addEventListener("loadedmetadata", onLoaded, { once: true });
1847
+ video.load();
1848
+ return () => video.removeEventListener("loadedmetadata", onLoaded);
1849
+ }, [clip.id, clip.video_url, clip.start_seconds]);
1850
 
1851
+ // Active cue at current playhead
1852
+ const playheadInClip = clamp(playhead - effStart, 0, duration);
1853
+ const autoActive = cues.findIndex(
1854
  (cue) => playheadInClip >= cue.start_seconds && playheadInClip < cue.end_seconds
1855
  );
1856
  const activeIndex =
1857
  selectedCueIndex >= 0 && selectedCueIndex < cues.length
1858
  ? selectedCueIndex
1859
+ : Math.max(0, autoActive);
1860
  const activeCueText = cues[activeIndex]?.text || clip.subtitle_text || clip.title || "";
1861
 
1862
+ // ─── Mutations ──────────────────────────────────────────────
1863
+ function commitTrim(start, end) {
1864
+ setTrimDraft(null);
 
 
 
 
 
 
 
 
 
 
 
1865
  onPatch(clip.id, {
1866
  start_seconds: roundTime(start),
1867
  end_seconds: roundTime(end),
1868
  });
1869
  }
1870
+ function commitCaption(patch) {
1871
+ setCaptionDraft(null);
1872
+ onCaptionStyleChange(patch);
1873
+ }
1874
+ function patchCue(index, text) {
1875
+ const next = cues.map((cue, cueIndex) =>
1876
+ cueIndex === index ? { ...cue, text } : cue
1877
+ );
1878
+ onPatch(clip.id, { subtitle_text: next.map((cue) => cue.text).join(" ") });
1879
+ }
1880
  function setClipLength(seconds) {
1881
  onPatch(clip.id, {
1882
  end_seconds: roundTime(
 
1884
  ),
1885
  });
1886
  }
 
1887
  function seekTo(seconds) {
1888
  const video = videoRef.current;
1889
+ const target = clamp(seconds, effStart, effEnd);
1890
+ if (video) {
1891
+ try {
1892
+ video.currentTime = target;
1893
+ } catch {
1894
+ /* ignore */
1895
+ }
1896
+ }
1897
+ setPlayhead(target);
1898
  }
 
1899
  function togglePlay() {
1900
  const video = videoRef.current;
1901
  if (!video) return;
1902
+ if (video.paused) {
1903
+ if (video.currentTime < effStart || video.currentTime >= effEnd - 0.05) {
1904
+ try {
1905
+ video.currentTime = effStart;
1906
+ } catch {
1907
+ /* ignore */
1908
+ }
1909
+ }
1910
+ video.play();
1911
+ } else {
1912
+ video.pause();
1913
+ }
1914
  }
1915
 
1916
  return (
 
1924
  <div className="editor-topbar-info">
1925
  <h2>{clip.title}</h2>
1926
  <p>
1927
+ {formatTime(effStart)} – {formatTime(effEnd)} &nbsp;·&nbsp;{" "}
1928
  {duration.toFixed(1)}s &nbsp;·&nbsp; {Math.round(clip.score)} {t("score")}
1929
  </p>
1930
  </div>
 
1962
  <PreviewStage
1963
  clip={clip}
1964
  videoRef={videoRef}
1965
+ captionStyle={effCaptionStyle}
1966
  activeCueText={activeCueText}
1967
  isPlaying={isPlaying}
1968
  playhead={playhead}
1969
+ effStart={effStart}
1970
+ effEnd={effEnd}
1971
  onTogglePlay={togglePlay}
1972
  onSeekDelta={(delta) => seekTo(playhead + delta)}
1973
+ onCaptionDraftChange={setCaptionDraft}
1974
+ onCaptionCommit={commitCaption}
1975
  t={t}
1976
  />
1977
 
 
1990
  duration={duration}
1991
  timelineDuration={timelineDuration}
1992
  playhead={playhead}
1993
+ effStart={effStart}
1994
+ effEnd={effEnd}
1995
+ isDragging={trimDraft !== null}
1996
  selectedCueIndex={activeIndex}
1997
  onSelectCue={setSelectedCueIndex}
1998
  onSeek={seekTo}
1999
+ onTrimDraftChange={setTrimDraft}
2000
+ onTrimCommit={commitTrim}
 
2001
  t={t}
2002
  />
2003
 
 
2078
  activeCueText,
2079
  isPlaying,
2080
  playhead,
2081
+ effStart,
2082
+ effEnd,
2083
  onTogglePlay,
2084
  onSeekDelta,
2085
+ onCaptionDraftChange,
2086
+ onCaptionCommit,
2087
  t,
2088
  }) {
2089
  const stageRef = useRef(null);
 
2093
  const stage = stageRef.current;
2094
  if (!stage) return;
2095
  const rect = stage.getBoundingClientRect();
2096
+ function compute(ev) {
2097
  const x = clamp(((ev.clientX - rect.left) / rect.width) * 100, 4, 96);
2098
  const y = clamp(((ev.clientY - rect.top) / rect.height) * 100, 6, 94);
2099
+ return { x: Math.round(x), y: Math.round(y) };
2100
  }
2101
+ function onMove(ev) {
2102
+ onCaptionDraftChange(compute(ev));
2103
+ }
2104
+ function onUp(ev) {
2105
+ onCaptionCommit(compute(ev));
2106
  window.removeEventListener("mousemove", onMove);
2107
  window.removeEventListener("mouseup", onUp);
2108
  }
 
2110
  window.addEventListener("mouseup", onUp);
2111
  }
2112
 
2113
+ const playheadInClip = clamp(playhead - effStart, 0, Math.max(0.5, effEnd - effStart));
2114
+ const clipDuration = Math.max(0.5, effEnd - effStart);
2115
+
2116
  return (
2117
  <section className="nle-panel nle-preview">
2118
  <div className="nle-panel-head">
 
2155
  className="btn btn-icon btn-primary"
2156
  type="button"
2157
  onClick={onTogglePlay}
 
2158
  >
2159
  {isPlaying ? <Pause size={14} /> : <Play size={14} />}
2160
  </button>
 
2168
  </button>
2169
  </div>
2170
  <div className="preview-time">
2171
+ <strong>{formatTime(playheadInClip)}</strong> / {formatTime(clipDuration)}
2172
  </div>
2173
  <div className="preview-toolbar-right">
2174
  <span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}>
 
2228
  duration,
2229
  timelineDuration,
2230
  playhead,
2231
+ effStart,
2232
+ effEnd,
2233
+ isDragging,
2234
  selectedCueIndex,
2235
  onSelectCue,
2236
  onSeek,
2237
+ onTrimDraftChange,
2238
+ onTrimCommit,
 
2239
  t,
2240
  }) {
2241
  const laneRef = useRef(null);
 
2249
  return result;
2250
  }, [timelineDuration]);
2251
 
2252
+ const clipLeftPct = (effStart / timelineDuration) * 100;
2253
+ const clipWidthPct = ((effEnd - effStart) / timelineDuration) * 100;
2254
  const playheadPct = clamp((playhead / timelineDuration) * 100, 0, 100);
2255
 
2256
  function laneRect() {
 
2264
  event.stopPropagation();
2265
  const rect = laneRect();
2266
  if (!rect) return;
2267
+ // Snapshot at mousedown — drag uses these as the reference
2268
+ const initialStart = clip.start_seconds;
2269
+ const initialEnd = clip.end_seconds;
2270
+ function compute(ev) {
2271
  const ratio = clamp((ev.clientX - rect.left) / rect.width, 0, 1);
2272
  const seconds = roundTime(ratio * timelineDuration);
2273
  if (edge === "left") {
2274
+ return {
2275
+ start_seconds: clamp(seconds, 0, initialEnd - 0.5),
2276
+ end_seconds: initialEnd,
2277
+ };
2278
  }
2279
+ return {
2280
+ start_seconds: initialStart,
2281
+ end_seconds: clamp(seconds, initialStart + 0.5, timelineDuration),
2282
+ };
2283
  }
2284
+ function onMove(ev) {
2285
+ onTrimDraftChange(compute(ev));
2286
+ }
2287
+ function onUp(ev) {
2288
+ const final = compute(ev);
2289
+ onTrimCommit(final.start_seconds, final.end_seconds);
2290
  window.removeEventListener("mousemove", onMove);
2291
  window.removeEventListener("mouseup", onUp);
2292
  }
 
2296
  }
2297
 
2298
  function startBodyDrag(event) {
2299
+ // Ignore clicks that originated on a handle (handles stop propagation)
2300
  event.preventDefault();
2301
  const rect = laneRect();
2302
  if (!rect) return;
 
2304
  const initialStart = clip.start_seconds;
2305
  const initialEnd = clip.end_seconds;
2306
  const length = initialEnd - initialStart;
2307
+ function compute(ev) {
2308
  const dx = ev.clientX - startX;
2309
  const deltaSeconds = (dx / rect.width) * timelineDuration;
2310
  const newStart = clamp(initialStart + deltaSeconds, 0, timelineDuration - length);
2311
+ return {
2312
+ start_seconds: newStart,
2313
+ end_seconds: newStart + length,
2314
+ };
2315
+ }
2316
+ function onMove(ev) {
2317
+ onTrimDraftChange(compute(ev));
2318
  }
2319
+ function onUp(ev) {
2320
+ const final = compute(ev);
2321
+ onTrimCommit(final.start_seconds, final.end_seconds);
2322
  window.removeEventListener("mousemove", onMove);
2323
  window.removeEventListener("mouseup", onUp);
2324
  }
 
2383
  <div className="timeline-track-label">V1</div>
2384
  <div className="timeline-track-lane video" ref={laneRef}>
2385
  <div
2386
+ className={`timeline-clip ${isDragging ? "dragging" : ""}`}
2387
  style={{
2388
  left: `${clipLeftPct}%`,
2389
  width: `${clipWidthPct}%`,
 
2395
  className="timeline-handle left"
2396
  onMouseDown={startEdgeDrag("left")}
2397
  />
2398
+ <span className="timeline-clip-label">{clip.title}</span>
2399
  <span
2400
  className="timeline-handle right"
2401
  onMouseDown={startEdgeDrag("right")}
 
2412
  <div className="timeline-track-lane">
2413
  {cues.map((cue, index) => {
2414
  const cueLeft =
2415
+ ((effStart + cue.start_seconds) / timelineDuration) * 100;
2416
  const cueWidth =
2417
  ((cue.end_seconds - cue.start_seconds) / timelineDuration) * 100;
2418
  return (
 
2442
  <div className="timeline-track-label">A1</div>
2443
  <div className="timeline-track-lane audio">
2444
  <div className="timeline-waveform">
2445
+ {Array.from({ length: 80 }).map((_, index) => (
2446
  <span
2447
  key={index}
2448
  style={{
2449
+ height: `${24 + ((index * 17 + clip.id.length * 11) % 70)}%`,
2450
  }}
2451
  />
2452
  ))}
frontend/src/styles.css CHANGED
@@ -1550,7 +1550,7 @@ textarea:focus,
1550
  }
1551
 
1552
  .timeline-track-lane.video {
1553
- height: 64px;
1554
  }
1555
 
1556
  .timeline-track-lane.audio {
@@ -1563,30 +1563,42 @@ textarea:focus,
1563
  top: 6px;
1564
  bottom: 6px;
1565
  border-radius: 6px;
1566
- border: 1px solid var(--primary-dim);
1567
- background: linear-gradient(180deg, rgba(129, 140, 248, 0.32), rgba(129, 140, 248, 0.18));
1568
- color: var(--text);
1569
  display: flex;
1570
  align-items: center;
1571
- padding: 0 28px;
1572
- font-size: 0.74rem;
1573
- font-weight: 600;
1574
  white-space: nowrap;
1575
  overflow: hidden;
1576
- text-overflow: ellipsis;
1577
  cursor: grab;
1578
  user-select: none;
1579
- transition: filter 140ms ease;
 
1580
  }
1581
 
1582
  .timeline-clip:hover {
1583
- filter: brightness(1.12);
 
1584
  }
1585
 
1586
  .timeline-clip.dragging {
1587
  cursor: grabbing;
1588
- filter: brightness(1.2);
1589
- box-shadow: 0 0 0 1px var(--primary), var(--shadow-md);
 
 
 
 
 
 
 
 
 
 
 
1590
  }
1591
 
1592
  .timeline-handle {
 
1550
  }
1551
 
1552
  .timeline-track-lane.video {
1553
+ height: 72px;
1554
  }
1555
 
1556
  .timeline-track-lane.audio {
 
1563
  top: 6px;
1564
  bottom: 6px;
1565
  border-radius: 6px;
1566
+ border: 1px solid #6366f1;
1567
+ background: linear-gradient(180deg, #6366f1 0%, #4f46e5 100%);
1568
+ color: #ffffff;
1569
  display: flex;
1570
  align-items: center;
1571
+ padding: 0 22px;
1572
+ font-size: 0.78rem;
1573
+ font-weight: 700;
1574
  white-space: nowrap;
1575
  overflow: hidden;
 
1576
  cursor: grab;
1577
  user-select: none;
1578
+ transition: filter 140ms ease, box-shadow 140ms ease, transform 100ms ease;
1579
+ box-shadow: 0 2px 10px rgba(79, 70, 229, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.18);
1580
  }
1581
 
1582
  .timeline-clip:hover {
1583
+ filter: brightness(1.1);
1584
+ box-shadow: 0 4px 14px rgba(79, 70, 229, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.22);
1585
  }
1586
 
1587
  .timeline-clip.dragging {
1588
  cursor: grabbing;
1589
+ filter: brightness(1.18);
1590
+ box-shadow: 0 0 0 2px var(--accent), 0 6px 18px rgba(79, 70, 229, 0.6);
1591
+ transform: scale(1.005);
1592
+ }
1593
+
1594
+ .timeline-clip-label {
1595
+ flex: 1;
1596
+ min-width: 0;
1597
+ overflow: hidden;
1598
+ text-overflow: ellipsis;
1599
+ pointer-events: none;
1600
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
1601
+ padding: 0 4px;
1602
  }
1603
 
1604
  .timeline-handle {