Skip to content

Commit 6e09c9d

Browse files
songoao25EtienneLescot
authored andcommitted
fix: localize native export validation errors
1 parent 6388ae7 commit 6e09c9d

21 files changed

Lines changed: 254 additions & 18 deletions

File tree

‎src/components/ai-edition/ExportDialog.cancel.test.tsx‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock("@/native/sceneDescription", () => ({
1717

1818
import { toast } from "sonner";
1919
import { I18nProvider } from "@/contexts/I18nContext";
20+
import { LOCALE_STORAGE_KEY } from "@/i18n/config";
2021
import { type AxcutDocument, axcutSchemaVersion } from "@/lib/ai-edition/schema";
2122
import { cancelExportNative, exportGifNative, exportMultiNative } from "@/native";
2223
import { NativeBridgeRequestError } from "@/native/client";
@@ -144,7 +145,35 @@ describe("export cancellation", () => {
144145
}),
145146
} as unknown as Window["electronAPI"];
146147
});
147-
afterEach(cleanup);
148+
afterEach(() => {
149+
cleanup();
150+
localStorage.removeItem(LOCALE_STORAGE_KEY);
151+
});
152+
153+
it("localizes native validation failures in both the panel and notification", async () => {
154+
localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN");
155+
const job = pendingMp4Export();
156+
render(
157+
<I18nProvider>
158+
<ExportDialog open onClose={onClose} document={DOC} />
159+
</I18nProvider>,
160+
);
161+
fireEvent.click(screen.getByRole("button", { name: "导出 MP4" }));
162+
await waitFor(() => expect(exportMultiNative).toHaveBeenCalledOnce());
163+
await act(async () =>
164+
job.reject(
165+
new NativeBridgeRequestError({
166+
code: "INTERNAL_ERROR",
167+
retryable: false,
168+
message:
169+
"validating exported video: The exported video has no decodable displayable frames. The destination has not been replaced.",
170+
}),
171+
),
172+
);
173+
const message = "无法验证导出的视频。目标文件未被替换,请重试导出。";
174+
expect(screen.getByText(message)).toBeVisible();
175+
expect(toast.error).toHaveBeenCalledWith("导出失败", { description: message });
176+
});
148177

