/* ============================================================================ Animatic "Giới thiệu 60 giây" — 12 cảnh theo §3 Storyboard của 2026-06-14-promo-video-60s-script.md. Tự nạp ảnh thật ở webinar/promo/.png (cảnh 3–11); chưa có ảnh → placeholder brand-styled. Cảnh 1/2/12 = đồ họa. Pure JSX + CSS, không thêm thư viện. Xem webinar/promo/README.md. ========================================================================== */ const PROMO_DIR = 'webinar/promo/'; const RING_NODES = ['Tiếp nhận', 'Đo', 'Phân tầng', 'Điều trị', 'Tái khám', 'Báo cáo']; // 12 cảnh — dur(ms) bám timecode §3b; tổng ~58s. const SCENES = [ { k: 'logo', dur: 3000, big: 'Vietcanthi', vo: 'Vietcanthi.', tx: 'fade', node: -1 }, { k: 'ring', dur: 5000, big: 'Hệ sinh thái kiểm soát cận thị · khép kín', vo: 'Hệ sinh thái kiểm soát cận thị khép kín cho phòng khám nhãn khoa.', tx: 'fade', node: -1 }, { k: 'shot', img: '03-devices', dur: 4000, big: 'Kết nối máy đo · chuẩn hóa dữ liệu', vo: 'Bắt đầu từ dữ liệu chuẩn hóa — kết nối máy đo,', tx: 'slide', node: 1 }, { k: 'shot', img: '04-visit', dur: 4000, big: 'Số đo tự chảy vào · không gõ tay', vo: 'số đo tự chảy vào hồ sơ, không gõ tay.', tx: 'match', node: 1 }, { k: 'shot', img: '05-tier', dur: 4000, big: 'Phân tầng nguy cơ · theo bằng chứng', vo: 'Hệ thống tự động phân tầng nguy cơ theo bằng chứng.', tx: 'zoom', node: 2 }, { k: 'shot', img: '04-visit', dur: 4000, big: 'Nhập liệu nhanh', vo: 'Khám nhanh, nhập liệu gọn.', tx: 'push', node: 3 }, { k: 'shot', img: '07-treatment', dur: 6000, big: 'Đề xuất phác đồ · có dẫn chứng', vo: 'Hệ thống đề xuất phương pháp điều trị phù hợp, kèm trích dẫn y văn.', tx: 'cross', node: 3 }, { k: 'shot', img: '08-schedule', dur: 5000, big: 'Mỗi phương pháp · một lịch riêng', vo: 'Mỗi phương pháp một lịch riêng — tự động phân lịch tái khám,', tx: 'slide', node: 4 }, { k: 'shot', img: '09-reminders', dur: 5000, big: 'Tự động nhắc · giảm vắng hẹn', vo: 'và nhắc đúng hạn, giảm bỏ theo dõi.', tx: 'cross', node: 4 }, { k: 'shot', img: '10-alchart', dur: 5000, big: 'Theo dõi tốc độ trục nhãn cầu', vo: 'Theo dõi tốc độ thay đổi trục nhãn cầu,', tx: 'ken', node: 5 }, { k: 'shot', img: '11-ksct', dur: 7000, big: 'Đánh giá hiệu quả · dự phóng', vo: 'đánh giá hiệu quả từng phương pháp, phân tích xu hướng và dự phóng theo bằng chứng.', tx: 'push', node: 5 }, { k: 'cta', dur: 6000, big: 'Đặt demo hôm nay · vietcanthi.com', vo: 'Vietcanthi — kiểm soát cận thị khép kín, chuẩn y văn. Đặt demo ngay hôm nay.', tx: 'fade', node: -1 }]; const TOTAL = SCENES.reduce((s, x) => s + x.dur, 0); /* ---- Vòng 6 mắt xích (Tiếp nhận→Đo→Phân tầng→Điều trị→Tái khám→Báo cáo) ---- */ function Ring6({ active = -1, size = 120, labels = false, closed = false }) { const cx = size / 2, cy = size / 2, r = size * 0.36; const node = RING_NODES.map((name, i) => { const a = (-90 + i * 60) * Math.PI / 180; return { name, x: cx + r * Math.cos(a), y: cy + r * Math.sin(a), lx: cx + (r + size * 0.16) * Math.cos(a), ly: cy + (r + size * 0.16) * Math.sin(a) }; }); return ( ); } /* ---- Cảnh đồ họa: logo sting / vòng / CTA ---- */ function LogoScene() { return (
VietCanThi
); } function RingScene({ on }) { return (
{on && }
); } function CtaScene() { return (
VietCanThi

See the world clearly.

vietcanthi.comĐặt demo cho phòng khám của bạn
); } function ShotScene({ scene, active }) { const { useState } = React; const [err, setErr] = useState(false); if (err) { return (
Cảnh · ảnh thật {scene.big} Thả ảnh {PROMO_DIR}{scene.img}.png để thay placeholder
); } return ( {scene.big} setErr(true)} />); } function Animatic() { const { useState, useEffect, useRef } = React; const [idx, setIdx] = useState(0); const [playing, setPlaying] = useState(true); const wrapRef = useRef(null); const timerRef = useRef(null); const startRef = useRef(0); const leftRef = useRef(SCENES[0].dur); const resumeRef = useRef(false); useEffect(() => { clearTimeout(timerRef.current); if (!playing) return undefined; const m = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)'); if (m && m.matches) return undefined; const dur = resumeRef.current ? leftRef.current : SCENES[idx].dur; resumeRef.current = false; leftRef.current = dur; startRef.current = (window.performance || Date).now(); timerRef.current = setTimeout(() => setIdx((p) => (p + 1) % SCENES.length), dur); return () => clearTimeout(timerRef.current); }, [idx, playing]); const pause = () => { if (!playing) return; const elapsed = (window.performance || Date).now() - startRef.current; leftRef.current = Math.max(400, leftRef.current - elapsed); resumeRef.current = true; setPlaying(false); }; const toggle = () => { if (playing) pause(); else setPlaying(true); }; const goTo = (i) => { resumeRef.current = false; leftRef.current = SCENES[i].dur; setIdx(i); }; const replay = () => { resumeRef.current = false; leftRef.current = SCENES[0].dur; setIdx(0); setPlaying(true); }; const fullscreen = () => { const el = wrapRef.current; if (!el) return; if (document.fullscreenElement) document.exitFullscreen(); else if (el.requestFullscreen) el.requestFullscreen(); }; const cur = SCENES[idx]; return (
{SCENES.map((s, k) => { const on = k === idx; return (
{s.k === 'logo' && } {s.k === 'ring' && } {s.k === 'cta' && } {s.k === 'shot' && }
); })} {/* badge vòng 6 mắt xích góc trên — xuất hiện từ cảnh 2 trở đi trong các cảnh app */} {cur.k === 'shot' &&
{cur.node >= 0 ? RING_NODES[cur.node] : ''}
} {/* overlay text + phụ đề VO (cảnh logo/cta có chữ riêng nên ẩn) */} {cur.k === 'shot' &&
} {(cur.k === 'shot' || cur.k === 'ring') &&
{cur.k === 'shot' && Vietcanthi · Kiểm soát cận thị khép kín}

{cur.big}

{cur.vo}

}
{/* controls + progress */}
{SCENES.map((s, k) => )}
{String(idx + 1).padStart(2, '0')}/{SCENES.length}
); } window.Animatic = Animatic;