feat(subtitles): 补字幕第 1 步——上传字幕 / zip,识别语言与季集,配对写入视频同目录,历史与撤销
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
feee481915
commit
ef29bd62ce
22 files changed
+1554
-2
No files matched your search
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
cursorPage,
|
||||
FeedSource,
|
||||
SITE_KINDS,
|
||||
type SiteDefinitionView,
|
||||
type SiteHealthResult,
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { SubtitleLanguage, SubtitleParsedItem, SubtitleTarget, SubtitleWriteView } from "@umt/contracts";
|
||||
import { FileUp, LoaderCircle, Undo2 } from "lucide-react";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useApplySubtitles,
|
||||
useParseSubtitles,
|
||||
useSubtitleTargets,
|
||||
useSubtitleWrites,
|
||||
useUndoSubtitle,
|
||||
} from "~/api/subtitles.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 补字幕(docs/plan/subtitles.md):上传字幕 / zip → 预览(语言、写到哪一集,可改)→ 写到视频同目录;下面是历史,可撤销。
|
||||
|
||||
export const SUBTITLE_LANGUAGE_LABEL: Record<SubtitleLanguage, string> = {
|
||||
"zh-Hans": "简中",
|
||||
"zh-Hant": "繁中",
|
||||
"zh-Hans&en": "简英双语",
|
||||
"zh-Hant&en": "繁英双语",
|
||||
"zh&ja": "中日双语",
|
||||
"zh&ko": "中韩双语",
|
||||
en: "英文",
|
||||
ja: "日文",
|
||||
ko: "韩文",
|
||||
unknown: "未知",
|
||||
};
|
||||
const SOURCE_LABEL = { upload: "上传", subhd: "SubHD", zimuku: "字幕库" } as const;
|
||||
const SKIP = "__skip__";
|
||||
const ACCEPT = ".srt,.ass,.ssa,.vtt,.sub,.sbv,.zip";
|
||||
|
||||
const targetLabel = (t: SubtitleTarget) =>
|
||||
t.episode !== null
|
||||
? `S${String(t.season ?? 1).padStart(2, "0")}E${String(t.episode).padStart(2, "0")} · ${t.fileName}`
|
||||
: t.fileName;
|
||||
|
||||
type Row = SubtitleParsedItem & { target: string };
|
||||
|
||||
/** 同一后缀的语言(写出来是同一个文件名):双语按中文,与整理规则一致 */
|
||||
const suffixGroup = (l: SubtitleLanguage) => (l.startsWith("zh-Hant") ? "zh-tw" : l.startsWith("zh") ? "zh-cn" : l);
|
||||
|
||||
export function SubtitleDialog({
|
||||
entityId,
|
||||
title,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
entityId: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const targets = useSubtitleTargets(entityId, open);
|
||||
const writes = useSubtitleWrites(entityId, open);
|
||||
const parse = useParseSubtitles();
|
||||
const apply = useApplySubtitles();
|
||||
const undo = useUndoSubtitle(entityId);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const list = targets.data?.targets ?? [];
|
||||
const busy = parse.isPending || apply.isPending;
|
||||
|
||||
const load = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
try {
|
||||
const r = await parse.mutateAsync({ entityId, files });
|
||||
setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
|
||||
setSkipped((prev) => [...prev, ...r.skipped]);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "解析字幕失败"));
|
||||
}
|
||||
};
|
||||
const patch = (id: string, p: Partial<Row>) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...p } : r)));
|
||||
const chosen = rows.filter((r) => r.target !== SKIP);
|
||||
// 同一个视频选了两份同语言字幕:写入时后一份会被拒绝,先提示
|
||||
const dup = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out = new Set<string>();
|
||||
for (const r of chosen) {
|
||||
const k = `${r.target}|${r.language}|${r.ext}`;
|
||||
if (seen.has(k)) out.add(r.id);
|
||||
seen.add(k);
|
||||
}
|
||||
return out;
|
||||
}, [chosen]);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const r = await apply.mutateAsync({
|
||||
entityId,
|
||||
source: "upload",
|
||||
items: chosen.map((x) => ({
|
||||
name: x.name,
|
||||
ext: x.ext,
|
||||
language: x.language,
|
||||
content: x.content,
|
||||
targetLocationId: x.target,
|
||||
})),
|
||||
});
|
||||
if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`);
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => `${f.name}:${f.reason}`).join("\n"));
|
||||
setRows((rs) => rs.filter((x) => r.failed.some((f) => f.name === x.name)));
|
||||
setSkipped([]);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "写入失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>补字幕</DialogTitle>
|
||||
<DialogDescription>
|
||||
{title}:字幕写到视频同目录,文件名是视频名加语言后缀;同名字幕会先备份,可以撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{targets.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : targets.error ? (
|
||||
<ErrorState error={targets.error} onRetry={() => void targets.refetch()} />
|
||||
) : !list.length ? (
|
||||
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
|
||||
) : (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void load([...e.dataTransfer.files]);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
|
||||
dragging && "border-foreground/60 bg-accent",
|
||||
)}
|
||||
>
|
||||
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
|
||||
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
|
||||
</span>
|
||||
<input
|
||||
ref={input}
|
||||
id={inputId}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void load([...(e.target.files ?? [])]);
|
||||
if (input.current) input.current.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{rows.length ? (
|
||||
<ul className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="grid gap-2 rounded-lg border border-border/70 px-3 py-2 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1.2fr)]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-caption text-foreground" title={r.name}>
|
||||
{r.name.split("/").pop()}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{[
|
||||
r.archive ? `来自 ${r.archive}` : null,
|
||||
r.episode !== null ? `识别为第 ${r.episode} 集` : null,
|
||||
r.encoding !== "utf-8" ? `${r.encoding} 已转 UTF-8` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
{dup.has(r.id) ? <p className="text-meta text-warning">同一集已选了同语言的字幕</p> : null}
|
||||
{(() => {
|
||||
const old = list
|
||||
.find((t) => t.locationId === r.target)
|
||||
?.existing.find(
|
||||
(x) =>
|
||||
suffixGroup(x.language) === suffixGroup(r.language) && x.fileName.endsWith(`.${r.ext}`),
|
||||
);
|
||||
return old ? (
|
||||
<p className="truncate text-meta text-warning">会覆盖已有的 {old.fileName}(先备份)</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<Select value={r.language} onValueChange={(v) => patch(r.id, { language: v as SubtitleLanguage })}>
|
||||
<SelectTrigger aria-label="语言" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(Object.keys(SUBTITLE_LANGUAGE_LABEL) as SubtitleLanguage[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{SUBTITLE_LANGUAGE_LABEL[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={r.target} onValueChange={(v) => patch(r.id, { target: v })}>
|
||||
<SelectTrigger aria-label="写到" className="w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={SKIP}>不写</SelectItem>
|
||||
{list.map((t) => (
|
||||
<SelectItem key={t.locationId} value={t.locationId}>
|
||||
{targetLabel(t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{skipped.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
跳过:{skipped.map((s) => `${s.name}(${s.reason})`).join("、")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<History items={writes.data?.items ?? []} onUndo={(w) => undo.mutate(w.id)} undoing={undo.isPending} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{rows.length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRows([]);
|
||||
setSkipped([]);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => void submit()} disabled={busy || !chosen.length}>
|
||||
{apply.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{chosen.length ? `写入 ${chosen.length} 个字幕` : "写入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function History({
|
||||
items,
|
||||
onUndo,
|
||||
undoing,
|
||||
}: {
|
||||
items: SubtitleWriteView[];
|
||||
onUndo: (w: SubtitleWriteView) => void;
|
||||
undoing: boolean;
|
||||
}) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section aria-label="补字幕记录" className="border-t border-border/60 pt-3">
|
||||
<h3 className="mb-2 text-meta font-semibold text-muted-foreground">补字幕记录</h3>
|
||||
<ul className="space-y-1">
|
||||
{items.slice(0, 30).map((w) => (
|
||||
<li key={w.id} className="flex min-w-0 items-center gap-2 text-caption">
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 truncate", w.undoneAt && "text-muted-foreground line-through")}
|
||||
title={w.relativePath}
|
||||
>
|
||||
{w.fileName}
|
||||
</span>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">
|
||||
{SOURCE_LABEL[w.source]} · {relTime(w.createdAt)}
|
||||
</span>
|
||||
{w.replaced && !w.undoneAt ? <StatusBadge className="shrink-0">覆盖了旧字幕</StatusBadge> : null}
|
||||
{w.undoneAt ? (
|
||||
<StatusBadge className="shrink-0">已撤销</StatusBadge>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={undoing}
|
||||
onClick={() => onUndo(w)}
|
||||
title={w.replaced ? "删掉这次写入,还原旧字幕" : "删掉这次写入的字幕"}
|
||||
>
|
||||
<Undo2 />
|
||||
撤销
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user