feat(subtitles): 补字幕第 1 步——上传字幕 / zip,识别语言与季集,配对写入视频同目录,历史与撤销

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 01:08:05 +08:00
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>
);
}