File size: 40,129 Bytes
c55989f
 
 
 
d4a04dd
 
 
 
 
0ce96ff
 
 
 
 
c55989f
0ce96ff
 
 
 
 
 
9206420
 
 
 
 
 
 
c55989f
 
 
 
 
 
 
 
 
 
d4a04dd
c55989f
0ce96ff
c55989f
 
 
 
 
 
 
 
788bc93
 
 
 
 
 
 
 
 
 
 
 
c55989f
788bc93
c55989f
 
 
 
 
 
 
 
4f9859d
 
 
d908e31
 
4f9859d
 
6547b89
4f9859d
 
d908e31
c55989f
 
d908e31
4f9859d
 
c55989f
 
3370310
 
 
c55989f
788bc93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c55989f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad3a570
 
c55989f
ad3a570
 
 
c55989f
 
 
 
 
4f9859d
c55989f
 
ad3a570
 
 
 
 
 
 
 
 
 
 
 
 
 
c55989f
 
 
4f9859d
c55989f
 
 
 
 
 
 
 
 
 
 
 
 
 
00151ba
 
 
 
 
 
 
c55989f
2621912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c55989f
 
 
 
 
 
 
 
d4a04dd
0ce96ff
 
 
c55989f
 
 
 
 
 
 
 
 
 
d908e31
 
 
 
 
 
788bc93
 
 
 
 
 
 
 
 
 
c55989f
 
 
 
d4a04dd
 
c55989f
 
d908e31
c55989f
3370310
c55989f
9f6c247
 
 
c55989f
 
 
d908e31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b53507
 
 
 
4f9859d
1b53507
d908e31
4f9859d
 
 
 
 
 
6547b89
4f9859d
d908e31
 
4f9859d
 
6547b89
4f9859d
6547b89
4f9859d
 
6547b89
d908e31
c55989f
 
0ce96ff
 
 
 
 
 
 
c55989f
0ce96ff
 
 
 
 
c55989f
 
0ce96ff
 
 
 
 
 
 
 
 
1b53507
 
 
d908e31
 
 
c55989f
 
 
d908e31
1b53507
c55989f
 
 
 
 
 
 
 
 
788bc93
 
 
 
 
 
c55989f
 
 
 
d908e31
 
c55989f
d908e31
4f9859d
 
 
1b53507
4f9859d
 
 
 
 
6547b89
 
 
c55989f
 
 
 
 
 
ad3a570
0b41d4a
ad3a570
 
0b41d4a
ad3a570
0b41d4a
ad3a570
0b41d4a
ad3a570
0b41d4a
ad3a570
 
c55989f
 
 
 
 
 
 
 
 
d908e31
 
4f9859d
d908e31
4f9859d
d908e31
 
4f9859d
 
d908e31
 
4f9859d
 
1b53507
 
 
 
4f9859d
 
c55989f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788bc93
c55989f
 
788bc93
 
 
 
 
 
c55989f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00151ba
2621912
 
 
00151ba
 
 
c55989f
 
 
 
 
 
 
ad3a570
c55989f
 
 
 
 
 
 
9f6c247
c55989f
 
 
 
d908e31
 
 
 
 
 
 
 
 
 
 
 
 
c55989f
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
import { useEffect, useRef, useState } from "react";
import { api, fileUrl, pollJob } from "../api";
import { IcDownload, IcPlus, IcTrash } from "./Icons";

// Font NHÚNG kèm app (backend/app/assets/fonts, nạp qua /fonts) — mã nguồn mở,
// hỗ trợ đầy đủ tiếng Việt/en/fil. Trình duyệt (css/canvas) và libass (ass) dùng
// CHUNG file font nên bản xem trước khớp video render trên MỌI máy chủ, không cần
// font Microsoft. `ass` = ĐÚNG family name nội bộ của .ttf (khớp Fontname libass).
// Các file là face nét đậm sẵn, khai báo weight thường -> KHÔNG làm-đậm-giả.
// k = HỆ SỐ CO CỠ CHỮ CSS↔libass (đo thực nghiệm). libass vẽ `Fontsize=F` ra cỡ
// pixel NHỎ hơn CSS `font-size:F` (CSS đặt EM = F px). Đo render_frame(libass) vs
// canvas trình duyệt cùng ngưỡng (chuỗi H/Hg/Hàng) -> tỉ lệ ink_libass/ink_css NHẤT
// QUÁN <1% -> là hệ số co cố định mỗi font. Nhân k vào cỡ chữ CSS preview để KHỚP
// libass. (KHÔNG dùng mscale=hheaH/winH lý thuyết: libass build này vẽ nhỏ hơn nữa.)
const FONTS = [
  { label: "Be Vietnam Pro", css: "'Be Vietnam Pro', sans-serif", ass: "Be Vietnam Pro", k: 0.655 },
  { label: "Roboto", css: "'Roboto', sans-serif", ass: "Roboto", k: 0.829 },
  { label: "Open Sans", css: "'Open Sans', sans-serif", ass: "Open Sans", k: 0.692 },
  { label: "Montserrat", css: "'Montserrat', sans-serif", ass: "Montserrat", k: 0.641 },
  { label: "Arimo (giống Arial)", css: "'Arimo', sans-serif", ass: "Arimo", k: 0.694 },
  { label: "Tinos (serif, giống Times)", css: "'Tinos', serif", ass: "Tinos", k: 0.905 },
  { label: "Oswald (hẹp, cho tiêu đề)", css: "'Oswald', sans-serif", ass: "Oswald", k: 0.587 },
  { label: "Nunito (bo tròn)", css: "'Nunito', sans-serif", ass: "Nunito", k: 0.724 },
  { label: "Lato", css: "'Lato', sans-serif", ass: "Lato", k: 0.714 },
  { label: "Merriweather (serif)", css: "'Merriweather', serif", ass: "Merriweather", k: 0.577 },
  { label: "Playfair Display (serif sang)", css: "'Playfair Display', serif", ass: "Playfair Display", k: 0.705 },
  { label: "Kanit (đậm, cho phụ đề)", css: "'Kanit', sans-serif", ass: "Kanit", k: 0.630 },
  { label: "Dancing Script (viết tay)", css: "'Dancing Script', cursive", ass: "Dancing Script", k: 0.710 },
];
const BGS = [
  ["Viền đen mỏng", "outlineThin"], ["Viền đen đậm", "outlineThick"],
  ["Viền trắng", "outlineWhite"], ["Bóng đổ", "shadow"], ["Phát sáng", "glow"],
  ["Hộp đen mờ", "boxDark"], ["Hộp đen đậm", "boxSolid"], ["Hộp bo tròn", "boxRound"],
  ["Thanh nền cả dòng", "bar"], ["Hộp màu highlight", "boxColor"], ["Không nền", "none"],
];
const MODES = [
  ["whole", "Hiện cả câu"], ["progressive", "Hiện dần"], ["highlight", "Highlight từng chữ"],
];
const DEF = { font: "Be Vietnam Pro", fontSize: 54, color: "#ffffff", highlight: "#ffd400", bg: "outlineThin", x: 50, y: 86, w: 82 };
const cssOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).css;
const kOf = (ass) => (FONTS.find((f) => f.ass === ass) || FONTS[0]).k;
const trunc = (t, n) => { t = (t || "").trim(); return t.length > n ? t.slice(0, n).trim() + "…" : t; };
const wordsFor = (text, start, end) => {
  const words = (text || "").split(/\s+/).filter(Boolean);
  const dur = Math.max(0.2, end - start);
  const wt = words.map((w) => w.length + 1); const tot = wt.reduce((a, b) => a + b, 0) || 1;
  let t = start; return words.map((w, i) => { const wd = dur * wt[i] / tot; const o = { w, start: t, end: t + wd }; t += wd; return o; });
};

