Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/components/ai-edition/ExportDialog.cancel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock("@/native/sceneDescription", () => ({

import { toast } from "sonner";
import { I18nProvider } from "@/contexts/I18nContext";
import { LOCALE_STORAGE_KEY } from "@/i18n/config";
import { type AxcutDocument, axcutSchemaVersion } from "@/lib/ai-edition/schema";
import { cancelExportNative, exportGifNative, exportMultiNative } from "@/native";
import { NativeBridgeRequestError } from "@/native/client";
Expand Down Expand Up @@ -144,7 +145,35 @@ describe("export cancellation", () => {
}),
} as unknown as Window["electronAPI"];
});
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.removeItem(LOCALE_STORAGE_KEY);
});

it("localizes native validation failures in both the panel and notification", async () => {
localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN");
const job = pendingMp4Export();
render(
<I18nProvider>
<ExportDialog open onClose={onClose} document={DOC} />
</I18nProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "导出 MP4" }));
await waitFor(() => expect(exportMultiNative).toHaveBeenCalledOnce());
await act(async () =>
job.reject(
new NativeBridgeRequestError({
code: "INTERNAL_ERROR",
retryable: false,
message:
"validating exported video: The exported video has no decodable displayable frames. The destination has not been replaced.",
}),
),
);
const message = "无法验证导出的视频。目标文件未被替换,请重试导出。";
expect(screen.getByText(message)).toBeVisible();
expect(toast.error).toHaveBeenCalledWith("导出失败", { description: message });
});

it("enables Cancel immediately, waits for cleanup and returns to the same options", async () => {
const job = pendingExport();
Expand Down
6 changes: 4 additions & 2 deletions src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { NativeBridgeRequestError } from "@/native/client";
import type { CompositorClipInput } from "@/native/contracts";
import { buildSceneDescription, resolveVisibleClips } from "@/native/sceneDescription";
import { exportErrorMessage } from "./exportError";
import { ModalShell } from "./Modals";
import styles from "./NewEditorShell.module.css";
import { Toggle } from "./RightPanes";
Expand Down Expand Up @@ -490,10 +491,11 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) {
setError(null);
return;
}
setError(err instanceof Error ? err.message : String(err));
const message = exportErrorMessage(err instanceof Error ? err.message : String(err), t);
setError(message);
setPhase("error");
toast.error(t("exportDialog.exportFailed"), {
description: err instanceof Error ? err.message : String(err),
description: message,
});
} finally {
unsubscribeProgress?.();
Expand Down
75 changes: 75 additions & 0 deletions src/components/ai-edition/exportError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getAvailableLocales, translate } from "@/i18n/loader";
import { exportErrorMessage } from "./exportError";

const cases = [
[
"No video frames were exported. Check that the clips contain readable video before exporting again.",
"noReadableFrames",
],
[
"Cannot export clip 12: the camera video ends before this clip starts. Record the camera again or remove its track before exporting.",
"cameraEnded",
],
[
"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.",
"cameraUnreadable",
],
[
"Cannot export clip 12: the screen video has no readable frame at the clip's start.",
"screenUnreadable",
],
["Cannot export clip 12: could not read the screen video.", "screenUnreadable"],
] as const;

