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

+84
View File
@@ -0,0 +1,84 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type SubtitleApplyRequest,
SubtitleApplyResult,
SubtitleParseResponse,
SubtitleTargetsResponse,
SubtitleWritesResponse,
SubtitleWriteView,
} from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
// 补字幕(docs/plan/subtitles.md):目标视频、解析上传、写入、历史与撤销
export const subtitleKeys = {
targets: (entityId: string) => ["subtitles", "targets", entityId] as const,
writes: (entityId: string) => ["subtitles", "writes", entityId] as const,
};
export function useSubtitleTargets(entityId: string, enabled = true) {
return useQuery({
queryKey: subtitleKeys.targets(entityId),
enabled,
queryFn: ({ signal }) => getParsed(SubtitleTargetsResponse, "/subtitles/targets", { entityId }, signal),
});
}
export function useSubtitleWrites(entityId: string, enabled = true) {
return useQuery({
queryKey: subtitleKeys.writes(entityId),
enabled,
queryFn: ({ signal }) => getParsed(SubtitleWritesResponse, "/subtitles/writes", { entityId }, signal),
});
}
/** 读本地文件为 base64(字幕与 zip 都很小,直接放进 JSON) */
async function fileBase64(f: File): Promise<string> {
const buf = new Uint8Array(await f.arrayBuffer());
let s = "";
for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000));
return btoa(s);
}
export function useParseSubtitles() {
return useMutation({
mutationFn: async ({ entityId, files }: { entityId: string; files: File[] }) =>
parseOrThrow(
SubtitleParseResponse,
await api.post<unknown>("/subtitles/parse", {
entityId,
files: await Promise.all(files.map(async (f) => ({ name: f.name, base64: await fileBase64(f) }))),
}),
"/subtitles/parse",
),
});
}
export function useApplySubtitles() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: SubtitleApplyRequest) =>
parseOrThrow(SubtitleApplyResult, await api.post<unknown>("/subtitles/apply", body), "/subtitles/apply"),
onSuccess: (_r, body) => {
qc.invalidateQueries({ queryKey: subtitleKeys.targets(body.entityId) });
qc.invalidateQueries({ queryKey: subtitleKeys.writes(body.entityId) });
},
});
}
export function useUndoSubtitle(entityId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) =>
parseOrThrow(
SubtitleWriteView,
await api.post<unknown>(`/subtitles/writes/${id}/undo`),
"/subtitles/writes/undo",
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: subtitleKeys.targets(entityId) });
qc.invalidateQueries({ queryKey: subtitleKeys.writes(entityId) });
},
});
}