export default function SubtitleEditor({
  session, videoUrl, videoRel, srtRel, username, onClose,
  // ---- Chế độ dùng lại cho tab "Chèn chữ vào video" (nhiều video) ----
  initialSegs = null,   // segs khởi tạo (khôi phục khi chuyển qua lại video)
  initialMode = null,   // mode khởi tạo
  onChange = null,      // (segs, mode, payload) -> đẩy state lên cha để lưu/áp form
  headerExtra = null,   // JSX chèn thêm ở header (vd nút "Áp form cho mọi video")
  onFetchLines = null,  // async () -> [{start,end,text}] : nhận dạng lời từ video (ASR)
}) {
  // Nhân bản sâu segs khởi tạo -> không dùng chung tham chiếu với state cha.
  const [segs, setSegs] = useState(() =>
    initialSegs ? JSON.parse(JSON.stringify(initialSegs)) : []);
  const [sel, setSel] = useState(0);
  const [mode, setMode] = useState(initialMode || "whole");
  const [playing, setPlaying] = useState(false);
  const [t, setT] = useState(0);
  const [dur, setDur] = useState(0);
  const [thumbs, setThumbs] = useState([]);
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState(null);
  const [editing, setEditing] = useState(false);
  const [, force] = useState(0);
  // Xem trước KHỚP 100%: khi TẠM DỪNG, server render đúng 1 khung bằng CHÍNH
  // ffmpeg/libass của bản burn -> ảnh giống HỆT video xuất (không dùng bộ vẽ thứ 2
  // như libass-wasm vốn khác phiên bản libass server nên lệch).
  const [exact, setExact] = useState(true);
  const [fit, setFit] = useState({ w: 0, h: 0 });   // cỡ hiển thị video = nội dung THẬT (bỏ letterbox)
  const [previewImg, setPreviewImg] = useState(null); // ảnh khung chuẩn hiện đè video
  const [previewLoading, setPreviewLoading] = useState(false);
  const [previewErr, setPreviewErr] = useState(false); // server render khung lỗi
  const [dragging, setDragging] = useState(false);   // đang kéo/giãn ô -> hiện CSS nhanh
  const [frame, setFrame] = useState(null);          // ảnh xem chuẩn PHÓNG TO (modal)
  const [frameBusy, setFrameBusy] = useState(false);
  const videoRef = useRef();
  const wrapRef = useRef();
  const colRef = useRef();          // cột trái -> đo bề rộng khả dụng để fit video
  const frameTimer = useRef(null);  // debounce auto render khung chuẩn
  const frameSeq = useRef(0);       // chống race: chỉ nhận kết quả mới nhất
  const nat = useRef({ w: 1080, h: 1920 });
  const act = useRef(null);
  // Huỷ vòng poll job burn phụ đề khi đóng editor / rời trang.
  const pollAbort = useRef(new AbortController());
  useEffect(() => () => pollAbort.current.abort(), []);

  // Nhận dạng LỜI GỐC trong video (ASR) -> đổ vào editor thành các đoạn chữ SỬA
  // ĐƯỢC (không khoá), để user tạo kiểu & burn. Dùng ở tab "Chèn chữ vào video".
  const [fetchingLines, setFetchingLines] = useState(false);
  const loadLines = async () => {
    if (!onFetchLines) return;
    if (segs.some((s) => !s.locked) &&
        !window.confirm("Thay các đoạn chữ đang có bằng lời nhận dạng từ video?")) return;
    setFetchingLines(true);
    try {
      const lines = await onFetchLines();
      const loaded = (lines || []).map((s) => ({
        start: +s.start, end: +s.end, text: s.text || "", locked: false,
        words: wordsFor(s.text, s.start, s.end),
        style: JSON.parse(JSON.stringify(DEF)),
      }));
      if (!loaded.length) { alert("Không nhận được lời nào từ video (có thể video không có tiếng nói)."); return; }
      setSegs((cur) => [...cur.filter((x) => x.locked), ...loaded]);
      setSel(0);
    } catch (e) { alert("Lỗi nhận dạng lời: " + (e?.message || e)); }
    finally { setFetchingLines(false); }
  };

  // Tự sinh phụ đề từ lời dịch (SRT). User bấm nút này khi muốn.
  const autoSubtitle = async () => {
    try {
      const r = await api.subtitlePlan(session, srtRel);
      const loaded = (r.segments || []).map((s) => ({
        ...s, locked: true, words: s.words || wordsFor(s.text, s.start, s.end),
        style: JSON.parse(JSON.stringify(DEF)),
      }));
      setSegs((cur) => [...cur.filter((x) => !x.locked), ...loaded]);
    } catch (e) { alert("Lỗi tạo phụ đề: " + e.message); }
  };

  // chụp ~8 thumbnail từ video (video same-origin nên canvas không bị taint)
  useEffect(() => {
    const v = document.createElement("video");
    v.src = videoUrl; v.muted = true; v.crossOrigin = "anonymous";
    const seek = (time) => new Promise((res) => { const h = () => { v.removeEventListener("seeked", h); res(); }; v.addEventListener("seeked", h); v.currentTime = time; });
    v.addEventListener("loadedmetadata", async () => {
      try {
        const N = 8, cv = document.createElement("canvas"); cv.width = 64; cv.height = 114;
        const ctx = cv.getContext("2d"); const out = [];
        for (let i = 0; i < N; i++) { await seek(v.duration * (i + 0.5) / N); ctx.drawImage(v, 0, 0, cv.width, cv.height); out.push(cv.toDataURL("image/jpeg", 0.5)); }
        setThumbs(out);
      } catch { /* bỏ qua nếu chụp lỗi */ }
    });
    // eslint-disable-next-line
  }, [videoUrl]);

  // đồng bộ thời gian video -> state (rAF cho mượt)
  useEffect(() => {
    const v = videoRef.current; if (!v) return; let raf;
    const loop = () => { setT(v.currentTime); raf = requestAnimationFrame(loop); };
    const onMeta = () => { setDur(v.duration); nat.current = { w: v.videoWidth || 1080, h: v.videoHeight || 1920 }; };
    const onPlay = () => { setPlaying(true); cancelAnimationFrame(raf); loop(); };
    const onPause = () => { setPlaying(false); cancelAnimationFrame(raf); setT(v.currentTime); };
    v.addEventListener("loadedmetadata", onMeta); v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); v.addEventListener("seeked", () => setT(v.currentTime));
    return () => { cancelAnimationFrame(raf); v.removeEventListener("loadedmetadata", onMeta); v.removeEventListener("play", onPlay); v.removeEventListener("pause", onPause); };
    // eslint-disable-next-line
  }, []);

  const patch = (i, p) => setSegs((cur) => cur.map((s, j) => (j === i ? { ...s, ...p } : s)));
  const patchStyle = (p) => setSegs((cur) => cur.map((s, j) => (j === sel ? { ...s, style: { ...s.style, ...p } } : s)));
  const cur = () => segs[sel];

  // đoạn đang hiển thị theo thời gian (nhiều đoạn có thể cùng lúc)
  const activeIdx = segs.map((s, i) => i).filter((i) => t >= segs[i].start && t <= segs[i].end);
  useEffect(() => { if (!editing && activeIdx.length && !activeIdx.includes(sel)) setSel(activeIdx[activeIdx.length - 1]); });

  const total = dur || 1;
  const scale = () => { const v = videoRef.current; return v && v.videoHeight ? v.clientHeight / v.videoHeight : 1; };

  // ---- lanes cho timeline ----
  const lanes = (() => {
    const order = segs.map((s, i) => i).sort((a, b) => segs[a].start - segs[b].start);
    const ends = []; const lane = {};
    order.forEach((i) => { const s = segs[i]; let L = ends.findIndex((e) => s.start >= e - 0.001); if (L < 0) { L = ends.length; ends.push(s.end); } else ends[L] = s.end; lane[i] = L; });
    return { lane, n: Math.max(1, ends.length) };
  })();

  const seekTo = (time) => { const v = videoRef.current; if (v) { v.currentTime = Math.max(0, Math.min(total, time)); } setPlaying(false); if (v) v.pause(); };

  // ---- kéo/giãn ô text trên video ----
  const boxDown = (i) => (e) => {
    if (editing || e.target.dataset.h) return;
    setSel(i); const v = videoRef.current; if (v) v.pause();
    act.current = { kind: "move", i, sx: e.clientX, sy: e.clientY, moved: false };
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  // dir = "w" (tay trái/phải -> đổi BỀ RỘNG vùng) | "h" (tay trên/dưới -> đổi CỠ CHỮ).
  const handleDown = (i, dir) => (e) => {
    if (editing) return; e.stopPropagation(); setSel(i); const v = videoRef.current; if (v) v.pause();
    // Số dòng cố định trong lúc kéo -> ánh xạ cỡ chữ ổn định, không nhảy.
    const nLines = measureBreaks(segs[i]).length + 1;
    act.current = { kind: "resize", dir, i, sx: e.clientX, sy: e.clientY, nLines, moved: false };
    e.currentTarget.parentElement.setPointerCapture(e.pointerId);
  };
  const boxMove = (e) => {
    const a = act.current; if (!a) return;
    if (!a.moved && Math.hypot(e.clientX - a.sx, (e.clientY || 0) - (a.sy || 0)) < 3) return;
    if (!a.moved) setDragging(true);   // bắt đầu kéo thật -> hiện CSS nhanh cho mượt
    a.moved = true; const r = wrapRef.current.getBoundingClientRect(); const s = segs[a.i].style;
    if (a.kind === "move") { const hw = s.w / 2; patch(a.i, { style: { ...s, x: Math.max(hw + 1, Math.min(99 - hw, (e.clientX - r.left) / r.width * 100)), y: Math.max(6, Math.min(94, (e.clientY - r.top) / r.height * 100)) } }); }
    else if (a.dir === "h") {
      // Kéo cạnh TRÊN/DƯỚI -> đổi cỡ chữ (giãn to/nhỏ). Khoảng cách từ TÂM ô tới con
      // trỏ (px hiển thị) ~ nửa chiều cao khối chữ = nLines*fontSize*scale/2 -> con
      // trỏ luôn bám cạnh: fontSize = 2*dist / (nLines*scale).
      const cyPx = r.top + (s.y / 100) * r.height;
      const dist = Math.abs(e.clientY - cyPx);
      const sc = scale() || 1;
      const font = Math.round((2 * dist) / (Math.max(1, a.nLines) * sc));
      patch(a.i, { style: { ...s, fontSize: Math.max(24, Math.min(120, font)) } });
    } else {
      // Kéo cạnh TRÁI/PHẢI -> đổi bề rộng vùng (đối xứng quanh tâm x).
      const dx = Math.abs((e.clientX - r.left) / r.width * 100 - s.x);
      patch(a.i, { style: { ...s, w: Math.max(24, Math.min(96, dx * 2)) } });
    }
  };
  const boxUp = (i) => (e) => {
    const a = act.current; if (a && !a.moved && !segs[i].locked) startEdit(i);
    act.current = null; setDragging(false);   // thả -> render lại khung chuẩn
  };

  const startEdit = (i) => { setSel(i); setEditing(true); const v = videoRef.current; if (v) v.pause(); setTimeout(() => { const el = document.getElementById("subedit-" + i); if (el) { el.focus(); const rg = document.createRange(); rg.selectNodeContents(el); const se = window.getSelection(); se.removeAllRanges(); se.addRange(rg); } }, 0); };
  const endEdit = (i) => (e) => { setEditing(false); const txt = (e.currentTarget.textContent || "").trim() || "Text"; setSegs((cur) => cur.map((s, j) => (j === i ? { ...s, text: txt, words: wordsFor(txt, s.start, s.end) } : s))); };

  const addText = () => {
    const start = Math.max(0, Math.min(t, total - 1)); const end = Math.min(total, start + 2.5);
    const style = JSON.parse(JSON.stringify(DEF)); style.y = 50;
    const seg = { start, end, text: "Text mới", locked: false, style, words: wordsFor("Text mới", start, end) };
    setSegs((cur) => { const n = [...cur, seg]; setSel(n.length - 1); return n; });
    seekTo((start + end) / 2); setTimeout(() => startEdit(segs.length), 30);
  };
  const delText = () => { const s = cur(); if (!s || s.locked) return; setSegs((c) => c.filter((_, j) => j !== sel)); setSel(0); };
  const applyAll = () => { const s = cur().style; setSegs((c) => c.map((sg) => ({ ...sg, style: { ...JSON.parse(JSON.stringify(s)), x: sg.style.x, y: sg.style.y } }))); };
  // Ngược với applyAll: chỉ chép VỊ TRÍ + bề rộng vùng của đoạn đang chọn sang các
  // đoạn còn lại, giữ nguyên font/màu/nền riêng của từng đoạn.
  const applyPosAll = () => {
    const s = cur(); if (!s) return;
    const { x, y, w } = s.style;
    setSegs((c) => c.map((sg, j) => (j === sel ? sg : { ...sg, style: { ...sg.style, x, y, w } })));
  };

  // Nút hành động tức thì (về giữa‑đáy / áp style / áp vị trí) chạy xong không đổi
  // gì trên nút -> user không rõ đã bấm chưa. doFlash loé NHẸ nền cam ~0.6s để báo
  // "đã nhấn". flash = key nút vừa bấm.
  const [flash, setFlash] = useState("");
  const flashTimer = useRef(null);
  useEffect(() => () => clearTimeout(flashTimer.current), []);
  const doFlash = (key, fn) => {
    if (fn) fn();
    setFlash(key);
    clearTimeout(flashTimer.current);
    flashTimer.current = setTimeout(() => setFlash(""), 600);
  };
  const flashStyle = (key) => (flash === key
    ? { background: "var(--orange-soft)", borderColor: "var(--orange)", color: "var(--orange-700)" }
    : undefined);

  // Đo chữ thật (canvas, đúng font/cỡ ở độ phân giải GỐC của video) -> trả về
  // index các từ BẮT ĐẦU dòng mới, để bản burn xuống dòng GIỐNG HỆT preview.
  const measureBreaks = (seg) => {
    const v = videoRef.current; const natW = (v && v.videoWidth) || 1080;
    const st = seg.style; const boxPx = Math.max(80, (st.w / 100) * natW * 0.97);
    const words = (seg.text || "").split(/\s+/).filter(Boolean);
    const cv = measureBreaks._cv || (measureBreaks._cv = document.createElement("canvas"));
    const ctx = cv.getContext("2d");
    // Font nhúng đã là nét đậm sẵn + libass render Bold=0 -> ĐO weight thường
    // (không "bold") để độ rộng đo khớp đúng glyph libass dùng. Nhân k để đo ở
    // ĐÚNG cỡ libass vẽ ra -> điểm ngắt dòng khớp cách libass lấp đầy vùng.
    ctx.font = `${st.fontSize * kOf(st.font)}px ${cssOf(st.font)}`;
    const sp = ctx.measureText(" ").width;
    const breaks = []; let lineW = 0;
    words.forEach((w, i) => {
      const ww = ctx.measureText(w).width;
      if (i > 0 && lineW + sp + ww > boxPx) { breaks.push(i); lineW = ww; }
      else { lineW = i === 0 ? ww : lineW + sp + ww; }
    });
    return breaks;
  };

  // Payload đoạn phụ đề (kèm breaks đo thật) — DÙNG CHUNG cho burn, ASS libass và
  // ảnh xem trước -> mọi nơi ăn cùng dữ liệu.
  const segPayload = () => segs.map((s) => ({
    start: s.start, end: s.end, text: s.text, style: s.style, breaks: measureBreaks(s),
  }));

  // Đẩy state (segs + mode + payload đã đo breaks) lên component cha khi có onChange
  // (tab "Chèn chữ nhiều video"): cha lưu theo từng video + để "áp form cho mọi video".
  // payload đo breaks bằng ĐÚNG video đang mở -> burn hàng loạt khớp bản xem trước.
  useEffect(() => {
    if (!onChange) return;
    try { onChange(segs, mode, segPayload()); }
    catch { onChange(segs, mode, null); }
    // eslint-disable-next-line
  }, [segs, mode]);

  const burn = async () => {
    if (!segs.length) return alert("Chưa có đoạn text nào. Bấm 'Tự tạo phụ đề' hoặc 'Thêm text'.");
    setBusy(true); setResult(null);
    try {
      // Đợi font nhúng nạp xong -> measureBreaks đo bằng ĐÚNG font (không fallback).
      try { await document.fonts.ready; } catch { /* trình duyệt cũ: bỏ qua */ }
      const { job_id } = await api.subtitleBurn({
        session, video: videoRel, mode, username: username || "",
        segments: segPayload(),
      });
      const done = await pollJob(job_id, () => {}, 1000, pollAbort.current.signal);
      const out = done.result?.output || (done.items || []).find((i) => i.status === "done")?.result;
      // _v = mốc render -> cache-bust <video> khi burn LẠI cùng tên file (không thì
      // trình duyệt giữ khung đen/bytes cũ; tải về vẫn đúng vì là request mới).
      if (out) setResult({ ...out, _v: Date.now() }); else alert("Lỗi tạo video phụ đề (xem log).");
    } catch (e) { alert("Lỗi: " + e.message); } finally { setBusy(false); }
  };

  // "Xem trước chuẩn": server render 1 khung tại thời điểm hiện tại = CHÍNH bản
  // burn (cùng ASS+libass+font) -> ảnh khớp 100% để đối chiếu tuyệt đối.
  const showFrame = async () => {
    if (!segs.length) return alert("Chưa có đoạn text nào.");
    setFrameBusy(true);
    try {
      try { await document.fonts.ready; } catch { /* bỏ qua */ }
      const r = await api.subtitlePreviewFrame(session, videoRel, segPayload(), mode, t);
      setFrame(fileUrl(r.image_url) + "?v=" + r.v);
    } catch (e) { alert("Lỗi xem trước: " + e.message); }
    finally { setFrameBusy(false); }
  };

  // Cỡ hiển thị video = nội dung THẬT (bỏ letterbox ngang) -> overlay (libass & CSS)
  // ánh xạ đúng toạ độ video nên khớp bản burn. Trước đây overlay tính theo khung
  // thẻ <video> (kèm viền đen) -> lệch ngang & sai bề rộng hộp.
  useEffect(() => {
    const compute = () => {
      const nw = nat.current.w || 1080, nh = nat.current.h || 1920;
      const avail = (colRef.current && colRef.current.clientWidth) || 300;
      const h = Math.min(460, avail * nh / nw);
      setFit({ w: Math.round(h * nw / nh), h: Math.round(h) });
    };
    compute();
    window.addEventListener("resize", compute);
    return () => window.removeEventListener("resize", compute);
  }, [dur]);

  // idle    = lúc NÊN render ảnh chuẩn (bật exact + tạm dừng + không gõ/kéo).
  // showExact = ảnh chuẩn ĐANG hiện trên màn (đã render xong). CHỈ khi đó mới ẩn
  //   lớp CSS. Còn lại (đang phát / đang chờ ảnh / lỗi / gõ / kéo) -> LUÔN hiện CSS
  //   làm nền => KHÔNG bao giờ bị trống chữ. Ảnh chuẩn về thì đè lên CSS.
  const idle = exact && !playing && !editing && !dragging;
  const showExact = idle && !!previewImg;
  useEffect(() => {
    if (!idle) return;
    const v = videoRef.current;
    if (!v || !v.videoWidth || !segs.length) { setPreviewImg(null); return; }
    clearTimeout(frameTimer.current);
    const seq = ++frameSeq.current;
    setPreviewImg(null);                 // xoá ảnh cũ -> hiện khung video thật trong lúc chờ
    setPreviewLoading(true); setPreviewErr(false);
    frameTimer.current = setTimeout(async () => {
      try { await document.fonts.ready; } catch { /* */ }
      try {
        const r = await api.subtitlePreviewFrame(session, videoRel, segPayload(), mode, t);
        if (seq === frameSeq.current) setPreviewImg(fileUrl(r.image_url) + "?v=" + r.v);
      } catch (e) { if (seq === frameSeq.current) setPreviewErr(true); }
      finally { if (seq === frameSeq.current) setPreviewLoading(false); }
    }, 200);
    return () => clearTimeout(frameTimer.current);
    // eslint-disable-next-line
  }, [idle, segs, mode, t, dur]);

  // ---- render 1 ô text ----
  const boxStyle = (s, on) => {
    const st = s.style;
    const sc = scale();
    const fpx = st.fontSize * sc;         // = libass Fontsize ở display px (mốc hình học hộp/viền, KHÔNG co K)
    const px = fpx * kOf(st.font);         // cỡ chữ CSS đã co K -> nét chữ KHỚP libass
    // lineHeight = 1/K: khoảng cách dòng CSS = fpx = advance libass (đo thực nghiệm
    // libass giãn dòng đúng 1.0*fsize). px = fpx*K nên lineHeight*px = fpx -> khớp.
    const base = { position: "absolute", left: st.x + "%", top: st.y + "%", transform: "translate(-50%,-50%)", width: st.w + "%", textAlign: "center", lineHeight: 1 / kOf(st.font), fontWeight: 400, fontFamily: cssOf(st.font), fontSize: px + "px", color: st.color, boxSizing: "border-box", padding: "0 1.5%", cursor: editing ? "text" : "move", outline: on ? "1.5px dashed rgba(255,255,255,.85)" : "none", zIndex: on ? 5 : 2, wordWrap: "break-word" };
    const b = st.bg; const ol = (p, c) => `${-p}px ${-p}px 0 ${c},${p}px ${-p}px 0 ${c},${-p}px ${p}px 0 ${c},${p}px ${p}px 0 ${c},0 ${p * 1.3}px 3px ${c}`;
    // Viền: libass Outline (ScaledBorderAndShadow, PlayResY=videoH) = px ở video, quy
    // display = value*sc. outlineThin=2, outlineThick=5, outlineWhite=3 (khớp _style_line).
    if (b === "outlineThin") base.textShadow = ol(Math.max(0.6, 2 * sc), "#000");
    else if (b === "outlineThick") base.textShadow = ol(Math.max(0.8, 5 * sc), "#000");
    else if (b === "outlineWhite") base.textShadow = ol(Math.max(0.6, 3 * sc), "#fff");
    else if (b === "shadow") base.textShadow = "0 3px 7px rgba(0,0,0,.95)";
    else if (b === "glow") base.textShadow = `0 0 9px ${st.highlight},0 0 4px ${st.highlight}`;
    // Hộp nền: đệm DỌC quy theo fpx (KHÔNG co K) -> KHỚP _bg_event backend
    // (pad_y=0.14/0.23*fsize). Đệm NGANG = 1.5% bề rộng hộp (CSS % = theo width) ->
    // nội dung = 97% vùng, đúng bằng measureBreaks (*0.97) -> ngắt dòng live khớp burn.
    // Bo góc quy theo fpx (radius={..}*fsize trong _bg_event).
    else if (b === "boxDark") { base.background = "rgba(0,0,0,.5)"; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.20 * fpx + "px"; }
    else if (b === "boxSolid") { base.background = "#000"; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.13 * fpx + "px"; }
    else if (b === "boxRound") { base.background = "rgba(0,0,0,.62)"; base.padding = `${0.23 * fpx}px 1.5%`; base.borderRadius = 0.73 * fpx + "px"; }
    else if (b === "bar") { base.background = "rgba(0,0,0,.5)"; base.padding = `${0.23 * fpx}px 1.5%`; }
    else if (b === "boxColor") { base.background = st.highlight; base.padding = `${0.14 * fpx}px 1.5%`; base.borderRadius = 0.20 * fpx + "px"; }
    // Ảnh chuẩn đang đè -> ẩn chữ/nền/viền CSS (chỉ giữ vùng bắt sự kiện + khung
    // chọn). Lúc khác luôn hiện CSS làm nền -> không bao giờ trống chữ.
    if (showExact && !(editing && on)) {
      base.color = "transparent"; base.textShadow = "none";
      base.background = "none"; base.padding = 0;
    }
    return base;
  };
  const boxContent = (s) => {
    if (editing && segs[sel] === s) return null;   // ô đang gõ: contentEditable tự giữ text
    if (showExact) return null;                     // ảnh chuẩn đang đè -> CSS không vẽ (tránh 2 lớp)
    if (mode === "whole") return s.text;
    if (mode === "progressive") return s.words.filter((w) => t >= w.start).map((w) => w.w).join(" ") || " ";
    return s.words.map((w, i) => <span key={i} style={{ color: t >= w.start ? s.style.highlight : s.style.color }}>{w.w}{i < s.words.length - 1 ? " " : ""}</span>);
  };

  const fmt = (x) => { x = Math.max(0, x || 0); const m = Math.floor(x / 60), ss = Math.floor(x % 60); return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; };

  return (
    <div className="card" style={{ borderColor: "var(--orange)" }}>
      <div className="flex between center" style={{ gap: 8, flexWrap: "wrap" }}>
        <div className="panel-title" style={{ margin: 0 }}>Chèn chữ / phụ đề (hard sub)</div>
        <div className="flex center gap8" style={{ flexWrap: "wrap" }}>
          {headerExtra}
          {onClose && <button className="btn sm ghost" onClick={onClose}>Đóng</button>}
        </div>
      </div>

      <div className="grid-2" style={{ gridTemplateColumns: "300px 1fr", gap: 16, marginTop: 10 }}>
        {/* preview + timeline */}
        <div ref={colRef}>
          <div ref={wrapRef} style={{ position: "relative", width: fit.w ? fit.w + "px" : "100%", height: fit.h ? fit.h + "px" : "auto", margin: "0 auto", background: "#000", borderRadius: 12, overflow: "hidden" }}>
            <video ref={videoRef} src={videoUrl} onClick={() => { const v = videoRef.current; v.paused ? v.play() : v.pause(); }}
                   style={{ display: "block", width: "100%", height: "100%", objectFit: "contain" }} />
            {/* Ảnh khung CHUẨN (server ffmpeg = đúng bản burn) đè lên video khi rảnh. */}
            {idle && previewImg && (
              <img src={previewImg} alt="" draggable={false}
                   onError={() => { setPreviewImg(null); setPreviewErr(true); }}
                   style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", zIndex: 1, pointerEvents: "none" }} />
            )}
            {idle && previewLoading && (
              <div style={{ position: "absolute", right: 6, top: 6, zIndex: 6, background: "rgba(0,0,0,.55)", color: "#fff", fontSize: 11, padding: "2px 8px", borderRadius: 10, pointerEvents: "none" }}>đang dựng khung chuẩn…</div>
            )}
            {idle && previewErr && !previewLoading && (
              <div style={{ position: "absolute", right: 6, top: 6, zIndex: 6, background: "rgba(200,40,40,.9)", color: "#fff", fontSize: 11, padding: "2px 8px", borderRadius: 10, pointerEvents: "none" }}>Lỗi dựng khung chuẩn (server)</div>
            )}
            {segs.map((s, i) => (t >= s.start && t <= s.end) ? (
              <div key={i} style={boxStyle(s, i === sel)} onPointerDown={boxDown(i)} onPointerMove={boxMove} onPointerUp={boxUp(i)}>
                <div id={"subedit-" + i} contentEditable={editing && i === sel} suppressContentEditableWarning
                     onBlur={endEdit(i)} style={{ outline: "none" }}>
                  {boxContent(s)}
                </div>
                {i === sel && !editing && (() => {
                  const hst = { position: "absolute", background: "var(--orange)", border: "1px solid #fff", borderRadius: 2, zIndex: 6 };
                  return (<>
                    <span data-h="1" onPointerDown={handleDown(i, "w")} title="Kéo đổi bề rộng vùng"
                          style={{ ...hst, left: -4, top: "50%", transform: "translateY(-50%)", width: 6, height: 13, cursor: "ew-resize" }} />
                    <span data-h="1" onPointerDown={handleDown(i, "w")} title="Kéo đổi bề rộng vùng"
                          style={{ ...hst, right: -4, top: "50%", transform: "translateY(-50%)", width: 6, height: 13, cursor: "ew-resize" }} />
                    <span data-h="1" onPointerDown={handleDown(i, "h")} title="Kéo giãn to/nhỏ cỡ chữ"
                          style={{ ...hst, top: -4, left: "50%", transform: "translateX(-50%)", width: 13, height: 6, cursor: "ns-resize" }} />
                    <span data-h="1" onPointerDown={handleDown(i, "h")} title="Kéo giãn to/nhỏ cỡ chữ"
                          style={{ ...hst, bottom: -4, left: "50%", transform: "translateX(-50%)", width: 13, height: 6, cursor: "ns-resize" }} />
                  </>);
                })()}
              </div>
            ) : null)}
          </div>

          <div className="flex center gap8 mt8">
            <button className="btn sm" onClick={() => { const v = videoRef.current; v.paused ? v.play() : v.pause(); }}>{playing ? "❚❚" : "►"}</button>
            <span className="small muted">{fmt(t)} / {fmt(total)}</span>
            <button className="btn sm" style={{ marginLeft: "auto" }} onClick={addText}><IcPlus size={13} /> Thêm text</button>
          </div>
          <div className="flex center gap8 mt8" style={{ flexWrap: "wrap" }}>
            <label className="small" style={{ display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer" }}
                   title="Bật: khi DỪNG, hiện đúng khung video sẽ xuất (server render). Tắt: chỉ xem nhanh bằng CSS (gần đúng).">
              <input type="checkbox" checked={exact} onChange={(e) => setExact(e.target.checked)} />
              Xem trước khớp 100% (dừng để thấy)
            </label>
            <button className="btn sm ghost" onClick={showFrame} disabled={frameBusy || !segs.length}
                    title="Mở ảnh khung chuẩn phóng to (đúng video sẽ xuất)">
              {frameBusy ? "Đang render…" : "🔍 Phóng to ảnh chuẩn"}
            </button>
          </div>
          {exact && (
            <p className="small muted" style={{ margin: "6px 0 0" }}>
              {playing ? "▶ Đang phát: xem nhanh (gần đúng). Bấm DỪNG (⏸) để xem phụ đề CHUẨN."
                : showExact ? "✅ Phụ đề đang hiện = ĐÚNG y video sẽ xuất."
                  : previewErr ? "⚠ Chưa dựng được khung chuẩn (đang hiện tạm bản gần đúng)."
                    : "⏳ Đang dựng khung chuẩn… (đang hiện tạm bản gần đúng)"}
            </p>
          )}

          {/* timeline */}
          <div style={{ position: "relative", marginTop: 10 }} >
            <div style={{ display: "flex", height: 30, borderRadius: 6, overflow: "hidden", border: "0.5px solid var(--line)", cursor: "pointer" }}
                 onPointerDown={(e) => { const r = e.currentTarget.getBoundingClientRect(); seekTo((e.clientX - r.left) / r.width * total); }}>
              {(thumbs.length ? thumbs : Array.from({ length: 8 })).map((th, i) => (
                <div key={i} style={{ flex: 1, background: th ? `center/cover url(${th})` : "#334", borderRight: i < 7 ? "1px solid rgba(255,255,255,.15)" : "none" }} />
              ))}
            </div>
            <div style={{ position: "relative", height: lanes.n * 22 + (lanes.n - 1) * 3, marginTop: 4 }}>
              {segs.map((s, i) => (
                <div key={i} onClick={() => { seekTo((s.start + s.end) / 2); setSel(i); }}
                     title={s.text}
                     style={{ position: "absolute", left: (s.start / total * 100) + "%", width: Math.max(6, (s.end - s.start) / total * 100) + "%", top: lanes.lane[i] * 25, height: 22, borderRadius: 5, fontSize: 10, lineHeight: "20px", padding: "0 6px", boxSizing: "border-box", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", cursor: "pointer", border: i === sel ? "1.5px solid var(--orange)" : "1px solid var(--line)", borderStyle: s.locked ? "solid" : "dashed", background: i === sel ? "var(--orange-soft)" : "var(--white)", color: i === sel ? "var(--orange-700)" : "var(--muted)" }}>
                  {s.locked ? "🔒 " : ""}{trunc(s.text, 16)}
                </div>
              ))}
            </div>
            <div style={{ position: "absolute", top: 0, width: 2, height: 34 + lanes.n * 22 + (lanes.n - 1) * 3, background: "#ff3b30", left: `calc(${t / total * 100}% - 1px)`, pointerEvents: "none" }} />
          </div>
        </div>

        {/* controls */}
        <div className="stack" style={{ gap: 12 }}>
          {srtRel && segs.filter((s) => s.locked).length === 0 && (
            <button className="btn primary" onClick={autoSubtitle}>Tự tạo phụ đề từ lời dịch</button>
          )}
          {onFetchLines && (
            <button className="btn primary" onClick={loadLines} disabled={fetchingLines}
                    title="Nhận dạng lời thoại trong video (Whisper) và đổ sẵn vào để sửa">
              {fetchingLines ? "Đang nhận dạng lời…" : "🎙 Tự lấy lời từ video (nhận dạng)"}
            </button>
          )}
          <div className="flex gap8 center">
            <button className="btn sm ghost" onClick={delText} style={{ display: cur() && !cur().locked ? "inline-flex" : "none" }}><IcTrash size={13} /> Xóa đoạn</button>
            <span className="small muted">{cur() && cur().locked ? "🔒 text lồng tiếng — không xóa được" : ""}</span>
          </div>

          <div>
            <span className="field-label">Kiểu hiển thị (chung cả video)</span>
            <div className="seg-group">{MODES.map(([id, lb]) => <button key={id} className={mode === id ? "active" : ""} onClick={() => setMode(id)}>{lb}</button>)}</div>
          </div>

          {cur() && (
            <>
              <div className="row wrap" style={{ gap: 10 }}>
                <div style={{ flex: 1 }}><span className="field-label">Phông</span>
                  <select value={cur().style.font} onChange={(e) => patchStyle({ font: e.target.value })} style={{ width: "100%" }}>
                    {FONTS.map((f) => <option key={f.ass} value={f.ass}>{f.label}</option>)}</select></div>
                <div style={{ flex: 1 }}><span className="field-label">Kiểu nền</span>
                  <select value={cur().style.bg} onChange={(e) => patchStyle({ bg: e.target.value })} style={{ width: "100%" }}>
                    {BGS.map(([lb, id]) => <option key={id} value={id}>{lb}</option>)}</select></div>
              </div>
              <div className="row wrap" style={{ gap: 10 }}>
                <label className="field-label" style={{ flex: 1 }}>Màu chữ
                  <input type="color" value={cur().style.color} onChange={(e) => patchStyle({ color: e.target.value })}
                         style={{ width: "100%", height: 34, marginTop: 4, border: "1px solid var(--line)", borderRadius: 6, padding: 2, cursor: "pointer", background: "var(--white)" }} /></label>
                <label className="field-label" style={{ flex: 1 }}>Màu highlight
                  <input type="color" value={cur().style.highlight} onChange={(e) => patchStyle({ highlight: e.target.value })}
                         style={{ width: "100%", height: 34, marginTop: 4, border: "1px solid var(--line)", borderRadius: 6, padding: 2, cursor: "pointer", background: "var(--white)" }} /></label>
              </div>
              <div><span className="field-label">Cỡ chữ: {cur().style.fontSize}</span>
                <input className="slider" type="range" min="24" max="120" value={cur().style.fontSize} onChange={(e) => patchStyle({ fontSize: +e.target.value })} /></div>
              <div className="flex gap8" style={{ flexWrap: "wrap" }}>
                <button className="btn sm" style={flashStyle("center")} onClick={() => doFlash("center", () => patchStyle({ x: 50, y: 86 }))}>Về giữa‑đáy</button>
                <button className="btn sm" style={flashStyle("all")} onClick={() => doFlash("all", applyAll)}>Áp style cho mọi đoạn</button>
                <button className="btn sm" style={flashStyle("pos")} onClick={() => doFlash("pos", applyPosAll)} disabled={segs.length < 2}
                        title="Chép vị trí (và bề rộng vùng) của đoạn đang chọn sang tất cả đoạn khác">
                  Áp vị trí cho mọi đoạn
                </button>
              </div>
            </>
          )}

          <button className="btn primary block mt8" onClick={burn} disabled={busy || !segs.length}>
            {busy ? "Đang tạo video có phụ đề…" : "Tạo video có phụ đề"}
          </button>
          <p className="small muted">Đoạn 🔒 là phụ đề từ lời dịch (không xóa). Bấm vào chữ trên video để sửa; kéo để đặt vị trí; kéo tay <b>trái/phải</b> để đổi bề rộng vùng, kéo tay <b>trên/dưới</b> để giãn to/nhỏ cỡ chữ. Đặt xong 1 đoạn, bấm “Áp vị trí cho mọi đoạn” để các đoạn kia về cùng chỗ.</p>
        </div>
      </div>

      {result && (
        <div className="mt16" style={{ borderTop: "1px solid var(--line)", paddingTop: 14 }}>
          <div className="panel-title" style={{ marginBottom: 10 }}>Video đã chèn phụ đề</div>
          <div className="flex" style={{ gap: 14, flexWrap: "wrap", alignItems: "flex-start" }}>
            <video key={result._v} src={fileUrl(result.video_url) + (result._v ? "?v=" + result._v : "")} controls style={{ maxWidth: 260, maxHeight: 420, borderRadius: 12, background: "#000" }} />
            <a className="btn primary" href={fileUrl(result.video_url)} download><IcDownload size={15} /> Tải video có phụ đề</a>
          </div>
        </div>
      )}

      {frame && (
        <div onClick={() => setFrame(null)}
             style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.72)", zIndex: 60, display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
          <div onClick={(e) => e.stopPropagation()} style={{ background: "var(--white)", borderRadius: 12, padding: 12, maxWidth: "92vw", maxHeight: "92vh", overflow: "auto" }}>
            <div className="flex between center" style={{ marginBottom: 8, gap: 12 }}>
              <div className="panel-title" style={{ margin: 0 }}>Xem trước chuẩn — đúng như video sẽ xuất</div>
              <button className="btn sm ghost" onClick={() => setFrame(null)}>Đóng</button>
            </div>
            <img src={frame} alt="preview" style={{ display: "block", maxWidth: "100%", maxHeight: "78vh", borderRadius: 8, background: "#000" }} />
          </div>
        </div>
      )}
    </div>
  );
}