describe("native export error presentation", () => {
it.each(cases)("localizes %s in every supported language", (message, key) => {
const native = readFileSync("crates/compositor/src/timeline_walk.rs", "utf8");
// The bridge currently carries anyhow text. Catch native wording drift here.
expect(native).toContain(message.replace("clip 12:", "clip {}:"));
for (const locale of getAvailableLocales()) {
const t = (key: string, vars?: Record<string, string | number>) =>
translate(locale, "editor", key, vars);
const expected = t(`exportDialog.${key}`, { clip: 12 });
expect(exportErrorMessage(message, t)).toBe(expected);
expect(expected).not.toContain("exportDialog.");
expect(expected).not.toContain("{{");
if (key !== "noReadableFrames") expect(expected).toContain("12");
if (locale !== "en")
expect(expected).not.toBe(translate("en", "editor", `exportDialog.${key}`, { clip: 12 }));
}
});

it("translates recognized context chains without exposing decoder internals", () => {
const t = (key: string, vars?: Record<string, string | number>) =>
translate("zh-CN", "editor", key, vars);
for (const [message, key] of cases.slice(2)) {
expect(exportErrorMessage(`${message}: decoder failed: invalid data`, t)).toBe(
t(`exportDialog.${key}`, { clip: 12 }),
);
}
for (const detail of [
"No decoder is available for the exported video.",
"open exported video: invalid data",
"The exported video has no decodable displayable frames. The destination has not been replaced.",
]) {
for (const locale of getAvailableLocales()) {
const scoped = (key: string) => translate(locale, "editor", key);
expect(exportErrorMessage(`validating exported video: ${detail}`, scoped)).toBe(
scoped("exportDialog.validationFailed"),
);
}
}
});

it.each([
"disk full",
"Cannot export clip 12: unknown encoder error",
"cleanup failed: validating exported video: invalid data",
"[pipeline] warning: shortened clip",
"Cannot export clip 0: could not read the screen video.",
])("preserves unrelated errors: %s", (message) => {
expect(exportErrorMessage(message, () => "wrong diagnosis")).toBe(message);
});
});
41 changes: 41 additions & 0 deletions src/components/ai-edition/exportError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
type Translate = (key: string, vars?: Record<string, string | number>) => string;

