implement translation task

This commit is contained in:
vlOd2 2025-12-26 21:27:30 +02:00
parent f72c6214e8
commit a3dd8512bd
5 changed files with 233 additions and 33 deletions

View file

@ -32,6 +32,7 @@ export interface CaptionOptionProps {
countryCode?: string;
children: React.ReactNode;
selected?: boolean;
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
error?: React.ReactNode;
@ -83,7 +84,7 @@ function CaptionOptionRightSide(props: CaptionOptionProps) {
<div className="flex items-center">
{translateBtn(true)}
{props.error ? (
<span className="text-video-context-error">
<span className="flex items-center text-video-context-error">
<Icon className="ml-2" icon={Icons.WARNING} />
</span>
) : (
@ -166,6 +167,7 @@ export function CaptionOption(props: CaptionOptionProps) {
selected={props.selected}
loading={props.loading}
error={props.error}
disabled={props.disabled}
onClick={props.onClick}
onDoubleClick={props.onDoubleClick}
rightSide={<CaptionOptionRightSide {...props} />}

View file

@ -1,9 +1,11 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FlagIcon } from "@/components/FlagIcon";
import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { CaptionListItem } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
import { CaptionOption } from "./CaptionsView";
@ -81,12 +83,59 @@ export function TranslateSubtitleView({
}: LanguageSubtitlesViewProps) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const setCaption = usePlayerStore((s) => s.setCaption);
const translateTask = usePlayerStore((s) => s.caption.translateTask);
const translateCaption = usePlayerStore((s) => s.translateCaption);
const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask);
useEffect(() => {
if (!translateTask) {
return;
}
if (translateTask.done) {
console.log(translateTask.translatedCaption);
// setCaption(translateTask.translatedCaption!);
}
}, [translateTask, setCaption]);
function renderTargetLang(langCode: string) {
const friendlyName = getPrettyLanguageNameFromLocale(langCode);
async function onClick() {
clearTranslateTask();
await translateCaption(caption, langCode);
}
return (
<CaptionOption countryCode={langCode} flag>
<CaptionOption
key={langCode}
countryCode={langCode}
disabled={
!!translateTask && !translateTask.done && !translateTask.error
}
loading={
!!translateTask &&
!translateTask.done &&
!translateTask.error &&
translateTask.targetLanguage === langCode
}
error={
!!translateTask &&
translateTask.error &&
translateTask.targetLanguage === langCode
}
selected={
!!translateTask &&
translateTask.done &&
translateTask.targetLanguage === langCode
}
onClick={() =>
!translateTask || translateTask.done || translateTask.error
? onClick()
: undefined
}
flag
>
{friendlyName}
</CaptionOption>
);

View file

@ -25,13 +25,22 @@ const subtitlesTestSource: SourceSliceSource = {
const subtitlesTestSubs: CaptionListItem[] = [
{
id: "English",
id: "http://localhost:8000/subs/en.srt",
display: "English",
language: "en",
url: "http://localhost:8000/subs/en.srt",
needsProxy: false,
},
{
id: "Romanian",
id: "http://localhost:8000/subs/en-small.srt",
display: "English (small)",
language: "en",
url: "http://localhost:8000/subs/en-small.srt",
needsProxy: false,
},
{
id: "http://localhost:8000/subs/ro.srt",
display: "Romanian",
language: "ro",
url: "http://localhost:8000/subs/ro.srt",
needsProxy: false,

View file

@ -1,6 +1,7 @@
/* eslint-disable no-console */
import { ScrapeMedia } from "@p-stream/providers";
import { downloadCaption } from "@/backend/helpers/subs";
import { MakeSlice } from "@/stores/player/slices/types";
import {
SourceQuality,
@ -10,6 +11,8 @@ import {
import { useQualityStore } from "@/stores/quality";
import { ValuesOf } from "@/utils/typeguard";
import { translateSubtitle } from "../utils/captionstranslation";
export const playerStatus = {
IDLE: "idle",
RESUME: "resume",
@ -73,6 +76,16 @@ export interface AudioTrack {
language: string;
}
export interface TranslateTask {
targetCaption: CaptionListItem;
fetchedTargetCaption?: Caption;
targetLanguage: string;
translatedCaption?: Caption;
done: boolean;
error: boolean;
cancel: () => void;
}
export interface SourceSlice {
status: PlayerStatus;
source: SourceSliceSource | null;
@ -87,6 +100,7 @@ export interface SourceSlice {
caption: {
selected: Caption | null;
asTrack: boolean;
translateTask: TranslateTask | null;
};
meta: PlayerMeta | null;
failedSourcesPerMedia: Record<string, string[]>; // mediaKey -> array of failed sourceIds
@ -106,6 +120,11 @@ export interface SourceSlice {
redisplaySource(startAt: number): void;
setCaptionAsTrack(asTrack: boolean): void;
addExternalSubtitles(): Promise<void>;
translateCaption(
targetCaption: CaptionListItem,
targetLanguage: string,
): Promise<void>;
clearTranslateTask(): void;
addFailedSource(sourceId: string): void;
addFailedEmbed(sourceId: string, embedId: string): void;
clearFailedSources(mediaKey?: string): void;
@ -174,6 +193,7 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
caption: {
selected: null,
asTrack: false,
translateTask: null,
},
setSourceId(id) {
set((s) => {
@ -374,9 +394,11 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.meta = null;
s.failedSourcesPerMedia = {};
s.failedEmbedsPerMedia = {};
this.clearTranslateTask();
s.caption = {
selected: null,
asTrack: false,
translateTask: null,
};
});
},
@ -413,4 +435,102 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
});
}
},
clearTranslateTask() {
set((s) => {
console.log("Clearing translate task");
if (s.caption.translateTask) {
console.log("Cancelling ongoing translate task");
s.caption.translateTask.cancel();
}
s.caption.translateTask = null;
});
},
async translateCaption(
targetCaption: CaptionListItem,
targetLanguage: string,
) {
let store = get();
if (store.caption.translateTask) {
console.warn("A translation task is already in progress");
return;
}
let cancelled = false;
set((s) => {
s.caption.translateTask = {
targetCaption,
targetLanguage,
done: false,
error: false,
cancel() {
console.log("Translation task cancelled by user");
cancelled = true;
},
};
});
function handleError(err: any) {
console.error("Translation task ran into an error", err);
if (cancelled) {
return;
}
set((s) => {
if (!s.caption.translateTask) return;
s.caption.translateTask.error = true;
});
}
try {
const srtData = await downloadCaption(targetCaption);
if (cancelled) {
return;
}
if (!srtData) {
throw new Error("Fetching failed");
}
set((s) => {
if (!s.caption.translateTask) return;
s.caption.translateTask.fetchedTargetCaption = {
id: targetCaption.id,
language: targetCaption.language,
srtData,
};
});
store = get();
} catch (err) {
handleError(err);
return;
}
try {
const result = await translateSubtitle(
targetCaption.id,
store.caption.translateTask!.fetchedTargetCaption!.srtData,
targetLanguage,
);
if (cancelled) {
return;
}
if (!result) {
throw new Error("Translation failed");
}
set((s) => {
if (!s.caption.translateTask) return;
const translatedCaption: Caption = {
id: `${targetCaption.id}-translated-${targetLanguage}`,
language: targetLanguage,
srtData: result,
};
s.caption.translateTask.done = true;
s.caption.translateTask.translatedCaption = translatedCaption;
console.log("Caption translation completed", s.caption.translateTask);
});
} catch (err) {
handleError(err);
}
},
});

View file

@ -10,14 +10,36 @@ const SUBTITLES_CACHE: Map<string, ArrayBuffer> = new Map<
ArrayBuffer
>();
async function translateText(text: string): Promise<string | undefined> {
async function compressStr(string: string): Promise<ArrayBuffer> {
const byteArray = new TextEncoder().encode(string);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(byteArray);
writer.close();
return new Response(cs.readable).arrayBuffer();
}
async function decompressStr(byteArray: ArrayBuffer): Promise<string> {
const cs = new DecompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(byteArray);
writer.close();
return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => {
return new TextDecoder().decode(arrayBuffer);
});
}
async function translateText(
text: string,
targetLang: string,
): Promise<string | undefined> {
if (!text) {
return "";
}
const response = await (
await fetch(
`${API_URL.replace("TARGET_LANG", "ro")}${encodeURIComponent(text)}`,
`${API_URL.replace("{TARGET_LANG}", targetLang)}${encodeURIComponent(text)}`,
{
method: "GET",
headers: {
@ -36,12 +58,18 @@ async function translateText(text: string): Promise<string | undefined> {
.join("");
}
async function translateCaption(caption: ContentCaption): Promise<boolean> {
async function translateCaption(
caption: ContentCaption,
targetLang: string,
): Promise<boolean> {
(caption as any).oldText = caption.text;
let text: string | undefined;
for (let i = 0; i < RETRY_COUNT; i += 1) {
try {
text = await translateText(caption.text.replace("\n", "<br>"));
text = await translateText(
caption.text.replace("\n", "<br>"),
targetLang,
);
if (text) {
text = text.replace("<br>", "\n");
break;
@ -59,11 +87,14 @@ async function translateCaption(caption: ContentCaption): Promise<boolean> {
return true;
}
async function translateCaptions(captions: ContentCaption[]): Promise<boolean> {
async function translateCaptions(
captions: ContentCaption[],
targetLang: string,
): Promise<boolean> {
console.log("[CTR] Translating", captions.length, "captions");
try {
const results: boolean[] = await Promise.all(
captions.map((c) => translateCaption(c)),
captions.map((c) => translateCaption(c, targetLang)),
);
const successCount = results.filter((v) => v).length;
@ -106,7 +137,10 @@ function tryUseCached(
return false;
}
async function translateSRTData(data: string): Promise<string | undefined> {
async function translateSRTData(
data: string,
targetLang: string,
): Promise<string | undefined> {
let captions: Caption[];
try {
captions = subsrt.parse(data);
@ -149,7 +183,7 @@ async function translateSRTData(data: string): Promise<string | undefined> {
}
i += j;
if (!(await translateCaptions(batch))) {
if (!(await translateCaptions(batch, targetLang))) {
translatedCaptions = undefined;
break;
}
@ -162,36 +196,22 @@ async function translateSRTData(data: string): Promise<string | undefined> {
: undefined;
}
async function compressStr(string: string): Promise<ArrayBuffer> {
const byteArray = new TextEncoder().encode(string);
const cs = new CompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(byteArray);
writer.close();
return new Response(cs.readable).arrayBuffer();
}
async function decompressStr(byteArray: ArrayBuffer): Promise<string> {
const cs = new DecompressionStream("deflate");
const writer = cs.writable.getWriter();
writer.write(byteArray);
writer.close();
return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => {
return new TextDecoder().decode(arrayBuffer);
});
}
export async function translateSubtitles(
export async function translateSubtitle(
id: string,
srtData: string,
targetLang: string,
): Promise<string | undefined> {
id = `${id}_${targetLang}`;
const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id);
if (cachedData) {
console.log("[CTR] Using cached translation for", id);
return decompressStr(cachedData);
}
console.log("[CTR] Translating", id);
const translatedData: string | undefined = await translateSRTData(srtData);
const translatedData: string | undefined = await translateSRTData(
srtData,
targetLang,
);
if (!translatedData) {
return undefined;
}