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

fix(editor): kill empty band + first-frame preview + polish

Browse files

Layout repair:
- Empty band above the editor topbar was caused by .editor-topbar
being position:sticky;top:72px while inside .editor-shell.nle
(overflow:hidden, no scroll context). The 72px offset rendered as
a 72px gap. Override to position:relative;top:auto in NLE mode.
- Compact AppHeader (56px) when in editor view; hide subtitle line.
Reclaim ~50px of vertical real estate for the timeline.

Preview stability:
- Add playsInline + preload=metadata + muted to the preview <video>.
- video.load() + currentTime=0.05 on clip change forces the first
frame to render so the canvas isn't blank before user interaction.

Trim handles:
- Widen drag handles from 10px to 14px and bump z-index to 4.
- Switch to amber/black for higher contrast against the indigo clip.
- Hover scales the handle 1.06x for clearer affordance.

AI Assistant copy:
- Subtitles were redundant ("ทำใหม่" / "—"). Replace with platform-
specific guidance ("Best for Reels & Shorts", "Best for TikTok
storytelling") so each tile communicates a distinct intent.
- Rename Tighten/Emphasize labels to explicit durations (30s/60s).
- Add aiDeleteClip + four ai*Sub keys across all 5 languages.

Inspector cleanup:
- Drop the duplicate inner "Inspector" h4 (panel head already labels it).

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

Files changed (2) hide show
  1. frontend/src/App.jsx +65 -23
  2. frontend/src/styles.css +57 -16
frontend/src/App.jsx CHANGED
@@ -282,9 +282,14 @@ const en = {
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
  };
@@ -451,9 +456,14 @@ const translations = {
451
  mediaBin: "คลิปทั้งหมด",
452
  aiAssistant: "ผู้ช่วย AI",
453
  aiReason: "AI ยังไม่ได้อธิบาย ลองสร้างใหม่ดูสิ",
454
- aiTighten: "ตัด้กระชับ",
455
- aiEmphasize: "เน้นจุดเด่น",
456
  aiRedoAll: "สร้างคลิปนี้ใหม่",
 
 
 
 
 
457
  dragToTrim: "ลากขอบเพื่อ trim · ลากกลางเพื่อย้าย",
458
  dragToPosition: "ลากข้อความเพื่อย้ายตำแหน่ง",
459
  },
@@ -618,9 +628,14 @@ const translations = {
618
  mediaBin: "クリップ一覧",
619
  aiAssistant: "AIアシスタント",
620
  aiReason: "AIの説明はまだありません。再生成してみてください。",
621
- aiTighten: "短くまとめる",
622
- aiEmphasize: "ハイライトを強調",
623
  aiRedoAll: "このクリップを再生成",
 
 
 
 
 
624
  dragToTrim: "端をドラッグでトリム · 中央をドラッグで移動",
625
  dragToPosition: "字幕をドラッグして移動",
626
  },
@@ -784,9 +799,14 @@ const translations = {
784
  mediaBin: "片段列表",
785
  aiAssistant: "AI 助手",
786
  aiReason: "AI 还没解释,试试重新生成。",
787
- aiTighten: "更紧凑",
788
- aiEmphasize: "突出高潮",
789
  aiRedoAll: "重新生成此片段",
 
 
 
 
 
790
  dragToTrim: "拖动边缘修剪 · 拖动中央移动",
791
  dragToPosition: "拖动字幕移动位置",
792
  },
@@ -951,9 +971,14 @@ const translations = {
951
  mediaBin: "클립 목록",
952
  aiAssistant: "AI 어시스턴트",
953
  aiReason: "AI가 아직 설명하지 않았습니다. 다시 만들어 보세요.",
954
- aiTighten: " 트하게",
955
- aiEmphasize: "하이라이트 강조",
956
  aiRedoAll: "이 클립 다시 만들기",
 
 
 
 
 
957
  dragToTrim: "끝을 드래그해 트림 · 가운데를 드래그해 이동",
958
  dragToPosition: "자막을 드래그해 이동",
959
  },
@@ -1137,6 +1162,7 @@ function App() {
1137
  theme={theme}
1138
  setTheme={setTheme}
1139
  t={t}
 
1140
  />
1141
 
1142
  {view === "editor" && editorClipId && editorClip ? (
@@ -1254,20 +1280,20 @@ function Dashboard({
1254
  // ============================================================
1255
  // App Header
1256
  // ============================================================
1257
- function AppHeader({ job, health, language, setLanguage, theme, setTheme, t }) {
1258
  const status = job?.status || "idle";
1259
  const modeLabel = health ? (health.demo_mode ? t("demoMode") : t("productionMode")) : "API";
1260
  const modeClass = health ? (health.demo_mode ? "demo" : "prod") : "";
1261
 
1262
  return (
1263
- <header className="app-header">
1264
  <div className="brand-block">
1265
  <div className="brand-mark">
1266
  <Scissors size={20} />
1267
  </div>
1268
  <div>
1269
  <h1>ElevenClip.AI</h1>
1270
- <p>{t("appSubtitle")}</p>
1271
  </div>
1272
  </div>
1273
 
@@ -1750,7 +1776,7 @@ function ClipEditorPage({
1750
  1
1751
  );
1752
 
1753
- // Sync playhead with video element
1754
  useEffect(() => {
1755
  const video = videoRef.current;
1756
  if (!video) return;
@@ -1763,15 +1789,26 @@ function ClipEditorPage({
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(() => {
@@ -2026,7 +2063,13 @@ function PreviewStage({
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
  )}
@@ -2361,7 +2404,7 @@ function AIAssistantPanel({ clip, t, onRegenerate, onTighten, onFitLength, onDel
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}>
@@ -2370,7 +2413,7 @@ function AIAssistantPanel({ clip, t, onRegenerate, onTighten, onFitLength, onDel
2370
  </span>
2371
  <span className="ai-action-text">
2372
  <strong>{t("aiTighten")}</strong>
2373
- <small>30s</small>
2374
  </span>
2375
  </button>
2376
  <button
@@ -2383,7 +2426,7 @@ function AIAssistantPanel({ clip, t, onRegenerate, onTighten, onFitLength, onDel
2383
  </span>
2384
  <span className="ai-action-text">
2385
  <strong>{t("aiEmphasize")}</strong>
2386
- <small>60s</small>
2387
  </span>
2388
  </button>
2389
  <button
@@ -2398,8 +2441,8 @@ function AIAssistantPanel({ clip, t, onRegenerate, onTighten, onFitLength, onDel
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>
@@ -2433,7 +2476,6 @@ function EditorInspector({
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>
 
282
  mediaBin: "Clips",
283
  aiAssistant: "AI Assistant",
284
  aiReason: "AI hasn't explained yet — try regenerating.",
285
+ aiTighten: "Tighten to 30s",
286
+ aiEmphasize: "Extend to 60s",
287
+ aiRedoAll: "Regenerate clip",
288
+ aiDeleteClip: "Remove clip",
289
+ aiActionRedoSub: "Let AI pick a new moment",
290
+ aiActionTightenSub: "Best for Reels & Shorts",
291
+ aiActionEmphasizeSub: "Best for TikTok storytelling",
292
+ aiActionDeleteSub: "Drop from this batch",
293
  dragToTrim: "Drag edges to trim · drag body to move",
294
  dragToPosition: "Drag caption to reposition",
295
  };
 
456
  mediaBin: "คลิปทั้งหมด",
457
  aiAssistant: "ผู้ช่วย AI",
458
  aiReason: "AI ยังไม่ได้อธิบาย ลองสร้างใหม่ดูสิ",
459
+ aiTighten: "ตัดลือ 30 วิ",
460
+ aiEmphasize: "ขยายป็ 60 วิ",
461
  aiRedoAll: "สร้างคลิปนี้ใหม่",
462
+ aiDeleteClip: "ลบคลิปนี้",
463
+ aiActionRedoSub: "ให้ AI หาช่วงใหม่",
464
+ aiActionTightenSub: "เหมาะกับ Reels และ Shorts",
465
+ aiActionEmphasizeSub: "เหมาะกับ TikTok แบบเล่าเรื่อง",
466
+ aiActionDeleteSub: "เอาออกจากชุดนี้",
467
  dragToTrim: "ลากขอบเพื่อ trim · ลากกลางเพื่อย้าย",
468
  dragToPosition: "ลากข้อความเพื่อย้ายตำแหน่ง",
469
  },
 
628
  mediaBin: "クリップ一覧",
629
  aiAssistant: "AIアシスタント",
630
  aiReason: "AIの説明はまだありません。再生成してみてください。",
631
+ aiTighten: "30秒に",
632
+ aiEmphasize: "60秒に延長",
633
  aiRedoAll: "このクリップを再生成",
634
+ aiDeleteClip: "クリップを削除",
635
+ aiActionRedoSub: "AIに別の場面を選ばせる",
636
+ aiActionTightenSub: "Reels・Shortsに最適",
637
+ aiActionEmphasizeSub: "TikTokのストーリーテリングに最適",
638
+ aiActionDeleteSub: "このバッチから外す",
639
  dragToTrim: "端をドラッグでトリム · 中央をドラッグで移動",
640
  dragToPosition: "字幕をドラッグして移動",
641
  },
 
799
  mediaBin: "片段列表",
800
  aiAssistant: "AI 助手",
801
  aiReason: "AI 还没解释,试试重新生成。",
802
+ aiTighten: "压缩到 30 秒",
803
+ aiEmphasize: "延长到 60 秒",
804
  aiRedoAll: "重新生成此片段",
805
+ aiDeleteClip: "删除此片段",
806
+ aiActionRedoSub: "让 AI 找新的精彩瞬间",
807
+ aiActionTightenSub: "适合 Reels 和 Shorts",
808
+ aiActionEmphasizeSub: "适合 TikTok 故事化内容",
809
+ aiActionDeleteSub: "从本批次移除",
810
  dragToTrim: "拖动边缘修剪 · 拖动中央移动",
811
  dragToPosition: "拖动字幕移动位置",
812
  },
 
971
  mediaBin: "클립 목록",
972
  aiAssistant: "AI 어시스턴트",
973
  aiReason: "AI가 아직 설명하지 않았습니다. 다시 만들어 보세요.",
974
+ aiTighten: "30초로 ",
975
+ aiEmphasize: "60초로 늘리기",
976
  aiRedoAll: "이 클립 다시 만들기",
977
+ aiDeleteClip: "클립 삭제",
978
+ aiActionRedoSub: "AI가 다른 장면을 찾도록",
979
+ aiActionTightenSub: "Reels와 Shorts에 적합",
980
+ aiActionEmphasizeSub: "TikTok 스토리텔링에 적합",
981
+ aiActionDeleteSub: "이번 배치에서 제외",
982
  dragToTrim: "끝을 드래그해 트림 · 가운데를 드래그해 이동",
983
  dragToPosition: "자막을 드래그해 이동",
984
  },
 
1162
  theme={theme}
1163
  setTheme={setTheme}
1164
  t={t}
1165
+ compact={view === "editor"}
1166
  />
1167
 
1168
  {view === "editor" && editorClipId && editorClip ? (
 
1280
  // ============================================================
1281
  // App Header
1282
  // ============================================================
1283
+ function AppHeader({ job, health, language, setLanguage, theme, setTheme, t, compact }) {
1284
  const status = job?.status || "idle";
1285
  const modeLabel = health ? (health.demo_mode ? t("demoMode") : t("productionMode")) : "API";
1286
  const modeClass = health ? (health.demo_mode ? "demo" : "prod") : "";
1287
 
1288
  return (
1289
+ <header className={`app-header ${compact ? "compact" : ""}`}>
1290
  <div className="brand-block">
1291
  <div className="brand-mark">
1292
  <Scissors size={20} />
1293
  </div>
1294
  <div>
1295
  <h1>ElevenClip.AI</h1>
1296
+ {!compact && <p>{t("appSubtitle")}</p>}
1297
  </div>
1298
  </div>
1299
 
 
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;
 
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(() => {
 
2063
  <div className="preview-stage" ref={stageRef}>
2064
  <div className="preview-stage-canvas">
2065
  {clip.video_url ? (
2066
+ <video
2067
+ ref={videoRef}
2068
+ src={`${API_BASE}${clip.video_url}`}
2069
+ playsInline
2070
+ preload="metadata"
2071
+ muted
2072
+ />
2073
  ) : (
2074
  <Film size={56} style={{ color: "var(--text-soft)" }} />
2075
  )}
 
2404
  </span>
2405
  <span className="ai-action-text">
2406
  <strong>{t("aiRedoAll")}</strong>
2407
+ <small>{t("aiActionRedoSub")}</small>
2408
  </span>
2409
  </button>
2410
  <button type="button" className="ai-action" onClick={onTighten}>
 
2413
  </span>
2414
  <span className="ai-action-text">
2415
  <strong>{t("aiTighten")}</strong>
2416
+ <small>{t("aiActionTightenSub")}</small>
2417
  </span>
2418
  </button>
2419
  <button
 
2426
  </span>
2427
  <span className="ai-action-text">
2428
  <strong>{t("aiEmphasize")}</strong>
2429
+ <small>{t("aiActionEmphasizeSub")}</small>
2430
  </span>
2431
  </button>
2432
  <button
 
2441
  <Trash2 size={14} />
2442
  </span>
2443
  <span className="ai-action-text">
2444
+ <strong>{t("aiDeleteClip")}</strong>
2445
+ <small>{t("aiActionDeleteSub")}</small>
2446
  </span>
2447
  </button>
2448
  </div>
 
2476
  <div className="nle-panel-body">
2477
  <div className="inspector-stack">
2478
  <section>
 
2479
  <dl className="inspector-meta">
2480
  <div>
2481
  <dt>{t("score")}</dt>
frontend/src/styles.css CHANGED
@@ -183,6 +183,22 @@ button:disabled {
183
  -webkit-backdrop-filter: blur(20px);
184
  }
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  :root[data-theme="light"] .app-header {
187
  background: rgba(255, 255, 255, 0.88);
188
  }
@@ -1128,11 +1144,28 @@ textarea:focus,
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;
@@ -1558,41 +1591,49 @@ textarea:focus,
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 {
 
183
  -webkit-backdrop-filter: blur(20px);
184
  }
185
 
186
+ .app-header.compact {
187
+ min-height: 56px;
188
+ height: 56px;
189
+ padding: 0 clamp(12px, 3vw, 32px);
190
+ gap: 14px;
191
+ }
192
+
193
+ .app-header.compact .brand-mark {
194
+ width: 32px;
195
+ height: 32px;
196
+ }
197
+
198
+ .app-header.compact h1 {
199
+ font-size: 0.95rem;
200
+ }
201
+
202
  :root[data-theme="light"] .app-header {
203
  background: rgba(255, 255, 255, 0.88);
204
  }
 
1144
  NLE 4-panel editor (Premiere-style)
1145
  ============================================================ */
1146
  .editor-shell.nle {
1147
+ height: calc(100vh - 56px);
1148
+ min-height: calc(100vh - 56px);
1149
  overflow: hidden;
1150
  }
1151
 
1152
+ .editor-shell.nle .editor-topbar {
1153
+ position: relative;
1154
+ top: auto;
1155
+ z-index: auto;
1156
+ padding: 10px clamp(14px, 2vw, 28px);
1157
+ flex: 0 0 auto;
1158
+ }
1159
+
1160
+ .editor-shell.nle .editor-topbar-info h2 {
1161
+ font-size: 0.86rem;
1162
+ }
1163
+
1164
+ .editor-shell.nle .editor-topbar-info p {
1165
+ font-size: 0.72rem;
1166
+ margin-top: 2px;
1167
+ }
1168
+
1169
  .editor-grid-nle {
1170
  display: grid;
1171
  grid-template-columns: 240px minmax(0, 1fr) 340px;
 
1591
 
1592
  .timeline-handle {
1593
  position: absolute;
1594
+ top: -2px;
1595
+ bottom: -2px;
1596
+ width: 14px;
1597
+ background: var(--accent);
1598
  cursor: ew-resize;
1599
+ z-index: 4;
1600
  display: flex;
1601
  align-items: center;
1602
  justify-content: center;
1603
+ opacity: 0.95;
1604
+ transition: opacity 140ms ease, background 140ms ease, transform 140ms ease;
1605
+ border: 1px solid rgba(0, 0, 0, 0.35);
1606
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
1607
  }
1608
 
1609
  .timeline-handle::before {
1610
  content: "";
1611
+ width: 3px;
1612
+ height: 18px;
1613
+ background: #1a1300;
1614
+ border-radius: 1.5px;
1615
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
1616
  }
1617
 
1618
  .timeline-handle:hover,
1619
  .timeline-handle.dragging {
1620
  opacity: 1;
1621
+ background: #fbbf24;
1622
+ transform: scaleY(1.06);
1623
  }
1624
 
1625
  .timeline-handle.left {
1626
  left: 0;
1627
+ border-radius: 4px 0 0 4px;
1628
  }
1629
 
1630
  .timeline-handle.right {
1631
  right: 0;
1632
+ border-radius: 0 4px 4px 0;
1633
+ }
1634
+
1635
+ .timeline-clip {
1636
+ padding: 0 22px;
1637
  }
1638
 
1639
  .timeline-caption-block {