JakgritB commited on
Commit ·
c481310
1
Parent(s): 08d2fa9
feat(frontend): redesign editor workspace controls
Browse files- frontend/src/App.jsx +474 -35
- frontend/src/styles.css +285 -15
frontend/src/App.jsx
CHANGED
|
@@ -50,6 +50,53 @@ const NICHES = [
|
|
| 50 |
const CLIP_STYLES = ["informative", "funny", "dramatic", "educational", "commentary"];
|
| 51 |
const LANGUAGE_OPTIONS = ["Thai", "English", "Japanese", "Chinese", "Korean", "Auto"];
|
| 52 |
const PLATFORM_OPTIONS = ["tiktok", "youtube_shorts", "instagram_reels"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
const defaultProfile = {
|
| 55 |
niche: "education",
|
|
@@ -132,6 +179,36 @@ const en = {
|
|
| 132 |
clipRange: "Clip range",
|
| 133 |
subtitleCues: "Subtitle cues",
|
| 134 |
subtitleCueHelp: "Short cues read better on TikTok, Reels, and Shorts.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
editorTools: "Editing tools",
|
| 136 |
rangeStart: "Range start",
|
| 137 |
rangeEnd: "Range end",
|
|
@@ -262,6 +339,36 @@ const translations = {
|
|
| 262 |
clipRange: "ช่วงคลิป",
|
| 263 |
subtitleCues: "จังหวะซับ",
|
| 264 |
subtitleCueHelp: "ซับสั้น ๆ อ่านง่ายกว่าสำหรับ TikTok, Reels และ Shorts",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
editorTools: "เครื่องมือตัดต่อ",
|
| 266 |
rangeStart: "ตำแหน่งเริ่ม",
|
| 267 |
rangeEnd: "ตำแหน่งจบ",
|
|
@@ -384,6 +491,36 @@ const translations = {
|
|
| 384 |
subtitles: "字幕",
|
| 385 |
subtitleCues: "字幕キュー",
|
| 386 |
subtitleCueHelp: "短い字幕キューのほうがTikTok、Reels、Shortsで読みやすくなります。",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
editorTools: "編集ツール",
|
| 388 |
rangeStart: "開始位置",
|
| 389 |
rangeEnd: "終了位置",
|
|
@@ -511,6 +648,36 @@ const translations = {
|
|
| 511 |
subtitles: "字幕",
|
| 512 |
subtitleCues: "字幕片段",
|
| 513 |
subtitleCueHelp: "短字幕片段更适合 TikTok、Reels 和 Shorts。",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
editorTools: "编辑工具",
|
| 515 |
rangeStart: "开始位置",
|
| 516 |
rangeEnd: "结束位置",
|
|
@@ -639,6 +806,36 @@ const translations = {
|
|
| 639 |
subtitles: "자막",
|
| 640 |
subtitleCues: "자막 큐",
|
| 641 |
subtitleCueHelp: "짧은 자막 큐가 TikTok, Reels, Shorts에서 더 읽기 쉽습니다.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
editorTools: "편집 도구",
|
| 643 |
rangeStart: "시작 위치",
|
| 644 |
rangeEnd: "종료 위치",
|
|
@@ -714,6 +911,13 @@ function App() {
|
|
| 714 |
const [error, setError] = useState("");
|
| 715 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 716 |
const [editorClipId, setEditorClipId] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
const [language, setLanguage] = useState(() => localStorage.getItem("elevenclip.language") || "en");
|
| 718 |
const [theme, setTheme] = useState(() => {
|
| 719 |
const saved = localStorage.getItem("elevenclip.theme");
|
|
@@ -736,6 +940,9 @@ function App() {
|
|
| 736 |
[job?.clips]
|
| 737 |
);
|
| 738 |
const editorClip = activeClips.find((clip) => clip.id === editorClipId);
|
|
|
|
|
|
|
|
|
|
| 739 |
|
| 740 |
useEffect(() => {
|
| 741 |
document.documentElement.dataset.theme = theme;
|
|
@@ -746,6 +953,10 @@ function App() {
|
|
| 746 |
localStorage.setItem("elevenclip.language", language);
|
| 747 |
}, [language]);
|
| 748 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
useEffect(() => {
|
| 750 |
fetchJson("/health").then(setHealth).catch(() => setHealth(null));
|
| 751 |
}, []);
|
|
@@ -791,15 +1002,23 @@ function App() {
|
|
| 791 |
}
|
| 792 |
|
| 793 |
async function patchClip(clipId, patch) {
|
| 794 |
-
const nextClip = await fetchJson(`/api/jobs/${job.id}/clips/${clipId}`, {
|
| 795 |
-
method: "PATCH",
|
| 796 |
-
headers: { "Content-Type": "application/json" },
|
| 797 |
-
body: JSON.stringify(patch),
|
| 798 |
-
});
|
| 799 |
setJob((current) => ({
|
| 800 |
...current,
|
| 801 |
-
clips: current.clips.map((clip) => (clip.id === clipId ?
|
| 802 |
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
}
|
| 804 |
|
| 805 |
async function regenerateClip(clip) {
|
|
@@ -822,6 +1041,13 @@ function App() {
|
|
| 822 |
return (value) => setProfile((current) => ({ ...current, [key]: value }));
|
| 823 |
}
|
| 824 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
return (
|
| 826 |
<main className="app-shell">
|
| 827 |
<AppHeader
|
|
@@ -844,6 +1070,8 @@ function App() {
|
|
| 844 |
onDelete={(clip) => patchClip(clip.id, { deleted: true })}
|
| 845 |
onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })}
|
| 846 |
onRegenerate={regenerateClip}
|
|
|
|
|
|
|
| 847 |
/>
|
| 848 |
) : (
|
| 849 |
<div className="workspace-grid">
|
|
@@ -975,7 +1203,7 @@ function ProfileForm({
|
|
| 975 |
value={profile.channel_description}
|
| 976 |
onChange={setProfileValue("channel_description")}
|
| 977 |
placeholder={t("channelDescriptionPlaceholder")}
|
| 978 |
-
rows={
|
| 979 |
/>
|
| 980 |
|
| 981 |
<div className="form-grid-two">
|
|
@@ -1241,23 +1469,14 @@ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegen
|
|
| 1241 |
</span>
|
| 1242 |
</div>
|
| 1243 |
|
| 1244 |
-
<
|
| 1245 |
-
defaultValue={clip.subtitle_text}
|
| 1246 |
-
aria-label={t("subtitles")}
|
| 1247 |
-
rows={3}
|
| 1248 |
-
onBlur={(event) => {
|
| 1249 |
-
if (event.target.value !== clip.subtitle_text) {
|
| 1250 |
-
onPatch(clip.id, { subtitle_text: event.target.value });
|
| 1251 |
-
}
|
| 1252 |
-
}}
|
| 1253 |
-
/>
|
| 1254 |
|
| 1255 |
<div className="clip-actions">
|
| 1256 |
-
<button type="button" title={t("openEditor")} onClick={() => onOpenEditor(clip)}>
|
| 1257 |
<PanelRightOpen size={16} />
|
| 1258 |
{t("openEditor")}
|
| 1259 |
</button>
|
| 1260 |
-
<button type="button" title={t("approve")} onClick={() => onApprove(clip)}>
|
| 1261 |
<Check size={16} />
|
| 1262 |
{clip.approved ? t("approved") : t("approve")}
|
| 1263 |
</button>
|
|
@@ -1278,11 +1497,23 @@ function ClipCard({ clip, t, onOpenEditor, onPatch, onDelete, onApprove, onRegen
|
|
| 1278 |
);
|
| 1279 |
}
|
| 1280 |
|
| 1281 |
-
function ClipEditorPage({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1282 |
const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
|
| 1283 |
-
const cues = getSubtitleCues(clip, duration);
|
| 1284 |
const metadataModel = clip.metadata?.model || "unknown";
|
| 1285 |
const sourceKind = job?.source?.kind || "video";
|
|
|
|
| 1286 |
const timelineDuration = Math.max(
|
| 1287 |
clip.end_seconds,
|
| 1288 |
...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
|
|
@@ -1332,6 +1563,29 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
|
|
| 1332 |
</div>
|
| 1333 |
|
| 1334 |
<div className="editor-grid">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1335 |
<section className="panel editor-main">
|
| 1336 |
<div className="panel-heading compact">
|
| 1337 |
<div>
|
|
@@ -1344,6 +1598,7 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
|
|
| 1344 |
</div>
|
| 1345 |
<div className="editor-preview">
|
| 1346 |
{clip.video_url ? <video controls src={`${API_BASE}${clip.video_url}`} /> : <Film size={44} />}
|
|
|
|
| 1347 |
</div>
|
| 1348 |
|
| 1349 |
<div className="range-editor">
|
|
@@ -1432,6 +1687,8 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
|
|
| 1432 |
</div>
|
| 1433 |
</div>
|
| 1434 |
|
|
|
|
|
|
|
| 1435 |
<div className="subtitle-editor">
|
| 1436 |
<div className="panel-heading compact">
|
| 1437 |
<div>
|
|
@@ -1514,6 +1771,8 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
|
|
| 1514 |
</button>
|
| 1515 |
</div>
|
| 1516 |
|
|
|
|
|
|
|
| 1517 |
<TranscriptMini transcript={job?.transcript || []} clip={clip} t={t} />
|
| 1518 |
</aside>
|
| 1519 |
</div>
|
|
@@ -1521,6 +1780,183 @@ function ClipEditorPage({ clip, job, t, onBack, onPatch, onDelete, onApprove, on
|
|
| 1521 |
);
|
| 1522 |
}
|
| 1523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1524 |
function TranscriptMini({ transcript, clip, t }) {
|
| 1525 |
const rows = transcript.filter(
|
| 1526 |
(segment) => segment.end_seconds >= clip.start_seconds && segment.start_seconds <= clip.end_seconds
|
|
@@ -1616,16 +2052,8 @@ async function fetchJson(path, options) {
|
|
| 1616 |
return response.json();
|
| 1617 |
}
|
| 1618 |
|
| 1619 |
-
function getSubtitleCues(clip, duration) {
|
| 1620 |
-
|
| 1621 |
-
if (Array.isArray(fromMetadata) && fromMetadata.length > 0) {
|
| 1622 |
-
return fromMetadata.map((cue) => ({
|
| 1623 |
-
start_seconds: Number(cue.start_seconds || 0),
|
| 1624 |
-
end_seconds: Number(cue.end_seconds || cue.start_seconds || 0),
|
| 1625 |
-
text: cue.text || "",
|
| 1626 |
-
}));
|
| 1627 |
-
}
|
| 1628 |
-
return splitSubtitleText(clip.subtitle_text || "").map((text, index, all) => {
|
| 1629 |
const cueDuration = duration / Math.max(all.length, 1);
|
| 1630 |
return {
|
| 1631 |
start_seconds: roundTime(index * cueDuration),
|
|
@@ -1635,14 +2063,21 @@ function getSubtitleCues(clip, duration) {
|
|
| 1635 |
});
|
| 1636 |
}
|
| 1637 |
|
| 1638 |
-
function splitSubtitleText(text) {
|
| 1639 |
const clean = text.trim().replace(/\s+/g, " ");
|
| 1640 |
if (!clean) return [""];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1641 |
const words = clean.split(" ");
|
| 1642 |
if (words.length <= 1) {
|
| 1643 |
const chunks = [];
|
| 1644 |
-
for (let index = 0; index < clean.length; index +=
|
| 1645 |
-
chunks.push(clean.slice(index, index +
|
| 1646 |
}
|
| 1647 |
return chunks;
|
| 1648 |
}
|
|
@@ -1651,7 +2086,7 @@ function splitSubtitleText(text) {
|
|
| 1651 |
words.forEach((word) => {
|
| 1652 |
const candidate = [...current, word].join(" ");
|
| 1653 |
const punctuationBreak = current.length > 0 && /[,.!?;:]$/.test(current[current.length - 1]);
|
| 1654 |
-
if (current.length > 0 && (candidate.length >
|
| 1655 |
chunks.push(current.join(" "));
|
| 1656 |
current = [word];
|
| 1657 |
} else {
|
|
@@ -1670,6 +2105,10 @@ function clamp(value, min, max) {
|
|
| 1670 |
return Math.min(Math.max(value, min), max);
|
| 1671 |
}
|
| 1672 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1673 |
function formatTime(value) {
|
| 1674 |
const safeValue = Number.isFinite(Number(value)) ? Number(value) : 0;
|
| 1675 |
const minutes = Math.floor(safeValue / 60);
|
|
|
|
| 50 |
const CLIP_STYLES = ["informative", "funny", "dramatic", "educational", "commentary"];
|
| 51 |
const LANGUAGE_OPTIONS = ["Thai", "English", "Japanese", "Chinese", "Korean", "Auto"];
|
| 52 |
const PLATFORM_OPTIONS = ["tiktok", "youtube_shorts", "instagram_reels"];
|
| 53 |
+
const FONT_OPTIONS = ["Inter", "Noto Sans Thai", "Poppins", "Montserrat", "Arial", "Impact"];
|
| 54 |
+
const CUE_DENSITIES = ["word", "short", "medium", "long"];
|
| 55 |
+
const CAPTION_ANIMATIONS = ["none", "highlight", "pop", "bounce"];
|
| 56 |
+
|
| 57 |
+
const defaultCaptionStyle = {
|
| 58 |
+
fontFamily: "Inter",
|
| 59 |
+
fontSize: 38,
|
| 60 |
+
fillColor: "#ffffff",
|
| 61 |
+
strokeColor: "#080b12",
|
| 62 |
+
strokeWidth: 4,
|
| 63 |
+
position: 18,
|
| 64 |
+
cueDensity: "short",
|
| 65 |
+
animation: "highlight",
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const captionPresets = {
|
| 69 |
+
clean: {
|
| 70 |
+
fontFamily: "Inter",
|
| 71 |
+
fontSize: 34,
|
| 72 |
+
fillColor: "#ffffff",
|
| 73 |
+
strokeColor: "#111827",
|
| 74 |
+
strokeWidth: 3,
|
| 75 |
+
position: 18,
|
| 76 |
+
cueDensity: "medium",
|
| 77 |
+
animation: "none",
|
| 78 |
+
},
|
| 79 |
+
bold: {
|
| 80 |
+
fontFamily: "Impact",
|
| 81 |
+
fontSize: 46,
|
| 82 |
+
fillColor: "#fef08a",
|
| 83 |
+
strokeColor: "#020617",
|
| 84 |
+
strokeWidth: 5,
|
| 85 |
+
position: 20,
|
| 86 |
+
cueDensity: "short",
|
| 87 |
+
animation: "pop",
|
| 88 |
+
},
|
| 89 |
+
karaoke: {
|
| 90 |
+
fontFamily: "Poppins",
|
| 91 |
+
fontSize: 40,
|
| 92 |
+
fillColor: "#ffffff",
|
| 93 |
+
strokeColor: "#020617",
|
| 94 |
+
strokeWidth: 4,
|
| 95 |
+
position: 22,
|
| 96 |
+
cueDensity: "word",
|
| 97 |
+
animation: "highlight",
|
| 98 |
+
},
|
| 99 |
+
};
|
| 100 |
|
| 101 |
const defaultProfile = {
|
| 102 |
niche: "education",
|
|
|
|
| 179 |
clipRange: "Clip range",
|
| 180 |
subtitleCues: "Subtitle cues",
|
| 181 |
subtitleCueHelp: "Short cues read better on TikTok, Reels, and Shorts.",
|
| 182 |
+
timelineTracks: "Timeline tracks",
|
| 183 |
+
videoTrack: "Video",
|
| 184 |
+
subtitleTrack: "Subtitles",
|
| 185 |
+
audioTrack: "Audio",
|
| 186 |
+
toolSelect: "Select",
|
| 187 |
+
toolTrim: "Trim",
|
| 188 |
+
toolCaptions: "Captions",
|
| 189 |
+
toolStyle: "Style",
|
| 190 |
+
toolExport: "Export",
|
| 191 |
+
captionStyle: "Caption style",
|
| 192 |
+
captionPreset: "Preset",
|
| 193 |
+
presetClean: "Clean",
|
| 194 |
+
presetBold: "Bold",
|
| 195 |
+
presetKaraoke: "Karaoke",
|
| 196 |
+
font: "Font",
|
| 197 |
+
fontSize: "Size",
|
| 198 |
+
fillColor: "Fill",
|
| 199 |
+
strokeColor: "Outline",
|
| 200 |
+
strokeWidth: "Outline width",
|
| 201 |
+
captionPosition: "Caption height",
|
| 202 |
+
captionLength: "Caption length",
|
| 203 |
+
animation: "Animation",
|
| 204 |
+
density_word: "Word by word",
|
| 205 |
+
density_short: "Short phrases",
|
| 206 |
+
density_medium: "Medium phrases",
|
| 207 |
+
density_long: "Long lines",
|
| 208 |
+
animation_none: "None",
|
| 209 |
+
animation_highlight: "Color highlight",
|
| 210 |
+
animation_pop: "Pop",
|
| 211 |
+
animation_bounce: "Bounce",
|
| 212 |
editorTools: "Editing tools",
|
| 213 |
rangeStart: "Range start",
|
| 214 |
rangeEnd: "Range end",
|
|
|
|
| 339 |
clipRange: "ช่วงคลิป",
|
| 340 |
subtitleCues: "จังหวะซับ",
|
| 341 |
subtitleCueHelp: "ซับสั้น ๆ อ่านง่ายกว่าสำหรับ TikTok, Reels และ Shorts",
|
| 342 |
+
timelineTracks: "แทร็กตัดต่อ",
|
| 343 |
+
videoTrack: "วิดีโอ",
|
| 344 |
+
subtitleTrack: "ซับ",
|
| 345 |
+
audioTrack: "เสียง",
|
| 346 |
+
toolSelect: "เลือก",
|
| 347 |
+
toolTrim: "ตัด",
|
| 348 |
+
toolCaptions: "ซับ",
|
| 349 |
+
toolStyle: "สไตล์",
|
| 350 |
+
toolExport: "ส่งออก",
|
| 351 |
+
captionStyle: "สไตล์ซับ",
|
| 352 |
+
captionPreset: "พรีเซ็ต",
|
| 353 |
+
presetClean: "เรียบ",
|
| 354 |
+
presetBold: "เด่น",
|
| 355 |
+
presetKaraoke: "คาราโอเกะ",
|
| 356 |
+
font: "ฟอนต์",
|
| 357 |
+
fontSize: "ขนาด",
|
| 358 |
+
fillColor: "สีตัวอักษร",
|
| 359 |
+
strokeColor: "สีขอบ",
|
| 360 |
+
strokeWidth: "ความหนาขอบ",
|
| 361 |
+
captionPosition: "ตำแหน่งซับ",
|
| 362 |
+
captionLength: "ความยาวข้อความ",
|
| 363 |
+
animation: "อนิเมชัน",
|
| 364 |
+
density_word: "ทีละคำ",
|
| 365 |
+
density_short: "วลีสั้น",
|
| 366 |
+
density_medium: "วลีกลาง",
|
| 367 |
+
density_long: "บรรทัดยาว",
|
| 368 |
+
animation_none: "ไม่มี",
|
| 369 |
+
animation_highlight: "ไฮไลต์สี",
|
| 370 |
+
animation_pop: "เด้งเข้า",
|
| 371 |
+
animation_bounce: "เด้งตามจังหวะ",
|
| 372 |
editorTools: "เครื่องมือตัดต่อ",
|
| 373 |
rangeStart: "ตำแหน่งเริ่ม",
|
| 374 |
rangeEnd: "ตำแหน่งจบ",
|
|
|
|
| 491 |
subtitles: "字幕",
|
| 492 |
subtitleCues: "字幕キュー",
|
| 493 |
subtitleCueHelp: "短い字幕キューのほうがTikTok、Reels、Shortsで読みやすくなります。",
|
| 494 |
+
timelineTracks: "タイムライントラック",
|
| 495 |
+
videoTrack: "動画",
|
| 496 |
+
subtitleTrack: "字幕",
|
| 497 |
+
audioTrack: "音声",
|
| 498 |
+
toolSelect: "選択",
|
| 499 |
+
toolTrim: "トリム",
|
| 500 |
+
toolCaptions: "字幕",
|
| 501 |
+
toolStyle: "スタイル",
|
| 502 |
+
toolExport: "書き出し",
|
| 503 |
+
captionStyle: "字幕スタイル",
|
| 504 |
+
captionPreset: "プリセット",
|
| 505 |
+
presetClean: "クリーン",
|
| 506 |
+
presetBold: "太字",
|
| 507 |
+
presetKaraoke: "カラオケ",
|
| 508 |
+
font: "フォント",
|
| 509 |
+
fontSize: "サイズ",
|
| 510 |
+
fillColor: "塗り",
|
| 511 |
+
strokeColor: "縁取り",
|
| 512 |
+
strokeWidth: "縁取り幅",
|
| 513 |
+
captionPosition: "字幕位置",
|
| 514 |
+
captionLength: "字幕の長さ",
|
| 515 |
+
animation: "アニメーション",
|
| 516 |
+
density_word: "単語ごと",
|
| 517 |
+
density_short: "短いフレーズ",
|
| 518 |
+
density_medium: "中くらい",
|
| 519 |
+
density_long: "長い行",
|
| 520 |
+
animation_none: "なし",
|
| 521 |
+
animation_highlight: "色ハイライト",
|
| 522 |
+
animation_pop: "ポップ",
|
| 523 |
+
animation_bounce: "バウンス",
|
| 524 |
editorTools: "編集ツール",
|
| 525 |
rangeStart: "開始位置",
|
| 526 |
rangeEnd: "終了位置",
|
|
|
|
| 648 |
subtitles: "字幕",
|
| 649 |
subtitleCues: "字幕片段",
|
| 650 |
subtitleCueHelp: "短字幕片段更适合 TikTok、Reels 和 Shorts。",
|
| 651 |
+
timelineTracks: "时间线轨道",
|
| 652 |
+
videoTrack: "视频",
|
| 653 |
+
subtitleTrack: "字幕",
|
| 654 |
+
audioTrack: "音频",
|
| 655 |
+
toolSelect: "选择",
|
| 656 |
+
toolTrim: "裁剪",
|
| 657 |
+
toolCaptions: "字幕",
|
| 658 |
+
toolStyle: "样式",
|
| 659 |
+
toolExport: "导出",
|
| 660 |
+
captionStyle: "字幕样式",
|
| 661 |
+
captionPreset: "预设",
|
| 662 |
+
presetClean: "简洁",
|
| 663 |
+
presetBold: "醒目",
|
| 664 |
+
presetKaraoke: "卡拉 OK",
|
| 665 |
+
font: "字体",
|
| 666 |
+
fontSize: "大小",
|
| 667 |
+
fillColor: "填充",
|
| 668 |
+
strokeColor: "描边",
|
| 669 |
+
strokeWidth: "描边宽度",
|
| 670 |
+
captionPosition: "字幕高度",
|
| 671 |
+
captionLength: "字幕长度",
|
| 672 |
+
animation: "动画",
|
| 673 |
+
density_word: "逐词",
|
| 674 |
+
density_short: "短词组",
|
| 675 |
+
density_medium: "中等词组",
|
| 676 |
+
density_long: "长句",
|
| 677 |
+
animation_none: "无",
|
| 678 |
+
animation_highlight: "颜色高亮",
|
| 679 |
+
animation_pop: "弹出",
|
| 680 |
+
animation_bounce: "弹跳",
|
| 681 |
editorTools: "编辑工具",
|
| 682 |
rangeStart: "开始位置",
|
| 683 |
rangeEnd: "结束位置",
|
|
|
|
| 806 |
subtitles: "자막",
|
| 807 |
subtitleCues: "자막 큐",
|
| 808 |
subtitleCueHelp: "짧은 자막 큐가 TikTok, Reels, Shorts에서 더 읽기 쉽습니다.",
|
| 809 |
+
timelineTracks: "타임라인 트랙",
|
| 810 |
+
videoTrack: "비디오",
|
| 811 |
+
subtitleTrack: "자막",
|
| 812 |
+
audioTrack: "오디오",
|
| 813 |
+
toolSelect: "선택",
|
| 814 |
+
toolTrim: "자르기",
|
| 815 |
+
toolCaptions: "자막",
|
| 816 |
+
toolStyle: "스타일",
|
| 817 |
+
toolExport: "내보내기",
|
| 818 |
+
captionStyle: "자막 스타일",
|
| 819 |
+
captionPreset: "프리셋",
|
| 820 |
+
presetClean: "깔끔",
|
| 821 |
+
presetBold: "강조",
|
| 822 |
+
presetKaraoke: "노래방",
|
| 823 |
+
font: "글꼴",
|
| 824 |
+
fontSize: "크기",
|
| 825 |
+
fillColor: "채우기",
|
| 826 |
+
strokeColor: "외곽선",
|
| 827 |
+
strokeWidth: "외곽선 두께",
|
| 828 |
+
captionPosition: "자막 높이",
|
| 829 |
+
captionLength: "자막 길이",
|
| 830 |
+
animation: "애니메이션",
|
| 831 |
+
density_word: "단어별",
|
| 832 |
+
density_short: "짧은 구문",
|
| 833 |
+
density_medium: "중간 구문",
|
| 834 |
+
density_long: "긴 줄",
|
| 835 |
+
animation_none: "없음",
|
| 836 |
+
animation_highlight: "색상 강조",
|
| 837 |
+
animation_pop: "팝",
|
| 838 |
+
animation_bounce: "바운스",
|
| 839 |
editorTools: "편집 도구",
|
| 840 |
rangeStart: "시작 위치",
|
| 841 |
rangeEnd: "종료 위치",
|
|
|
|
| 911 |
const [error, setError] = useState("");
|
| 912 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 913 |
const [editorClipId, setEditorClipId] = useState(null);
|
| 914 |
+
const [captionStyles, setCaptionStyles] = useState(() => {
|
| 915 |
+
try {
|
| 916 |
+
return JSON.parse(localStorage.getItem("elevenclip.captionStyles") || "{}");
|
| 917 |
+
} catch {
|
| 918 |
+
return {};
|
| 919 |
+
}
|
| 920 |
+
});
|
| 921 |
const [language, setLanguage] = useState(() => localStorage.getItem("elevenclip.language") || "en");
|
| 922 |
const [theme, setTheme] = useState(() => {
|
| 923 |
const saved = localStorage.getItem("elevenclip.theme");
|
|
|
|
| 940 |
[job?.clips]
|
| 941 |
);
|
| 942 |
const editorClip = activeClips.find((clip) => clip.id === editorClipId);
|
| 943 |
+
const editorCaptionStyle = editorClip
|
| 944 |
+
? { ...defaultCaptionStyle, ...(captionStyles[editorClip.id] || {}) }
|
| 945 |
+
: defaultCaptionStyle;
|
| 946 |
|
| 947 |
useEffect(() => {
|
| 948 |
document.documentElement.dataset.theme = theme;
|
|
|
|
| 953 |
localStorage.setItem("elevenclip.language", language);
|
| 954 |
}, [language]);
|
| 955 |
|
| 956 |
+
useEffect(() => {
|
| 957 |
+
localStorage.setItem("elevenclip.captionStyles", JSON.stringify(captionStyles));
|
| 958 |
+
}, [captionStyles]);
|
| 959 |
+
|
| 960 |
useEffect(() => {
|
| 961 |
fetchJson("/health").then(setHealth).catch(() => setHealth(null));
|
| 962 |
}, []);
|
|
|
|
| 1002 |
}
|
| 1003 |
|
| 1004 |
async function patchClip(clipId, patch) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1005 |
setJob((current) => ({
|
| 1006 |
...current,
|
| 1007 |
+
clips: current.clips.map((clip) => (clip.id === clipId ? { ...clip, ...patch } : clip)),
|
| 1008 |
}));
|
| 1009 |
+
try {
|
| 1010 |
+
const nextClip = await fetchJson(`/api/jobs/${job.id}/clips/${clipId}`, {
|
| 1011 |
+
method: "PATCH",
|
| 1012 |
+
headers: { "Content-Type": "application/json" },
|
| 1013 |
+
body: JSON.stringify(patch),
|
| 1014 |
+
});
|
| 1015 |
+
setJob((current) => ({
|
| 1016 |
+
...current,
|
| 1017 |
+
clips: current.clips.map((clip) => (clip.id === clipId ? nextClip : clip)),
|
| 1018 |
+
}));
|
| 1019 |
+
} catch (exc) {
|
| 1020 |
+
setError(exc.message);
|
| 1021 |
+
}
|
| 1022 |
}
|
| 1023 |
|
| 1024 |
async function regenerateClip(clip) {
|
|
|
|
| 1041 |
return (value) => setProfile((current) => ({ ...current, [key]: value }));
|
| 1042 |
}
|
| 1043 |
|
| 1044 |
+
function updateCaptionStyle(clipId, patch) {
|
| 1045 |
+
setCaptionStyles((current) => ({
|
| 1046 |
+
...current,
|
| 1047 |
+
[clipId]: { ...defaultCaptionStyle, ...(current[clipId] || {}), ...patch },
|
| 1048 |
+
}));
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
return (
|
| 1052 |
<main className="app-shell">
|
| 1053 |
<AppHeader
|
|
|
|
| 1070 |
onDelete={(clip) => patchClip(clip.id, { deleted: true })}
|
| 1071 |
onApprove={(clip) => patchClip(clip.id, { approved: !clip.approved })}
|
| 1072 |
onRegenerate={regenerateClip}
|
| 1073 |
+
captionStyle={editorCaptionStyle}
|
| 1074 |
+
onCaptionStyleChange={(patch) => updateCaptionStyle(editorClip.id, patch)}
|
| 1075 |
/>
|
| 1076 |
) : (
|
| 1077 |
<div className="workspace-grid">
|
|
|
|
| 1203 |
value={profile.channel_description}
|
| 1204 |
onChange={setProfileValue("channel_description")}
|
| 1205 |
placeholder={t("channelDescriptionPlaceholder")}
|
| 1206 |
+
rows={3}
|
| 1207 |
/>
|
| 1208 |
|
| 1209 |
<div className="form-grid-two">
|
|
|
|
| 1469 |
</span>
|
| 1470 |
</div>
|
| 1471 |
|
| 1472 |
+
<p className="subtitle-snippet">{clip.subtitle_text}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1473 |
|
| 1474 |
<div className="clip-actions">
|
| 1475 |
+
<button className="action-primary" type="button" title={t("openEditor")} onClick={() => onOpenEditor(clip)}>
|
| 1476 |
<PanelRightOpen size={16} />
|
| 1477 |
{t("openEditor")}
|
| 1478 |
</button>
|
| 1479 |
+
<button className="action-approve" type="button" title={t("approve")} onClick={() => onApprove(clip)}>
|
| 1480 |
<Check size={16} />
|
| 1481 |
{clip.approved ? t("approved") : t("approve")}
|
| 1482 |
</button>
|
|
|
|
| 1497 |
);
|
| 1498 |
}
|
| 1499 |
|
| 1500 |
+
function ClipEditorPage({
|
| 1501 |
+
clip,
|
| 1502 |
+
job,
|
| 1503 |
+
t,
|
| 1504 |
+
onBack,
|
| 1505 |
+
onPatch,
|
| 1506 |
+
onDelete,
|
| 1507 |
+
onApprove,
|
| 1508 |
+
onRegenerate,
|
| 1509 |
+
captionStyle,
|
| 1510 |
+
onCaptionStyleChange,
|
| 1511 |
+
}) {
|
| 1512 |
const duration = Math.max(1, clip.end_seconds - clip.start_seconds);
|
| 1513 |
+
const cues = getSubtitleCues(clip, duration, captionStyle);
|
| 1514 |
const metadataModel = clip.metadata?.model || "unknown";
|
| 1515 |
const sourceKind = job?.source?.kind || "video";
|
| 1516 |
+
const activeCue = cues[0]?.text || clip.subtitle_text || clip.title;
|
| 1517 |
const timelineDuration = Math.max(
|
| 1518 |
clip.end_seconds,
|
| 1519 |
...(job?.transcript || []).map((segment) => Number(segment.end_seconds || 0)),
|
|
|
|
| 1563 |
</div>
|
| 1564 |
|
| 1565 |
<div className="editor-grid">
|
| 1566 |
+
<aside className="tool-rail" aria-label={t("editorTools")}>
|
| 1567 |
+
<button type="button" className="active" title={t("toolSelect")}>
|
| 1568 |
+
<PanelRightOpen size={18} />
|
| 1569 |
+
<span>{t("toolSelect")}</span>
|
| 1570 |
+
</button>
|
| 1571 |
+
<button type="button" title={t("toolTrim")}>
|
| 1572 |
+
<Scissors size={18} />
|
| 1573 |
+
<span>{t("toolTrim")}</span>
|
| 1574 |
+
</button>
|
| 1575 |
+
<button type="button" title={t("toolCaptions")}>
|
| 1576 |
+
<Captions size={18} />
|
| 1577 |
+
<span>{t("toolCaptions")}</span>
|
| 1578 |
+
</button>
|
| 1579 |
+
<button type="button" title={t("toolStyle")}>
|
| 1580 |
+
<SlidersHorizontal size={18} />
|
| 1581 |
+
<span>{t("toolStyle")}</span>
|
| 1582 |
+
</button>
|
| 1583 |
+
<button type="button" title={t("toolExport")}>
|
| 1584 |
+
<Download size={18} />
|
| 1585 |
+
<span>{t("toolExport")}</span>
|
| 1586 |
+
</button>
|
| 1587 |
+
</aside>
|
| 1588 |
+
|
| 1589 |
<section className="panel editor-main">
|
| 1590 |
<div className="panel-heading compact">
|
| 1591 |
<div>
|
|
|
|
| 1598 |
</div>
|
| 1599 |
<div className="editor-preview">
|
| 1600 |
{clip.video_url ? <video controls src={`${API_BASE}${clip.video_url}`} /> : <Film size={44} />}
|
| 1601 |
+
<CaptionPreview text={activeCue} settings={captionStyle} />
|
| 1602 |
</div>
|
| 1603 |
|
| 1604 |
<div className="range-editor">
|
|
|
|
| 1687 |
</div>
|
| 1688 |
</div>
|
| 1689 |
|
| 1690 |
+
<TimelineTracks clip={clip} cues={cues} duration={timelineDuration} t={t} />
|
| 1691 |
+
|
| 1692 |
<div className="subtitle-editor">
|
| 1693 |
<div className="panel-heading compact">
|
| 1694 |
<div>
|
|
|
|
| 1771 |
</button>
|
| 1772 |
</div>
|
| 1773 |
|
| 1774 |
+
<CaptionStylePanel t={t} settings={captionStyle} onChange={onCaptionStyleChange} />
|
| 1775 |
+
|
| 1776 |
<TranscriptMini transcript={job?.transcript || []} clip={clip} t={t} />
|
| 1777 |
</aside>
|
| 1778 |
</div>
|
|
|
|
| 1780 |
);
|
| 1781 |
}
|
| 1782 |
|
| 1783 |
+
function CaptionPreview({ text, settings }) {
|
| 1784 |
+
const words = text.split(/\s+/).filter(Boolean);
|
| 1785 |
+
const litCount = Math.max(1, Math.ceil(words.length * 0.45));
|
| 1786 |
+
const style = {
|
| 1787 |
+
bottom: `${settings.position}%`,
|
| 1788 |
+
color: settings.fillColor,
|
| 1789 |
+
fontFamily: `"${settings.fontFamily}", Inter, ui-sans-serif, system-ui, sans-serif`,
|
| 1790 |
+
fontSize: `${settings.fontSize}px`,
|
| 1791 |
+
WebkitTextStroke: `${settings.strokeWidth}px ${settings.strokeColor}`,
|
| 1792 |
+
textShadow: `0 3px 12px ${settings.strokeColor}`,
|
| 1793 |
+
};
|
| 1794 |
+
|
| 1795 |
+
return (
|
| 1796 |
+
<div className={`caption-preview ${settings.animation}`} style={style}>
|
| 1797 |
+
{settings.animation === "highlight"
|
| 1798 |
+
? words.map((word, index) => (
|
| 1799 |
+
<span key={`${word}-${index}`} className={index < litCount ? "lit" : ""}>
|
| 1800 |
+
{word}
|
| 1801 |
+
{index < words.length - 1 ? " " : ""}
|
| 1802 |
+
</span>
|
| 1803 |
+
))
|
| 1804 |
+
: text}
|
| 1805 |
+
</div>
|
| 1806 |
+
);
|
| 1807 |
+
}
|
| 1808 |
+
|
| 1809 |
+
function TimelineTracks({ clip, cues, duration, t }) {
|
| 1810 |
+
const clipLeft = clamp((clip.start_seconds / duration) * 100, 0, 100);
|
| 1811 |
+
const clipWidth = clamp(((clip.end_seconds - clip.start_seconds) / duration) * 100, 4, 100);
|
| 1812 |
+
const subtitleItems = cues.slice(0, 8);
|
| 1813 |
+
return (
|
| 1814 |
+
<div className="timeline-workbench">
|
| 1815 |
+
<div className="panel-heading compact">
|
| 1816 |
+
<div>
|
| 1817 |
+
<h2>{t("timelineTracks")}</h2>
|
| 1818 |
+
<p>
|
| 1819 |
+
{formatTime(clip.start_seconds)} - {formatTime(clip.end_seconds)}
|
| 1820 |
+
</p>
|
| 1821 |
+
</div>
|
| 1822 |
+
<Film size={18} />
|
| 1823 |
+
</div>
|
| 1824 |
+
<div className="track-stack">
|
| 1825 |
+
<TrackRow label={t("videoTrack")}>
|
| 1826 |
+
<div className="track-clip video" style={{ left: `${clipLeft}%`, width: `${clipWidth}%` }}>
|
| 1827 |
+
{clip.title}
|
| 1828 |
+
</div>
|
| 1829 |
+
</TrackRow>
|
| 1830 |
+
<TrackRow label={t("subtitleTrack")}>
|
| 1831 |
+
{subtitleItems.map((cue, index) => (
|
| 1832 |
+
<div
|
| 1833 |
+
className="track-clip subtitle"
|
| 1834 |
+
key={`${cue.start_seconds}-${index}`}
|
| 1835 |
+
style={{
|
| 1836 |
+
left: `${clamp(((clip.start_seconds + cue.start_seconds) / duration) * 100, 0, 100)}%`,
|
| 1837 |
+
width: `${clamp(((cue.end_seconds - cue.start_seconds) / duration) * 100, 3, 45)}%`,
|
| 1838 |
+
}}
|
| 1839 |
+
>
|
| 1840 |
+
{cue.text}
|
| 1841 |
+
</div>
|
| 1842 |
+
))}
|
| 1843 |
+
</TrackRow>
|
| 1844 |
+
<TrackRow label={t("audioTrack")}>
|
| 1845 |
+
<div className="waveform">
|
| 1846 |
+
{Array.from({ length: 42 }).map((_, index) => (
|
| 1847 |
+
<span key={index} style={{ height: `${18 + ((index * 17) % 34)}%` }} />
|
| 1848 |
+
))}
|
| 1849 |
+
</div>
|
| 1850 |
+
</TrackRow>
|
| 1851 |
+
</div>
|
| 1852 |
+
</div>
|
| 1853 |
+
);
|
| 1854 |
+
}
|
| 1855 |
+
|
| 1856 |
+
function TrackRow({ label, children }) {
|
| 1857 |
+
return (
|
| 1858 |
+
<div className="track-row">
|
| 1859 |
+
<span>{label}</span>
|
| 1860 |
+
<div className="track-lane">{children}</div>
|
| 1861 |
+
</div>
|
| 1862 |
+
);
|
| 1863 |
+
}
|
| 1864 |
+
|
| 1865 |
+
function CaptionStylePanel({ t, settings, onChange }) {
|
| 1866 |
+
return (
|
| 1867 |
+
<section className="caption-style-panel">
|
| 1868 |
+
<div className="panel-heading compact">
|
| 1869 |
+
<div>
|
| 1870 |
+
<h2>{t("captionStyle")}</h2>
|
| 1871 |
+
</div>
|
| 1872 |
+
<Captions size={18} />
|
| 1873 |
+
</div>
|
| 1874 |
+
|
| 1875 |
+
<div className="preset-row">
|
| 1876 |
+
{Object.entries(captionPresets).map(([key, preset]) => (
|
| 1877 |
+
<button type="button" key={key} onClick={() => onChange(preset)}>
|
| 1878 |
+
{t(`preset${capitalize(key)}`)}
|
| 1879 |
+
</button>
|
| 1880 |
+
))}
|
| 1881 |
+
</div>
|
| 1882 |
+
|
| 1883 |
+
<div className="style-grid">
|
| 1884 |
+
<SelectField
|
| 1885 |
+
label={t("font")}
|
| 1886 |
+
value={settings.fontFamily}
|
| 1887 |
+
onChange={(value) => onChange({ fontFamily: value })}
|
| 1888 |
+
options={FONT_OPTIONS.map((value) => ({ value, label: value }))}
|
| 1889 |
+
/>
|
| 1890 |
+
<SelectField
|
| 1891 |
+
label={t("captionLength")}
|
| 1892 |
+
value={settings.cueDensity}
|
| 1893 |
+
onChange={(value) => onChange({ cueDensity: value })}
|
| 1894 |
+
options={CUE_DENSITIES.map((value) => ({ value, label: t(`density_${value}`) }))}
|
| 1895 |
+
/>
|
| 1896 |
+
<SelectField
|
| 1897 |
+
label={t("animation")}
|
| 1898 |
+
value={settings.animation}
|
| 1899 |
+
onChange={(value) => onChange({ animation: value })}
|
| 1900 |
+
options={CAPTION_ANIMATIONS.map((value) => ({ value, label: t(`animation_${value}`) }))}
|
| 1901 |
+
/>
|
| 1902 |
+
</div>
|
| 1903 |
+
|
| 1904 |
+
<div className="color-grid">
|
| 1905 |
+
<ColorField label={t("fillColor")} value={settings.fillColor} onChange={(fillColor) => onChange({ fillColor })} />
|
| 1906 |
+
<ColorField
|
| 1907 |
+
label={t("strokeColor")}
|
| 1908 |
+
value={settings.strokeColor}
|
| 1909 |
+
onChange={(strokeColor) => onChange({ strokeColor })}
|
| 1910 |
+
/>
|
| 1911 |
+
</div>
|
| 1912 |
+
|
| 1913 |
+
<RangeControl label={t("fontSize")} value={settings.fontSize} min={24} max={64} onChange={(fontSize) => onChange({ fontSize })} />
|
| 1914 |
+
<RangeControl
|
| 1915 |
+
label={t("strokeWidth")}
|
| 1916 |
+
value={settings.strokeWidth}
|
| 1917 |
+
min={0}
|
| 1918 |
+
max={8}
|
| 1919 |
+
onChange={(strokeWidth) => onChange({ strokeWidth })}
|
| 1920 |
+
/>
|
| 1921 |
+
<RangeControl
|
| 1922 |
+
label={t("captionPosition")}
|
| 1923 |
+
value={settings.position}
|
| 1924 |
+
min={8}
|
| 1925 |
+
max={38}
|
| 1926 |
+
onChange={(position) => onChange({ position })}
|
| 1927 |
+
/>
|
| 1928 |
+
</section>
|
| 1929 |
+
);
|
| 1930 |
+
}
|
| 1931 |
+
|
| 1932 |
+
function ColorField({ label, value, onChange }) {
|
| 1933 |
+
return (
|
| 1934 |
+
<label className="color-field">
|
| 1935 |
+
<span>{label}</span>
|
| 1936 |
+
<input type="color" value={value} onChange={(event) => onChange(event.target.value)} />
|
| 1937 |
+
</label>
|
| 1938 |
+
);
|
| 1939 |
+
}
|
| 1940 |
+
|
| 1941 |
+
function RangeControl({ label, value, min, max, onChange }) {
|
| 1942 |
+
return (
|
| 1943 |
+
<label className="range-control">
|
| 1944 |
+
<span>
|
| 1945 |
+
{label}
|
| 1946 |
+
<strong>{value}</strong>
|
| 1947 |
+
</span>
|
| 1948 |
+
<input
|
| 1949 |
+
type="range"
|
| 1950 |
+
min={min}
|
| 1951 |
+
max={max}
|
| 1952 |
+
step="1"
|
| 1953 |
+
value={value}
|
| 1954 |
+
onChange={(event) => onChange(Number(event.target.value))}
|
| 1955 |
+
/>
|
| 1956 |
+
</label>
|
| 1957 |
+
);
|
| 1958 |
+
}
|
| 1959 |
+
|
| 1960 |
function TranscriptMini({ transcript, clip, t }) {
|
| 1961 |
const rows = transcript.filter(
|
| 1962 |
(segment) => segment.end_seconds >= clip.start_seconds && segment.start_seconds <= clip.end_seconds
|
|
|
|
| 2052 |
return response.json();
|
| 2053 |
}
|
| 2054 |
|
| 2055 |
+
function getSubtitleCues(clip, duration, settings = defaultCaptionStyle) {
|
| 2056 |
+
return splitSubtitleText(clip.subtitle_text || "", settings.cueDensity).map((text, index, all) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2057 |
const cueDuration = duration / Math.max(all.length, 1);
|
| 2058 |
return {
|
| 2059 |
start_seconds: roundTime(index * cueDuration),
|
|
|
|
| 2063 |
});
|
| 2064 |
}
|
| 2065 |
|
| 2066 |
+
function splitSubtitleText(text, density = "short") {
|
| 2067 |
const clean = text.trim().replace(/\s+/g, " ");
|
| 2068 |
if (!clean) return [""];
|
| 2069 |
+
const limits = {
|
| 2070 |
+
word: { words: 1, chars: 18 },
|
| 2071 |
+
short: { words: 4, chars: 30 },
|
| 2072 |
+
medium: { words: 7, chars: 46 },
|
| 2073 |
+
long: { words: 12, chars: 78 },
|
| 2074 |
+
};
|
| 2075 |
+
const limit = limits[density] || limits.short;
|
| 2076 |
const words = clean.split(" ");
|
| 2077 |
if (words.length <= 1) {
|
| 2078 |
const chunks = [];
|
| 2079 |
+
for (let index = 0; index < clean.length; index += limit.chars) {
|
| 2080 |
+
chunks.push(clean.slice(index, index + limit.chars));
|
| 2081 |
}
|
| 2082 |
return chunks;
|
| 2083 |
}
|
|
|
|
| 2086 |
words.forEach((word) => {
|
| 2087 |
const candidate = [...current, word].join(" ");
|
| 2088 |
const punctuationBreak = current.length > 0 && /[,.!?;:]$/.test(current[current.length - 1]);
|
| 2089 |
+
if (current.length > 0 && (candidate.length > limit.chars || current.length >= limit.words || punctuationBreak)) {
|
| 2090 |
chunks.push(current.join(" "));
|
| 2091 |
current = [word];
|
| 2092 |
} else {
|
|
|
|
| 2105 |
return Math.min(Math.max(value, min), max);
|
| 2106 |
}
|
| 2107 |
|
| 2108 |
+
function capitalize(value) {
|
| 2109 |
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
| 2110 |
+
}
|
| 2111 |
+
|
| 2112 |
function formatTime(value) {
|
| 2113 |
const safeValue = Number.isFinite(Number(value)) ? Number(value) : 0;
|
| 2114 |
const minutes = Math.floor(safeValue / 60);
|
frontend/src/styles.css
CHANGED
|
@@ -249,10 +249,10 @@ button:disabled {
|
|
| 249 |
|
| 250 |
.workspace-grid {
|
| 251 |
display: grid;
|
| 252 |
-
grid-template-columns: minmax(320px,
|
| 253 |
-
gap:
|
| 254 |
align-items: start;
|
| 255 |
-
padding:
|
| 256 |
}
|
| 257 |
|
| 258 |
.center-column,
|
|
@@ -280,10 +280,8 @@ button:disabled {
|
|
| 280 |
}
|
| 281 |
|
| 282 |
.input-panel {
|
| 283 |
-
position:
|
| 284 |
-
|
| 285 |
-
max-height: calc(100vh - 120px);
|
| 286 |
-
overflow: auto;
|
| 287 |
}
|
| 288 |
|
| 289 |
.results-column {
|
|
@@ -555,7 +553,7 @@ textarea:focus,
|
|
| 555 |
|
| 556 |
.clip-grid {
|
| 557 |
display: grid;
|
| 558 |
-
grid-template-columns: repeat(auto-fit, minmax(
|
| 559 |
gap: 14px;
|
| 560 |
align-items: start;
|
| 561 |
}
|
|
@@ -565,12 +563,13 @@ textarea:focus,
|
|
| 565 |
border: 1px solid var(--border);
|
| 566 |
border-radius: var(--radius);
|
| 567 |
background: var(--surface-muted);
|
|
|
|
| 568 |
}
|
| 569 |
|
| 570 |
.clip-video {
|
| 571 |
display: grid;
|
| 572 |
aspect-ratio: 9 / 16;
|
| 573 |
-
max-height:
|
| 574 |
place-items: center;
|
| 575 |
background: #050b16;
|
| 576 |
color: white;
|
|
@@ -639,13 +638,42 @@ textarea:focus,
|
|
| 639 |
}
|
| 640 |
|
| 641 |
.clip-actions {
|
| 642 |
-
|
|
|
|
| 643 |
gap: 8px;
|
| 644 |
}
|
| 645 |
|
| 646 |
.clip-actions button,
|
| 647 |
.download-button {
|
|
|
|
| 648 |
padding: 0 10px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
}
|
| 650 |
|
| 651 |
.download-button {
|
|
@@ -661,8 +689,8 @@ textarea:focus,
|
|
| 661 |
|
| 662 |
.editor-shell {
|
| 663 |
display: grid;
|
| 664 |
-
gap:
|
| 665 |
-
padding:
|
| 666 |
}
|
| 667 |
|
| 668 |
.editor-topbar {
|
|
@@ -685,11 +713,43 @@ textarea:focus,
|
|
| 685 |
|
| 686 |
.editor-grid {
|
| 687 |
display: grid;
|
| 688 |
-
grid-template-columns: minmax(0, 1fr) minmax(
|
| 689 |
-
gap:
|
| 690 |
align-items: start;
|
| 691 |
}
|
| 692 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
.editor-main,
|
| 694 |
.inspector-panel {
|
| 695 |
display: grid;
|
|
@@ -698,8 +758,10 @@ textarea:focus,
|
|
| 698 |
|
| 699 |
.editor-preview {
|
| 700 |
display: grid;
|
|
|
|
| 701 |
min-height: 520px;
|
| 702 |
max-height: 68vh;
|
|
|
|
| 703 |
place-items: center;
|
| 704 |
border-radius: var(--radius);
|
| 705 |
background: #050b16;
|
|
@@ -713,8 +775,54 @@ textarea:focus,
|
|
| 713 |
object-fit: contain;
|
| 714 |
}
|
| 715 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
.range-editor,
|
| 717 |
-
.subtitle-editor
|
|
|
|
|
|
|
| 718 |
display: grid;
|
| 719 |
gap: 12px;
|
| 720 |
border: 1px solid var(--border);
|
|
@@ -854,6 +962,81 @@ textarea:focus,
|
|
| 854 |
font-variant-numeric: tabular-nums;
|
| 855 |
}
|
| 856 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
.cue-list {
|
| 858 |
display: grid;
|
| 859 |
gap: 10px;
|
|
@@ -900,6 +1083,69 @@ textarea:focus,
|
|
| 900 |
color: var(--danger);
|
| 901 |
}
|
| 902 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
.mini-transcript {
|
| 904 |
display: grid;
|
| 905 |
gap: 10px;
|
|
@@ -937,6 +1183,14 @@ textarea:focus,
|
|
| 937 |
grid-template-columns: minmax(290px, 340px) minmax(0, 1fr);
|
| 938 |
}
|
| 939 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 940 |
.results-column {
|
| 941 |
grid-column: 1 / -1;
|
| 942 |
}
|
|
@@ -958,6 +1212,16 @@ textarea:focus,
|
|
| 958 |
grid-template-columns: 1fr;
|
| 959 |
}
|
| 960 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
.input-panel {
|
| 962 |
position: static;
|
| 963 |
max-height: none;
|
|
@@ -981,10 +1245,16 @@ textarea:focus,
|
|
| 981 |
.range-sliders,
|
| 982 |
.timeline,
|
| 983 |
.cue-row,
|
|
|
|
| 984 |
.transcript-row {
|
| 985 |
grid-template-columns: 1fr;
|
| 986 |
}
|
| 987 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
.progress-percent {
|
| 989 |
font-size: 1.5rem;
|
| 990 |
}
|
|
|
|
| 249 |
|
| 250 |
.workspace-grid {
|
| 251 |
display: grid;
|
| 252 |
+
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
| 253 |
+
gap: 14px;
|
| 254 |
align-items: start;
|
| 255 |
+
padding: 18px clamp(16px, 3vw, 40px) 36px;
|
| 256 |
}
|
| 257 |
|
| 258 |
.center-column,
|
|
|
|
| 280 |
}
|
| 281 |
|
| 282 |
.input-panel {
|
| 283 |
+
position: static;
|
| 284 |
+
overflow: visible;
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
|
| 287 |
.results-column {
|
|
|
|
| 553 |
|
| 554 |
.clip-grid {
|
| 555 |
display: grid;
|
| 556 |
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
| 557 |
gap: 14px;
|
| 558 |
align-items: start;
|
| 559 |
}
|
|
|
|
| 563 |
border: 1px solid var(--border);
|
| 564 |
border-radius: var(--radius);
|
| 565 |
background: var(--surface-muted);
|
| 566 |
+
min-width: 0;
|
| 567 |
}
|
| 568 |
|
| 569 |
.clip-video {
|
| 570 |
display: grid;
|
| 571 |
aspect-ratio: 9 / 16;
|
| 572 |
+
max-height: 300px;
|
| 573 |
place-items: center;
|
| 574 |
background: #050b16;
|
| 575 |
color: white;
|
|
|
|
| 638 |
}
|
| 639 |
|
| 640 |
.clip-actions {
|
| 641 |
+
display: grid;
|
| 642 |
+
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
|
| 643 |
gap: 8px;
|
| 644 |
}
|
| 645 |
|
| 646 |
.clip-actions button,
|
| 647 |
.download-button {
|
| 648 |
+
min-width: 38px;
|
| 649 |
padding: 0 10px;
|
| 650 |
+
white-space: nowrap;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.clip-actions button:not(.action-primary):not(.action-approve),
|
| 654 |
+
.download-button {
|
| 655 |
+
width: 38px;
|
| 656 |
+
padding: 0;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.clip-actions .action-approve {
|
| 660 |
+
min-width: 92px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.subtitle-snippet {
|
| 664 |
+
display: -webkit-box;
|
| 665 |
+
min-height: 54px;
|
| 666 |
+
margin: 0;
|
| 667 |
+
overflow: hidden;
|
| 668 |
+
-webkit-box-orient: vertical;
|
| 669 |
+
-webkit-line-clamp: 3;
|
| 670 |
+
border: 1px solid var(--border);
|
| 671 |
+
border-radius: 7px;
|
| 672 |
+
background: var(--surface);
|
| 673 |
+
color: var(--text);
|
| 674 |
+
font-size: 0.84rem;
|
| 675 |
+
line-height: 1.45;
|
| 676 |
+
padding: 10px;
|
| 677 |
}
|
| 678 |
|
| 679 |
.download-button {
|
|
|
|
| 689 |
|
| 690 |
.editor-shell {
|
| 691 |
display: grid;
|
| 692 |
+
gap: 14px;
|
| 693 |
+
padding: 18px clamp(16px, 3vw, 40px) 32px;
|
| 694 |
}
|
| 695 |
|
| 696 |
.editor-topbar {
|
|
|
|
| 713 |
|
| 714 |
.editor-grid {
|
| 715 |
display: grid;
|
| 716 |
+
grid-template-columns: 74px minmax(0, 1fr) minmax(330px, 420px);
|
| 717 |
+
gap: 14px;
|
| 718 |
align-items: start;
|
| 719 |
}
|
| 720 |
|
| 721 |
+
.tool-rail {
|
| 722 |
+
position: sticky;
|
| 723 |
+
top: 96px;
|
| 724 |
+
display: grid;
|
| 725 |
+
gap: 8px;
|
| 726 |
+
border: 1px solid var(--border);
|
| 727 |
+
border-radius: var(--radius);
|
| 728 |
+
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
| 729 |
+
padding: 8px;
|
| 730 |
+
box-shadow: var(--shadow);
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
.tool-rail button {
|
| 734 |
+
display: grid;
|
| 735 |
+
min-height: 58px;
|
| 736 |
+
place-items: center;
|
| 737 |
+
gap: 4px;
|
| 738 |
+
border: 1px solid transparent;
|
| 739 |
+
border-radius: 7px;
|
| 740 |
+
background: transparent;
|
| 741 |
+
color: var(--text-muted);
|
| 742 |
+
font-size: 0.68rem;
|
| 743 |
+
font-weight: 800;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.tool-rail button.active,
|
| 747 |
+
.tool-rail button:hover {
|
| 748 |
+
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
|
| 749 |
+
background: var(--primary-soft);
|
| 750 |
+
color: var(--primary-strong);
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
.editor-main,
|
| 754 |
.inspector-panel {
|
| 755 |
display: grid;
|
|
|
|
| 758 |
|
| 759 |
.editor-preview {
|
| 760 |
display: grid;
|
| 761 |
+
position: relative;
|
| 762 |
min-height: 520px;
|
| 763 |
max-height: 68vh;
|
| 764 |
+
overflow: hidden;
|
| 765 |
place-items: center;
|
| 766 |
border-radius: var(--radius);
|
| 767 |
background: #050b16;
|
|
|
|
| 775 |
object-fit: contain;
|
| 776 |
}
|
| 777 |
|
| 778 |
+
.caption-preview {
|
| 779 |
+
position: absolute;
|
| 780 |
+
left: 50%;
|
| 781 |
+
width: min(82%, 760px);
|
| 782 |
+
transform: translateX(-50%);
|
| 783 |
+
color: white;
|
| 784 |
+
font-weight: 900;
|
| 785 |
+
letter-spacing: 0;
|
| 786 |
+
line-height: 1.08;
|
| 787 |
+
text-align: center;
|
| 788 |
+
text-transform: none;
|
| 789 |
+
pointer-events: none;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.caption-preview .lit {
|
| 793 |
+
color: var(--accent);
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
.caption-preview.pop {
|
| 797 |
+
animation: caption-pop 900ms ease-in-out infinite alternate;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.caption-preview.bounce {
|
| 801 |
+
animation: caption-bounce 760ms ease-in-out infinite alternate;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
@keyframes caption-pop {
|
| 805 |
+
from {
|
| 806 |
+
transform: translateX(-50%) scale(0.98);
|
| 807 |
+
}
|
| 808 |
+
to {
|
| 809 |
+
transform: translateX(-50%) scale(1.04);
|
| 810 |
+
}
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
@keyframes caption-bounce {
|
| 814 |
+
from {
|
| 815 |
+
transform: translateX(-50%) translateY(0);
|
| 816 |
+
}
|
| 817 |
+
to {
|
| 818 |
+
transform: translateX(-50%) translateY(-8px);
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
.range-editor,
|
| 823 |
+
.subtitle-editor,
|
| 824 |
+
.timeline-workbench,
|
| 825 |
+
.caption-style-panel {
|
| 826 |
display: grid;
|
| 827 |
gap: 12px;
|
| 828 |
border: 1px solid var(--border);
|
|
|
|
| 962 |
font-variant-numeric: tabular-nums;
|
| 963 |
}
|
| 964 |
|
| 965 |
+
.track-stack {
|
| 966 |
+
display: grid;
|
| 967 |
+
gap: 8px;
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
.track-row {
|
| 971 |
+
display: grid;
|
| 972 |
+
grid-template-columns: 92px minmax(0, 1fr);
|
| 973 |
+
gap: 10px;
|
| 974 |
+
align-items: center;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
.track-row > span {
|
| 978 |
+
color: var(--text-muted);
|
| 979 |
+
font-size: 0.75rem;
|
| 980 |
+
font-weight: 850;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.track-lane {
|
| 984 |
+
position: relative;
|
| 985 |
+
min-height: 42px;
|
| 986 |
+
overflow: hidden;
|
| 987 |
+
border: 1px solid var(--border);
|
| 988 |
+
border-radius: 7px;
|
| 989 |
+
background:
|
| 990 |
+
repeating-linear-gradient(
|
| 991 |
+
90deg,
|
| 992 |
+
transparent 0,
|
| 993 |
+
transparent 11.8%,
|
| 994 |
+
color-mix(in srgb, var(--border) 70%, transparent) 12%,
|
| 995 |
+
transparent 12.2%
|
| 996 |
+
),
|
| 997 |
+
var(--surface);
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
.track-clip {
|
| 1001 |
+
position: absolute;
|
| 1002 |
+
top: 7px;
|
| 1003 |
+
bottom: 7px;
|
| 1004 |
+
display: flex;
|
| 1005 |
+
min-width: 54px;
|
| 1006 |
+
align-items: center;
|
| 1007 |
+
overflow: hidden;
|
| 1008 |
+
border-radius: 6px;
|
| 1009 |
+
color: #042f2e;
|
| 1010 |
+
font-size: 0.72rem;
|
| 1011 |
+
font-weight: 850;
|
| 1012 |
+
padding: 0 8px;
|
| 1013 |
+
text-overflow: ellipsis;
|
| 1014 |
+
white-space: nowrap;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
.track-clip.video {
|
| 1018 |
+
background: linear-gradient(90deg, #5eead4, #22c55e);
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
.track-clip.subtitle {
|
| 1022 |
+
background: #fde68a;
|
| 1023 |
+
color: #422006;
|
| 1024 |
+
}
|
| 1025 |
+
|
| 1026 |
+
.waveform {
|
| 1027 |
+
position: absolute;
|
| 1028 |
+
inset: 8px 10px;
|
| 1029 |
+
display: flex;
|
| 1030 |
+
align-items: center;
|
| 1031 |
+
gap: 4px;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.waveform span {
|
| 1035 |
+
width: 4px;
|
| 1036 |
+
border-radius: 999px;
|
| 1037 |
+
background: color-mix(in srgb, var(--primary) 70%, var(--text-soft));
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
.cue-list {
|
| 1041 |
display: grid;
|
| 1042 |
gap: 10px;
|
|
|
|
| 1083 |
color: var(--danger);
|
| 1084 |
}
|
| 1085 |
|
| 1086 |
+
.caption-style-panel {
|
| 1087 |
+
background: color-mix(in srgb, var(--surface-muted) 92%, var(--surface));
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
.preset-row {
|
| 1091 |
+
display: grid;
|
| 1092 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 1093 |
+
gap: 8px;
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
.preset-row button {
|
| 1097 |
+
min-height: 36px;
|
| 1098 |
+
border: 1px solid var(--border);
|
| 1099 |
+
border-radius: 7px;
|
| 1100 |
+
background: var(--surface);
|
| 1101 |
+
color: var(--text);
|
| 1102 |
+
font-weight: 800;
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
.style-grid,
|
| 1106 |
+
.color-grid {
|
| 1107 |
+
display: grid;
|
| 1108 |
+
gap: 10px;
|
| 1109 |
+
}
|
| 1110 |
+
|
| 1111 |
+
.color-grid {
|
| 1112 |
+
grid-template-columns: 1fr 1fr;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
.color-field,
|
| 1116 |
+
.range-control {
|
| 1117 |
+
display: grid;
|
| 1118 |
+
gap: 7px;
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
.color-field span,
|
| 1122 |
+
.range-control span {
|
| 1123 |
+
display: flex;
|
| 1124 |
+
justify-content: space-between;
|
| 1125 |
+
color: var(--text-muted);
|
| 1126 |
+
font-size: 0.76rem;
|
| 1127 |
+
font-weight: 800;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.range-control strong {
|
| 1131 |
+
color: var(--text);
|
| 1132 |
+
font-variant-numeric: tabular-nums;
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.color-field input {
|
| 1136 |
+
width: 100%;
|
| 1137 |
+
height: 38px;
|
| 1138 |
+
border: 1px solid var(--border);
|
| 1139 |
+
border-radius: 7px;
|
| 1140 |
+
background: var(--surface);
|
| 1141 |
+
padding: 4px;
|
| 1142 |
+
}
|
| 1143 |
+
|
| 1144 |
+
.range-control input {
|
| 1145 |
+
width: 100%;
|
| 1146 |
+
accent-color: var(--primary);
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
.mini-transcript {
|
| 1150 |
display: grid;
|
| 1151 |
gap: 10px;
|
|
|
|
| 1183 |
grid-template-columns: minmax(290px, 340px) minmax(0, 1fr);
|
| 1184 |
}
|
| 1185 |
|
| 1186 |
+
.editor-grid {
|
| 1187 |
+
grid-template-columns: 64px minmax(0, 1fr);
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
.inspector-panel {
|
| 1191 |
+
grid-column: 1 / -1;
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
.results-column {
|
| 1195 |
grid-column: 1 / -1;
|
| 1196 |
}
|
|
|
|
| 1212 |
grid-template-columns: 1fr;
|
| 1213 |
}
|
| 1214 |
|
| 1215 |
+
.tool-rail {
|
| 1216 |
+
position: static;
|
| 1217 |
+
display: flex;
|
| 1218 |
+
overflow-x: auto;
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
.tool-rail button {
|
| 1222 |
+
min-width: 72px;
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
.input-panel {
|
| 1226 |
position: static;
|
| 1227 |
max-height: none;
|
|
|
|
| 1245 |
.range-sliders,
|
| 1246 |
.timeline,
|
| 1247 |
.cue-row,
|
| 1248 |
+
.track-row,
|
| 1249 |
.transcript-row {
|
| 1250 |
grid-template-columns: 1fr;
|
| 1251 |
}
|
| 1252 |
|
| 1253 |
+
.color-grid,
|
| 1254 |
+
.preset-row {
|
| 1255 |
+
grid-template-columns: 1fr;
|
| 1256 |
+
}
|
| 1257 |
+
|
| 1258 |
.progress-percent {
|
| 1259 |
font-size: 1.5rem;
|
| 1260 |
}
|