149178
it("enables Cancel immediately, waits for cleanup and returns to the same options", async () => {
150179
const job = pendingExport();

‎src/components/ai-edition/ExportDialog.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
import { NativeBridgeRequestError } from "@/native/client";
4545
import type { CompositorClipInput } from "@/native/contracts";
4646
import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription";
47+
import { exportErrorMessage } from "./exportError";
4748
import { ModalShell } from "./Modals";
4849
import styles from "./NewEditorShell.module.css";
4950
import { Toggle } from "./RightPanes";
@@ -490,10 +491,11 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
490491
setError(null);
491492
return;
492493
}
493-
setError(err instanceof Error ? err.message : String(err));
494+
const message = exportErrorMessage(err instanceof Error ? err.message : String(err), t);
495+
setError(message);
494496
setPhase("error");
495497
toast.error(t("exportDialog.exportFailed"), {
496-
description: err instanceof Error ? err.message : String(err),
498+
description: message,
497499
});
498500
} finally {
499501
unsubscribeProgress?.();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import { getAvailableLocales, translate } from "@/i18n/loader";
4+
import { exportErrorMessage } from "./exportError";
5+
6+
const cases = [
7+
[
8+
"No video frames were exported. Check that the clips contain readable video before exporting again.",
9+
"noReadableFrames",
10+
],
11+
[
12+
"Cannot export clip 12: the camera video ends before this clip starts. Record the camera again or remove its track before exporting.",
13+
"cameraEnded",
14+
],
15+
[
16+
"Cannot export clip 12: the camera video has no readable frame at the clip's start. Record the camera again or remove its track before exporting.",
17+
"cameraUnreadable",
18+
],
19+
[
20+
"Cannot export clip 12: the screen video has no readable frame at the clip's start.",
21+
"screenUnreadable",
22+
],
23+
["Cannot export clip 12: could not read the screen video.", "screenUnreadable"],
24+
] as const;
25+
26+
describe("native export error presentation", () => {
27+
it.each(cases)("localizes %s in every supported language", (message, key) => {
28+
const native = readFileSync("crates/compositor/src/timeline_walk.rs", "utf8");
29+
// The bridge currently carries anyhow text. Catch native wording drift here.
30+
expect(native).toContain(message.replace("clip 12:", "clip {}:"));
31+
for (const locale of getAvailableLocales()) {
32+
const t = (key: string, vars?: Record<string, string | number>) =>
33+
translate(locale, "editor", key, vars);
34+
const expected = t(`exportDialog.${key}`, { clip: 12 });
35+
expect(exportErrorMessage(message, t)).toBe(expected);
36+
expect(expected).not.toContain("exportDialog.");
37+
expect(expected).not.toContain("{{");
38+
if (key !== "noReadableFrames") expect(expected).toContain("12");
39+
if (locale !== "en")
40+
expect(expected).not.toBe(translate("en", "editor", `exportDialog.${key}`, { clip: 12 }));
41+
}
42+
});
43+
44+
it("translates recognized context chains without exposing decoder internals", () => {
45+
const t = (key: string, vars?: Record<string, string | number>) =>
46+
translate("zh-CN", "editor", key, vars);
47+
for (const [message, key] of cases.slice(2)) {
48+
expect(exportErrorMessage(`${message}: decoder failed: invalid data`, t)).toBe(
49+
t(`exportDialog.${key}`, { clip: 12 }),
50+
);
51+
}
52+
for (const detail of [
53+
"No decoder is available for the exported video.",
54+
"open exported video: invalid data",
55+
"The exported video has no decodable displayable frames. The destination has not been replaced.",
56+
]) {
57+
for (const locale of getAvailableLocales()) {
58+
const scoped = (key: string) => translate(locale, "editor", key);
59+
expect(exportErrorMessage(`validating exported video: ${detail}`, scoped)).toBe(
60+
scoped("exportDialog.validationFailed"),
61+
);
62+
}
63+
}
64+
});
65+
66+
it.each([
67+
"disk full",
68+
"Cannot export clip 12: unknown encoder error",
69+
"cleanup failed: validating exported video: invalid data",
70+
"[pipeline] warning: shortened clip",
71+
"Cannot export clip 0: could not read the screen video.",
72+
])("preserves unrelated errors: %s", (message) => {
73+
expect(exportErrorMessage(message, () => "wrong diagnosis")).toBe(message);
74+
});
75+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
type Translate = (key: string, vars?: Record<string, string | number>) => string;
2+
3+
/** Adapt the native exporter's existing error text at the presentation boundary. */
4+
export function exportErrorMessage(message: string, t: Translate): string {
5+
if (message.startsWith("validating exported video: ")) {
6+
return t("exportDialog.validationFailed");
7+
}
8+
if (
9+
message ===
10+
"No video frames were exported. Check that the clips contain readable video before exporting again."
11+
) {
12+
return t("exportDialog.noReadableFrames");
13+
}
14+
const clip = /^Cannot export clip ([1-9]\d*): (.+)$/s.exec(message);
15+
if (!clip) return message;
16+
const vars = { clip: clip[1] };
17+
const reason = clip[2];
18+
if (
19+
reason ===
20+
"the camera video ends before this clip starts. Record the camera again or remove its track before exporting."
21+
) {
22+
return t("exportDialog.cameraEnded", vars);
23+
}
24+
const cameraStart =
25+
"the camera video has no readable frame at the clip's start. Record the camera again or remove its track before exporting.";
26+
if (reason === cameraStart || reason.startsWith(`${cameraStart}: `)) {
27+
return t("exportDialog.cameraUnreadable", vars);
28+
}
29+
const screenStart = "the screen video has no readable frame at the clip's start.";
30+
const screenRead = "could not read the screen video.";
31+
if (
32+
reason === screenStart ||
33+
reason.startsWith(`${screenStart}: `) ||
34+
reason === screenRead ||
35+
reason.startsWith(`${screenRead}: `)
36+
) {
37+
return t("exportDialog.screenUnreadable", vars);
38+
}
39+
// Unknown failures retain their diagnostic detail instead of becoming a false diagnosis.
40+
return message;
41+
}

‎src/i18n/locales/ar/editor.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@
215215
"failedToWriteFile": "فشلت كتابة الملف",
216216
"qualityUpscaleWarning": "تكبير الدقة",
217217
"exportedVideoOf": "من الفيديو تم تصديره في",
218-
"nothingToExport": "لا يوجد شيء للتصدير — المخطط الزمني فارغ."
218+
"nothingToExport": "لا يوجد شيء للتصدير — المخطط الزمني فارغ.",
219+
"validationFailed": "تعذّر التحقق من الفيديو المُصدَّر. لم يُستبدل الملف الهدف. حاول التصدير مجددًا.",
220+
"noReadableFrames": "لم تُصدَّر أي إطارات فيديو. تأكد من أن المقاطع تحتوي على فيديو قابل للقراءة قبل إعادة التصدير.",
221+
"cameraEnded": "تعذّر تصدير المقطع {{clip}}: ينتهي فيديو الكاميرا قبل بداية هذا المقطع. أعد تسجيل الكاميرا أو أزل مسارها قبل التصدير.",
222+
"cameraUnreadable": "تعذّر تصدير المقطع {{clip}}: لا يحتوي فيديو الكاميرا على إطار قابل للقراءة عند بداية المقطع. أعد تسجيل الكاميرا أو أزل مسارها قبل التصدير.",
223+
"screenUnreadable": "تعذّر تصدير المقطع {{clip}}: لا يمكن قراءة فيديو الشاشة عند بداية المقطع. تحقق من التسجيل أو عدّل المقطع قبل إعادة التصدير."
219224
},
220225
"inspector": {
221226
"resetFocusPoint": "إعادة تعيين نقطة التركيز",

‎src/i18n/locales/cs/editor.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@
215215
"exportFailed": "Export selhal",
216216
"failedToWriteFile": "Nepodařilo se zapsat soubor",
217217
"qualityUpscaleWarning": "Zvýšení rozlišení",
218-
"nothingToExport": "Není co exportovat, časová osa je prázdná."
218+
"nothingToExport": "Není co exportovat, časová osa je prázdná.",
219+
"validationFailed": "Exportované video se nepodařilo ověřit. Cílový soubor nebyl nahrazen. Zkuste export znovu.",
220+
"noReadableFrames": "Nebyly exportovány žádné snímky videa. Před dalším exportem ověřte, že klipy obsahují čitelné video.",
221+
"cameraEnded": "Klip {{clip}} nelze exportovat: video z kamery končí před začátkem klipu. Nahrajte kameru znovu nebo před exportem odstraňte její stopu.",
222+
"cameraUnreadable": "Klip {{clip}} nelze exportovat: video z kamery nemá na začátku klipu čitelný snímek. Nahrajte kameru znovu nebo před exportem odstraňte její stopu.",
223+
"screenUnreadable": "Klip {{clip}} nelze exportovat: video obrazovky nelze na začátku klipu přečíst. Před dalším exportem zkontrolujte nahrávku nebo upravte klip."
219224
},
220225
"inspector": {
221226
"resetFocusPoint": "Obnovit bod zaostření",

‎src/i18n/locales/de/editor.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@
215215
"exportFailed": "Export fehlgeschlagen",
216216
"failedToWriteFile": "Datei konnte nicht geschrieben werden",
217217
"qualityUpscaleWarning": "Hochskalierung",
218-
"nothingToExport": "Nichts zu exportieren — die Timeline ist leer."
218+
"nothingToExport": "Nichts zu exportieren — die Timeline ist leer.",
219+
"validationFailed": "Das exportierte Video konnte nicht überprüft werden. Die Zieldatei wurde nicht ersetzt. Bitte erneut exportieren.",
220+
"noReadableFrames": "Es wurden keine Videobilder exportiert. Prüfe vor dem erneuten Export, ob die Clips lesbare Videos enthalten.",
221+
"cameraEnded": "Clip {{clip}} kann nicht exportiert werden: Das Kameravideo endet vor dem Beginn dieses Clips. Nimm das Kameravideo erneut auf oder entferne seine Spur vor dem Export.",
222+
"cameraUnreadable": "Clip {{clip}} kann nicht exportiert werden: Das Kameravideo enthält am Clipanfang kein lesbares Bild. Nimm das Kameravideo erneut auf oder entferne seine Spur vor dem Export.",
223+
"screenUnreadable": "Clip {{clip}} kann nicht exportiert werden: Das Bildschirmvideo ist am Clipanfang nicht lesbar. Prüfe die Aufnahme oder passe den Clip vor dem erneuten Export an."
219224
},
220225
"inspector": {
221226
"resetFocusPoint": "Fokuspunkt zurücksetzen",

‎src/i18n/locales/en/editor.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@
215215
"exportFailed": "Export failed",
216216
"failedToWriteFile": "Failed to write file",
217217
"qualityUpscaleWarning": "Upscale",
218-
"nothingToExport": "Nothing to export — the timeline is empty."
218+
"nothingToExport": "Nothing to export — the timeline is empty.",
219+
"validationFailed": "The exported video could not be validated. The destination was not replaced. Try exporting again.",
220+
"noReadableFrames": "No video frames were exported. Check that the clips contain readable video before exporting again.",
221+
"cameraEnded": "Cannot export clip {{clip}}: the camera video ends before this clip starts. Record the camera again or remove its track before exporting.",
222+
"cameraUnreadable": "Cannot export clip {{clip}}: the camera video has no readable frame at the clip’s start. Record the camera again or remove its track before exporting.",
223+
"screenUnreadable": "Cannot export clip {{clip}}: the screen video cannot be read at the clip’s start. Check the recording or adjust the clip before exporting again."
219224
},
220225
"inspector": {
221226
"resetFocusPoint": "Reset focus point",

‎src/i18n/locales/es/editor.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@
215215
"failedToWriteFile": "No se pudo escribir el archivo",
216216
"qualityUpscaleWarning": "Upscaling",
217217
"exportedVideoOf": "de vídeo exportado en",
218-
"nothingToExport": "Nada que exportar: la línea de tiempo está vacía."
218+
"nothingToExport": "Nada que exportar: la línea de tiempo está vacía.",
219+
"validationFailed": "No se pudo validar el vídeo exportado. El archivo de destino no se ha reemplazado. Vuelve a exportar.",
220+
"noReadableFrames": "No se ha exportado ningún fotograma. Comprueba que los clips contengan vídeo legible antes de volver a exportar.",
221+
"cameraEnded": "No se puede exportar el clip {{clip}}: el vídeo de la cámara termina antes del inicio del clip. Vuelve a grabar la cámara o elimina su pista antes de exportar.",
222+
"cameraUnreadable": "No se puede exportar el clip {{clip}}: el vídeo de la cámara no tiene ningún fotograma legible al inicio del clip. Vuelve a grabar la cámara o elimina su pista antes de exportar.",
223+
"screenUnreadable": "No se puede exportar el clip {{clip}}: no se puede leer el vídeo de pantalla al inicio del clip. Revisa la grabación o ajusta el clip antes de volver a exportar."
219224
},
220225
"inspector": {
221226
"resetFocusPoint": "Restablecer punto de enfoque",

‎src/i18n/locales/fr/editor.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@
215215
"exportFailed": "Échec de l'export",
216216
"failedToWriteFile": "Échec de l'écriture du fichier",
217217
"qualityUpscaleWarning": "Upscaling",
218-
"nothingToExport": "Rien à exporter — la timeline est vide."
218+
"nothingToExport": "Rien à exporter — la timeline est vide.",
219+
"validationFailed": "La vidéo exportée n’a pas pu être validée. Le fichier de destination n’a pas été remplacé. Réessayez l’exportation.",
220+
"noReadableFrames": "Aucune image vidéo n’a été exportée. Vérifiez que les clips contiennent une vidéo lisible avant de réessayer.",
221+
"cameraEnded": "Impossible d’exporter le clip {{clip}} : la vidéo de la caméra se termine avant le début de ce clip. Réenregistrez la caméra ou retirez sa piste avant d’exporter.",
222+
"cameraUnreadable": "Impossible d’exporter le clip {{clip}} : la vidéo de la caméra ne contient aucune image lisible au début du clip. Réenregistrez la caméra ou retirez sa piste avant d’exporter.",
223+
"screenUnreadable": "Impossible d’exporter le clip {{clip}} : la vidéo de l’écran est illisible au début du clip. Vérifiez l’enregistrement ou ajustez le clip avant de réessayer."
219224
},
220225
"inspector": {
221226
"resetFocusPoint": "Réinitialiser le point de focus",

0 commit comments

Comments
 (0)