/** Adapt the native exporter's existing error text at the presentation boundary. */
export function exportErrorMessage(message: string, t: Translate): string {
if (message.startsWith("validating exported video: ")) {
return t("exportDialog.validationFailed");
}
if (
message ===
"No video frames were exported. Check that the clips contain readable video before exporting again."
) {
return t("exportDialog.noReadableFrames");
}
const clip = /^Cannot export clip ([1-9]\d*): (.+)$/s.exec(message);
if (!clip) return message;
const vars = { clip: clip[1] };
const reason = clip[2];
if (
reason ===
"the camera video ends before this clip starts. Record the camera again or remove its track before exporting."
) {
return t("exportDialog.cameraEnded", vars);
}
const cameraStart =
"the camera video has no readable frame at the clip's start. Record the camera again or remove its track before exporting.";
if (reason === cameraStart || reason.startsWith(`${cameraStart}: `)) {
return t("exportDialog.cameraUnreadable", vars);
}
const screenStart = "the screen video has no readable frame at the clip's start.";
const screenRead = "could not read the screen video.";
if (
reason === screenStart ||
reason.startsWith(`${screenStart}: `) ||
reason === screenRead ||
reason.startsWith(`${screenRead}: `)
) {
return t("exportDialog.screenUnreadable", vars);
}
// Unknown failures retain their diagnostic detail instead of becoming a false diagnosis.
return message;
}
7 changes: 6 additions & 1 deletion src/i18n/locales/ar/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"failedToWriteFile": "فشلت كتابة الملف",
"qualityUpscaleWarning": "تكبير الدقة",
"exportedVideoOf": "من الفيديو تم تصديره في",
"nothingToExport": "لا يوجد شيء للتصدير — المخطط الزمني فارغ."
"nothingToExport": "لا يوجد شيء للتصدير — المخطط الزمني فارغ.",
"validationFailed": "تعذّر التحقق من الفيديو المُصدَّر. لم يُستبدل الملف الهدف. حاول التصدير مجددًا.",
"noReadableFrames": "لم تُصدَّر أي إطارات فيديو. تأكد من أن المقاطع تحتوي على فيديو قابل للقراءة قبل إعادة التصدير.",
"cameraEnded": "تعذّر تصدير المقطع {{clip}}: ينتهي فيديو الكاميرا قبل بداية هذا المقطع. أعد تسجيل الكاميرا أو أزل مسارها قبل التصدير.",
"cameraUnreadable": "تعذّر تصدير المقطع {{clip}}: لا يحتوي فيديو الكاميرا على إطار قابل للقراءة عند بداية المقطع. أعد تسجيل الكاميرا أو أزل مسارها قبل التصدير.",
"screenUnreadable": "تعذّر تصدير المقطع {{clip}}: لا يمكن قراءة فيديو الشاشة عند بداية المقطع. تحقق من التسجيل أو عدّل المقطع قبل إعادة التصدير."
},
"inspector": {
"resetFocusPoint": "إعادة تعيين نقطة التركيز",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/cs/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"exportFailed": "Export selhal",
"failedToWriteFile": "Nepodařilo se zapsat soubor",
"qualityUpscaleWarning": "Zvýšení rozlišení",
"nothingToExport": "Není co exportovat, časová osa je prázdná."
"nothingToExport": "Není co exportovat, časová osa je prázdná.",
"validationFailed": "Exportované video se nepodařilo ověřit. Cílový soubor nebyl nahrazen. Zkuste export znovu.",
"noReadableFrames": "Nebyly exportovány žádné snímky videa. Před dalším exportem ověřte, že klipy obsahují čitelné video.",
"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.",
"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.",
"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."
},
"inspector": {
"resetFocusPoint": "Obnovit bod zaostření",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/de/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"exportFailed": "Export fehlgeschlagen",
"failedToWriteFile": "Datei konnte nicht geschrieben werden",
"qualityUpscaleWarning": "Hochskalierung",
"nothingToExport": "Nichts zu exportieren — die Timeline ist leer."
"nothingToExport": "Nichts zu exportieren — die Timeline ist leer.",
"validationFailed": "Das exportierte Video konnte nicht überprüft werden. Die Zieldatei wurde nicht ersetzt. Bitte erneut exportieren.",
"noReadableFrames": "Es wurden keine Videobilder exportiert. Prüfe vor dem erneuten Export, ob die Clips lesbare Videos enthalten.",
"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.",
"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.",
"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."
},
"inspector": {
"resetFocusPoint": "Fokuspunkt zurücksetzen",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"exportFailed": "Export failed",
"failedToWriteFile": "Failed to write file",
"qualityUpscaleWarning": "Upscale",
"nothingToExport": "Nothing to export — the timeline is empty."
"nothingToExport": "Nothing to export — the timeline is empty.",
"validationFailed": "The exported video could not be validated. The destination was not replaced. Try exporting again.",
"noReadableFrames": "No video frames were exported. Check that the clips contain readable video before exporting again.",
"cameraEnded": "Cannot export clip {{clip}}: the camera video ends before this clip starts. Record the camera again or remove its track before exporting.",
"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.",
"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."
},
"inspector": {
"resetFocusPoint": "Reset focus point",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"failedToWriteFile": "No se pudo escribir el archivo",
"qualityUpscaleWarning": "Upscaling",
"exportedVideoOf": "de vídeo exportado en",
"nothingToExport": "Nada que exportar: la línea de tiempo está vacía."
"nothingToExport": "Nada que exportar: la línea de tiempo está vacía.",
"validationFailed": "No se pudo validar el vídeo exportado. El archivo de destino no se ha reemplazado. Vuelve a exportar.",
"noReadableFrames": "No se ha exportado ningún fotograma. Comprueba que los clips contengan vídeo legible antes de volver a exportar.",
"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.",
"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.",
"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."
},
"inspector": {
"resetFocusPoint": "Restablecer punto de enfoque",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/fr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"exportFailed": "Échec de l'export",
"failedToWriteFile": "Échec de l'écriture du fichier",
"qualityUpscaleWarning": "Upscaling",
"nothingToExport": "Rien à exporter — la timeline est vide."
"nothingToExport": "Rien à exporter — la timeline est vide.",
"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.",
"noReadableFrames": "Aucune image vidéo n’a été exportée. Vérifiez que les clips contiennent une vidéo lisible avant de réessayer.",
"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.",
"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.",
"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."
},
"inspector": {
"resetFocusPoint": "Réinitialiser le point de focus",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/it/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"failedToWriteFile": "Impossibile scrivere il file",
"qualityUpscaleWarning": "Upscaling",
"exportedVideoOf": "di video esportato in",
"nothingToExport": "Niente da esportare: la timeline è vuota."
"nothingToExport": "Niente da esportare: la timeline è vuota.",
"validationFailed": "Impossibile convalidare il video esportato. Il file di destinazione non è stato sostituito. Prova a esportare di nuovo.",
"noReadableFrames": "Non è stato esportato alcun fotogramma. Verifica che le clip contengano video leggibili prima di esportare di nuovo.",
"cameraEnded": "Impossibile esportare la clip {{clip}}: il video della videocamera termina prima dell’inizio della clip. Registra di nuovo la videocamera o rimuovi la sua traccia prima di esportare.",
"cameraUnreadable": "Impossibile esportare la clip {{clip}}: il video della videocamera non contiene fotogrammi leggibili all’inizio della clip. Registra di nuovo la videocamera o rimuovi la sua traccia prima di esportare.",
"screenUnreadable": "Impossibile esportare la clip {{clip}}: il video dello schermo non è leggibile all’inizio della clip. Controlla la registrazione o modifica la clip prima di esportare di nuovo."
},
"inspector": {
"resetFocusPoint": "Reimposta punto di messa a fuoco",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/ja-JP/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"failedToWriteFile": "ファイルの書き込みに失敗しました",
"qualityUpscaleWarning": "アップスケール",
"exportedVideoOf": "の動画を書き出しました。所要時間:",
"nothingToExport": "書き出す内容がありません — タイムラインが空です。"
"nothingToExport": "書き出す内容がありません — タイムラインが空です。",
"validationFailed": "書き出した動画を検証できませんでした。保存先のファイルは置き換えられていません。もう一度書き出してください。",
"noReadableFrames": "動画フレームが書き出されませんでした。クリップに読み取り可能な動画が含まれているか確認してから、再度書き出してください。",
"cameraEnded": "クリップ {{clip}} を書き出せません。カメラ動画がクリップの開始前に終了しています。カメラ動画を録画し直すか、カメラトラックを削除してから書き出してください。",
"cameraUnreadable": "クリップ {{clip}} を書き出せません。クリップの開始位置に読み取り可能なカメラ映像がありません。カメラ動画を録画し直すか、カメラトラックを削除してから書き出してください。",
"screenUnreadable": "クリップ {{clip}} を書き出せません。クリップの開始位置の画面動画を読み取れません。録画を確認するか、クリップを調整してから再度書き出してください。"
},
"inspector": {
"resetFocusPoint": "フォーカスポイントをリセット",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/ko-KR/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@
"failedToWriteFile": "파일 쓰기에 실패했습니다",
"qualityUpscaleWarning": "업스케일",
"exportedVideoOf": "분량의 영상을 내보냈습니다. 소요 시간:",
"nothingToExport": "내보낼 항목이 없습니다 — 타임라인이 비어 있습니다."
"nothingToExport": "내보낼 항목이 없습니다 — 타임라인이 비어 있습니다.",
"validationFailed": "내보낸 동영상을 검증할 수 없습니다. 대상 파일은 교체되지 않았습니다. 다시 내보내세요.",
"noReadableFrames": "내보낸 동영상 프레임이 없습니다. 클립에 읽을 수 있는 동영상이 있는지 확인한 후 다시 내보내세요.",
"cameraEnded": "클립 {{clip}}을(를) 내보낼 수 없습니다. 클립 시작 전에 카메라 동영상이 끝납니다. 카메라를 다시 녹화하거나 카메라 트랙을 제거한 후 내보내세요.",
"cameraUnreadable": "클립 {{clip}}을(를) 내보낼 수 없습니다. 클립 시작 지점에 읽을 수 있는 카메라 프레임이 없습니다. 카메라를 다시 녹화하거나 카메라 트랙을 제거한 후 내보내세요.",
"screenUnreadable": "클립 {{clip}}을(를) 내보낼 수 없습니다. 클립 시작 지점의 화면 동영상을 읽을 수 없습니다. 녹화 파일을 확인하거나 클립을 조정한 후 다시 내보내세요."
},
"inspector": {
"resetFocusPoint": "포커스 지점 재설정",
Expand Down
Loading
Loading