From 461c9d9b502114275a236fb719a3ac15497492d7 Mon Sep 17 00:00:00 2001 From: albertlast Date: Sat, 25 Jul 2026 19:42:35 +0200 Subject: [PATCH] perf(export): stream multi-page image exports to a folder on desktop Exporting rendered/inpainted pages built every PNG server-side, zipped that into a second in-memory copy, shipped the whole thing as one blob and only then opened the folder dialog. On large projects that OOM'd before the user was ever asked where to put the files. On desktop the UI now asks for the destination folder first, then exports one page per request and writes it straight to disk, so peak memory is a single page regardless of project size. A progress card in the activity bubble reports file N/M and can cancel mid-run, aborting the in-flight request rather than waiting out the current page. Filenames keep the server's `page-NNN-.png` numbering, gaps included, so a folder export matches what the zip contained. Two supporting server-side fixes, both needed because a folder export now issues one request per page instead of one per export: - `export_current_project` only compacts for the `.khr` archive, which is the sole format that reads the project directory. Image and PSD exports read the in-memory scene plus blobs the blob store already wrote eagerly, so compacting there meant a full-scene encode plus atomic write on every request. Nothing durable is lost: autosave already compacts 500ms after the last edit. - `png_bytes_for_page` reads under the scene lock instead of cloning the entire scene, which made an export O(pages^2). The guard is dropped before the decode. Browser behaviour is unchanged: the web build still takes the existing blob path, as do khr, psd and single-page exports on every platform. Co-Authored-By: Claude Opus 5 --- crates/koharu-rpc/src/psd_export.rs | 24 +-- crates/koharu-rpc/src/routes/projects.rs | 18 ++- ui/components/ActivityBubble.tsx | 67 +++++++- ui/lib/io/pagesIo.ts | 108 ++++++++++++- ui/lib/io/saveBlob.ts | 49 ++++-- ui/lib/io/scene.ts | 2 + ui/lib/stores/exportStore.ts | 57 +++++++ ui/public/locales/en-US/translation.json | 5 + ui/public/locales/es-ES/translation.json | 5 + ui/public/locales/ja-JP/translation.json | 5 + ui/public/locales/ko-KR/translation.json | 5 + ui/public/locales/pt-BR/translation.json | 5 + ui/public/locales/ru-RU/translation.json | 5 + ui/public/locales/tr-TR/translation.json | 5 + ui/public/locales/zh-CN/translation.json | 5 + ui/public/locales/zh-TW/translation.json | 5 + ui/tests/lib/io/pagesIo.test.ts | 195 ++++++++++++++++++++++- 17 files changed, 528 insertions(+), 37 deletions(-) create mode 100644 ui/lib/stores/exportStore.ts diff --git a/crates/koharu-rpc/src/psd_export.rs b/crates/koharu-rpc/src/psd_export.rs index 64044b39b..7a5af4f9d 100644 --- a/crates/koharu-rpc/src/psd_export.rs +++ b/crates/koharu-rpc/src/psd_export.rs @@ -182,16 +182,20 @@ pub fn png_bytes_for_page( page_id: PageId, role: ImageRole, ) -> Result>> { - let scene: Scene = session.scene_snapshot(); - let page = scene - .pages - .get(&page_id) - .ok_or_else(|| anyhow::anyhow!("page {page_id} not found"))?; - - let blob = page.nodes.values().find_map(|n| match &n.kind { - NodeKind::Image(img) if img.role == role => Some(img.blob.clone()), - _ => None, - }); + // Read under the lock instead of cloning the whole scene: this is called + // once per page, and a snapshot clone would make an export O(pages²). + let blob = { + let scene = session.scene.read(); + let page = scene + .pages + .get(&page_id) + .ok_or_else(|| anyhow::anyhow!("page {page_id} not found"))?; + page.nodes.values().find_map(|n| match &n.kind { + NodeKind::Image(img) if img.role == role => Some(img.blob.clone()), + _ => None, + }) + }; + // Guard dropped — the decode below must not hold the scene lock. let Some(blob_ref) = blob else { return Ok(None); }; diff --git a/crates/koharu-rpc/src/routes/projects.rs b/crates/koharu-rpc/src/routes/projects.rs index d3d918e13..95278a364 100644 --- a/crates/koharu-rpc/src/routes/projects.rs +++ b/crates/koharu-rpc/src/routes/projects.rs @@ -269,16 +269,22 @@ async fn export_current_project( .current_session() .ok_or_else(|| ApiError::bad_request("no project open"))?; - let s_for_compact = session.clone(); - tokio::task::spawn_blocking(move || s_for_compact.compact()) - .await - .map_err(|e| ApiError::internal(anyhow::Error::new(e)))? - .map_err(ApiError::internal)?; - let project_name = session.scene.read().project.name.clone(); match req.format { ExportFormat::Khr => { + // Only the `.khr` archive reads the project *directory*, so it is + // the only format that needs the scene flushed to disk first. + // Image/PSD exports read the in-memory scene plus blobs that the + // blob store already wrote eagerly — compacting there would mean a + // full-scene encode + atomic write on every request, which is + // ruinous now that folder exports issue one request per page. + let s_for_compact = session.clone(); + tokio::task::spawn_blocking(move || s_for_compact.compact()) + .await + .map_err(|e| ApiError::internal(anyhow::Error::new(e)))? + .map_err(ApiError::internal)?; + let src = session.dir.clone(); let bytes = tokio::task::spawn_blocking(move || koharu_app::archive::export_khr_bytes(&src)) diff --git a/ui/components/ActivityBubble.tsx b/ui/components/ActivityBubble.tsx index 454307f61..641205e19 100644 --- a/ui/components/ActivityBubble.tsx +++ b/ui/components/ActivityBubble.tsx @@ -14,6 +14,7 @@ import type { } from '@/lib/api/schemas' import { useDownloadsStore } from '@/lib/stores/downloadsStore' import { useEditorUiStore } from '@/lib/stores/editorUiStore' +import { type ExportActivity, useExportStore } from '@/lib/stores/exportStore' import { type JobEntry, useJobsStore } from '@/lib/stores/jobsStore' type TranslateFunc = ReturnType['t'] @@ -207,12 +208,62 @@ function JobCard({ job, onCancel, t }: { job: JobEntry; onCancel: () => void; t: ) } +function ExportCard({ + activity, + onCancel, + t, +}: { + activity: ExportActivity + onCancel: () => void + t: TranslateFunc +}) { + const { role, total, done, currentName } = activity + const percent = total > 0 ? clampProgress((done / total) * 100) : undefined + const title = + role === 'rendered' ? t('operations.exportingRendered') : t('operations.exportingInpainted') + const subtitle = [t('operations.fileProgress', { current: done, total }), currentName] + .filter(Boolean) + .join(' · ') + + return ( + +
+
+
+
+
{title}
+
+ {subtitle} +
+
+ +
+ +
+
+
+ + ) +} + export function ActivityBubble() { const { t } = useTranslation() const jobs = useJobsStore((s) => s.jobs) const downloads = useDownloadsStore((s) => s.downloads) const uiError = useEditorUiStore((s) => s.error) const clearUiError = useEditorUiStore((s) => s.clearError) + const exportActivity = useExportStore((s) => s.active) const runningJobs = Object.values(jobs).filter( (j: JobSummary) => j.status === 'running', @@ -223,11 +274,25 @@ export function ActivityBubble() { }) const errorMessage = uiError?.message - if (!errorMessage && runningJobs.length === 0 && activeDownloads.length === 0) return null + if ( + !errorMessage && + !exportActivity && + runningJobs.length === 0 && + activeDownloads.length === 0 + ) { + return null + } return (
{errorMessage && } + {exportActivity && ( + useExportStore.getState().requestCancel()} + t={t} + /> + )} {runningJobs.map((job) => ( void cancelOperation(job.id)} t={t} /> ))} diff --git a/ui/lib/io/pagesIo.ts b/ui/lib/io/pagesIo.ts index b2a1d23cc..ca64dae6d 100644 --- a/ui/lib/io/pagesIo.ts +++ b/ui/lib/io/pagesIo.ts @@ -1,11 +1,15 @@ 'use client' -import { getGetSceneJsonQueryKey } from '@/lib/api/default/default' -import type { SceneSnapshot } from '@/lib/api/schemas' +import { findImageBlob } from '@/hooks/useCurrentPage' +import { getGetSceneJsonQueryKey, getSceneJson } from '@/lib/api/default/default' +import type { Scene, SceneSnapshot } from '@/lib/api/schemas' +import { isTauri } from '@/lib/backend' import { openImageFiles, openImageFolder, openKhrFile } from '@/lib/io/openFiles' -import { saveBlob } from '@/lib/io/saveBlob' +import { pickSaveDirectory, saveBlob, writeFileInDir } from '@/lib/io/saveBlob' import { exportProject, uploadKhrArchive, uploadPages, uploadPagesByPaths } from '@/lib/io/scene' import { queryClient } from '@/lib/queryClient' +import { useEditorUiStore } from '@/lib/stores/editorUiStore' +import { type ExportRole, useExportStore } from '@/lib/stores/exportStore' import { usePreferencesStore } from '@/lib/stores/preferencesStore' /** @@ -65,11 +69,109 @@ function currentProjectName(): string | undefined { return snap?.scene.project?.name ?? undefined } +/** + * Surface a translated message in the activity bubble. `lib/i18n` is imported + * lazily so the io layer doesn't pull every locale bundle into its graph for + * the (common) case where nothing goes wrong. + */ +async function showExportError(key: string, opts?: Record): Promise { + const { default: i18n } = await import('@/lib/i18n') + useEditorUiStore.getState().showError(i18n.t(key, opts ?? {})) +} + +/** Read the scene from React Query's cache, fetching it only if absent. */ +async function currentScene(): Promise { + const cached = queryClient.getQueryData(getGetSceneJsonQueryKey()) + if (cached) return cached.scene + return (await getSceneJson()).scene +} + +/** + * Streaming multi-page image export (desktop only). + * + * Asks for the destination folder **first**, then exports one page per + * request and writes it straight to disk. Peak memory is a single page, no + * matter how many pages the project has — the old path buffered every PNG + * server-side, zipped that into a second copy, shipped it as one blob and + * only then opened the folder dialog, which OOM'd on large projects. + */ +export async function exportImagesToFolder(role: ExportRole, pages?: string[]): Promise { + const scene = await currentScene() + + // `pages` order (or scene insertion order) is the export order, and the + // `page-NNN-` prefix counts positions in *that* list — including pages that + // get skipped below for lacking the layer. This mirrors the server's naming + // so a folder export is byte-for-byte what the zip export contained. + const resolved: (readonly [string, Scene['pages'][string]])[] = pages + ? pages.map((id) => [id, scene.pages[id]] as const) + : Object.entries(scene.pages) + for (const [id, page] of resolved) { + if (!page) throw new Error(`page ${id} not found`) + } + const work = resolved + .map(([id, page], index) => ({ id, page, index })) + .filter(({ page }) => findImageBlob(page, role) !== null) + + // Same condition the server rejects with, caught client-side so the user + // gets told instead of being handed a folder dialog for an empty export. + if (work.length === 0) { + await showExportError('operations.exportNoPages') + return + } + + const dir = await pickSaveDirectory() + if (!dir) return + + const store = useExportStore + store.getState().start(role, work.length) + // Abort the in-flight request too, so Cancel doesn't wait out a page. + const controller = new AbortController() + const unsubscribe = store.subscribe((s) => { + if (s.cancelRequested) controller.abort() + }) + const failures: string[] = [] + + try { + for (const { id, index } of work) { + if (store.getState().cancelRequested) break + const name = `page-${String(index + 1).padStart(3, '0')}-${id}.png` + try { + const { blob } = await exportProject({ format: role, pages: [id] }, controller.signal) + await writeFileInDir(dir, name, new Uint8Array(await blob.arrayBuffer())) + } catch (err) { + if (controller.signal.aborted) break + failures.push(`${name}: ${String(err)}`) + } + // Nothing is retained between iterations — the blob is garbage now. + store.getState().advance(name) + } + } finally { + unsubscribe() + store.getState().finish() + } + + if (failures.length > 0) { + console.error('Export failures:', failures) + // Deliberately not named `count` — i18next would treat it as a plural + // selector and look for `exportFailures_one` / `_other`. + await showExportError('operations.exportFailures', { + failed: failures.length, + total: work.length, + }) + } +} + export async function exportCurrentProjectAs( format: 'khr' | 'psd' | 'rendered' | 'inpainted', pages?: string[], ): Promise { try { + // Multi-page image exports stream page-by-page into a folder on desktop. + // Everything else (khr, psd, single page, browser) takes the blob path. + if (isTauri() && (format === 'rendered' || format === 'inpainted') && pages?.length !== 1) { + await exportImagesToFolder(format, pages) + return + } const defaultFont = usePreferencesStore.getState().defaultFont const { blob, filename } = await exportProject({ format, pages, defaultFont }) const base = sanitiseBaseName(currentProjectName()) diff --git a/ui/lib/io/saveBlob.ts b/ui/lib/io/saveBlob.ts index 753d084cc..17565bef0 100644 --- a/ui/lib/io/saveBlob.ts +++ b/ui/lib/io/saveBlob.ts @@ -17,6 +17,37 @@ import { isTauri } from '@/lib/backend' +/** + * Native folder picker (Tauri only). Returns the chosen directory, or `null` + * if the user cancelled. Shared by `saveBlob`'s zip branch and the streaming + * folder export in `lib/io/pagesIo.ts`, which asks for the destination + * *before* it starts producing bytes. + */ +export async function pickSaveDirectory(): Promise { + const { open } = await import('@tauri-apps/plugin-dialog') + const folder = await open({ directory: true, multiple: false }) + return typeof folder === 'string' ? folder : null +} + +/** + * Write `bytes` to `name` inside `dir`. `name` may contain forward slashes — + * the intermediate directories are created only in that case, so the flat + * common case costs a single IPC call per file. + */ +export async function writeFileInDir( + dir: string, + name: string, + bytes: Uint8Array, +): Promise { + const { writeFile, mkdir } = await import('@tauri-apps/plugin-fs') + const normalized = name.replace(/\\/g, '/') + const full = `${dir}/${normalized}` + if (normalized.includes('/')) { + await mkdir(full.substring(0, full.lastIndexOf('/')), { recursive: true }).catch(() => {}) + } + await writeFile(full, bytes) +} + export async function saveBlob(blob: Blob, defaultName: string): Promise { // Zip detection must come from the actual content type — a single-file // export (PNG/PSD/khr) whose filename happens to end in `.zip` would @@ -24,27 +55,19 @@ export async function saveBlob(blob: Blob, defaultName: string): Promise folder.length) { - const dir = full.substring(0, slash) - await mkdir(dir, { recursive: true }).catch(() => {}) - } - await writeFile(full, bytes) + await writeFileInDir(folder, name, bytes) } return true } + const { save } = await import('@tauri-apps/plugin-dialog') + const { writeFile } = await import('@tauri-apps/plugin-fs') const path = await save({ defaultPath: defaultName }) if (!path || typeof path !== 'string') return false await writeFile(path, new Uint8Array(await blob.arrayBuffer())) diff --git a/ui/lib/io/scene.ts b/ui/lib/io/scene.ts index 8a2519319..bfbaba488 100644 --- a/ui/lib/io/scene.ts +++ b/ui/lib/io/scene.ts @@ -231,11 +231,13 @@ export async function uploadKhrArchive(file: File): Promise { */ export async function exportProject( req: ExportProjectRequest, + signal?: AbortSignal, ): Promise<{ blob: Blob; filename?: string }> { const res = await fetch(getExportCurrentProjectUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req), + signal, }) if (!res.ok) { const body = await res.json().catch(() => null) diff --git a/ui/lib/stores/exportStore.ts b/ui/lib/stores/exportStore.ts new file mode 100644 index 000000000..2e29e1ce4 --- /dev/null +++ b/ui/lib/stores/exportStore.ts @@ -0,0 +1,57 @@ +'use client' + +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +/** + * Progress for the streaming folder export in `lib/io/pagesIo.ts`. + * + * Purely client-side — unlike pipeline jobs there is no server-side job to + * poll, since the export loop lives in the UI (one request + one write per + * page, so a 1k-page export never holds more than a single page in memory). + * `cancelRequested` is polled between pages by the loop. + */ +export type ExportRole = 'rendered' | 'inpainted' + +export type ExportActivity = { + role: ExportRole + total: number + done: number + currentName?: string +} + +type ExportState = { + active: ExportActivity | null + cancelRequested: boolean + start: (role: ExportRole, total: number) => void + advance: (currentName: string) => void + requestCancel: () => void + finish: () => void +} + +export const useExportStore = create()( + immer((set) => ({ + active: null, + cancelRequested: false, + start: (role, total) => + set((s) => { + s.active = { role, total, done: 0 } + s.cancelRequested = false + }), + advance: (currentName) => + set((s) => { + if (!s.active) return + s.active.done += 1 + s.active.currentName = currentName + }), + requestCancel: () => + set((s) => { + s.cancelRequested = true + }), + finish: () => + set((s) => { + s.active = null + s.cancelRequested = false + }), + })), +) diff --git a/ui/public/locales/en-US/translation.json b/ui/public/locales/en-US/translation.json index 5cdb145ac..bb5025834 100644 --- a/ui/public/locales/en-US/translation.json +++ b/ui/public/locales/en-US/translation.json @@ -86,6 +86,11 @@ "processCurrent": "Processing current image", "processAll": "Processing all images", "imageProgress": "Image {{current}} / {{total}}", + "exportingRendered": "Exporting rendered pages", + "exportingInpainted": "Exporting inpainted pages", + "fileProgress": "File {{current}} / {{total}}", + "exportNoPages": "No pages have that layer yet.", + "exportFailures": "{{failed}} of {{total}} pages failed to export", "stepProgress": "Step {{current}} / {{total}}: {{step}}", "warningsOne": "1 step failed, continuing", "warningsOther": "{{count}} steps failed, continuing", diff --git a/ui/public/locales/es-ES/translation.json b/ui/public/locales/es-ES/translation.json index 17084fbac..31d6ce6a1 100644 --- a/ui/public/locales/es-ES/translation.json +++ b/ui/public/locales/es-ES/translation.json @@ -69,6 +69,11 @@ "processCurrent": "Procesando imagen actual", "processAll": "Procesando todas las imágenes", "imageProgress": "Imagen {{current}} / {{total}}", + "exportingRendered": "Exportando páginas renderizadas", + "exportingInpainted": "Exportando páginas rellenadas", + "fileProgress": "Archivo {{current}} / {{total}}", + "exportNoPages": "Ninguna página tiene esa capa todavía.", + "exportFailures": "No se pudieron exportar {{failed}} de {{total}} páginas", "stepProgress": "Paso {{current}} / {{total}}: {{step}}", "warningsOne": "1 paso falló, continuando", "warningsOther": "{{count}} pasos fallaron, continuando", diff --git a/ui/public/locales/ja-JP/translation.json b/ui/public/locales/ja-JP/translation.json index 2fe195ab4..da30a00c9 100644 --- a/ui/public/locales/ja-JP/translation.json +++ b/ui/public/locales/ja-JP/translation.json @@ -69,6 +69,11 @@ "processCurrent": "現在の画像を処理中", "processAll": "すべての画像を一括処理中", "imageProgress": "画像 {{current}} / {{total}}", + "exportingRendered": "レンダリング画像をエクスポート中", + "exportingInpainted": "インペイント画像をエクスポート中", + "fileProgress": "ファイル {{current}} / {{total}}", + "exportNoPages": "そのレイヤーを持つページがまだありません。", + "exportFailures": "{{total}} ページ中 {{failed}} ページのエクスポートに失敗しました", "stepProgress": "ステップ {{current}} / {{total}}:{{step}}", "warningsOne": "1 つのステップが失敗しましたが、続行します", "warningsOther": "{{count}} 個のステップが失敗しましたが、続行します", diff --git a/ui/public/locales/ko-KR/translation.json b/ui/public/locales/ko-KR/translation.json index f9ec55422..e02c8a31b 100644 --- a/ui/public/locales/ko-KR/translation.json +++ b/ui/public/locales/ko-KR/translation.json @@ -74,6 +74,11 @@ "processCurrent": "현재 이미지 처리 중", "processAll": "모든 이미지 처리 중", "imageProgress": "이미지 {{current}} / {{total}}", + "exportingRendered": "렌더링된 이미지 내보내는 중", + "exportingInpainted": "인페인트 이미지 내보내는 중", + "fileProgress": "파일 {{current}} / {{total}}", + "exportNoPages": "해당 레이어가 있는 페이지가 아직 없습니다.", + "exportFailures": "{{total}}개 중 {{failed}}개 페이지를 내보내지 못했습니다", "stepProgress": "단계 {{current}} / {{total}}: {{step}}", "warningsOne": "1개 단계 실패, 계속 진행", "warningsOther": "{{count}}개 단계 실패, 계속 진행", diff --git a/ui/public/locales/pt-BR/translation.json b/ui/public/locales/pt-BR/translation.json index ad891bfc6..ca4ae4dfe 100644 --- a/ui/public/locales/pt-BR/translation.json +++ b/ui/public/locales/pt-BR/translation.json @@ -70,6 +70,11 @@ "processCurrent": "Processando imagem atual", "processAll": "Processando todas as imagens", "imageProgress": "Imagem {{current}} / {{total}}", + "exportingRendered": "Exportando páginas renderizadas", + "exportingInpainted": "Exportando páginas com inpainting", + "fileProgress": "Arquivo {{current}} / {{total}}", + "exportNoPages": "Nenhuma página tem essa camada ainda.", + "exportFailures": "Falha ao exportar {{failed}} de {{total}} páginas", "stepProgress": "Etapa {{current}} / {{total}}: {{step}}", "warningsOne": "1 etapa falhou, continuando", "warningsOther": "{{count}} etapas falharam, continuando", diff --git a/ui/public/locales/ru-RU/translation.json b/ui/public/locales/ru-RU/translation.json index 62b06211f..a2a3fac46 100644 --- a/ui/public/locales/ru-RU/translation.json +++ b/ui/public/locales/ru-RU/translation.json @@ -69,6 +69,11 @@ "processCurrent": "Обработка текущего изображения", "processAll": "Обработка всех изображений", "imageProgress": "Изображение {{current}} / {{total}}", + "exportingRendered": "Экспорт отрендеренных страниц", + "exportingInpainted": "Экспорт страниц после инпейнтинга", + "fileProgress": "Файл {{current}} / {{total}}", + "exportNoPages": "Пока ни на одной странице нет этого слоя.", + "exportFailures": "Не удалось экспортировать {{failed}} из {{total}} страниц", "stepProgress": "Шаг {{current}} / {{total}}: {{step}}", "warningsOne": "1 шаг не выполнен, продолжаем", "warningsOther": "{{count}} шагов не выполнены, продолжаем", diff --git a/ui/public/locales/tr-TR/translation.json b/ui/public/locales/tr-TR/translation.json index b062d6fe9..7109b1d16 100644 --- a/ui/public/locales/tr-TR/translation.json +++ b/ui/public/locales/tr-TR/translation.json @@ -69,6 +69,11 @@ "processCurrent": "Geçerli görsel işleniyor", "processAll": "Tüm görseller işleniyor", "imageProgress": "Görsel {{current}} / {{total}}", + "exportingRendered": "Render görselleri dışa aktarılıyor", + "exportingInpainted": "İnpaint görselleri dışa aktarılıyor", + "fileProgress": "Dosya {{current}} / {{total}}", + "exportNoPages": "Henüz hiçbir sayfada bu katman yok.", + "exportFailures": "{{total}} sayfadan {{failed}} tanesi dışa aktarılamadı", "stepProgress": "Adım {{current}} / {{total}}: {{step}}", "warningsOne": "1 adım başarısız oldu, devam ediliyor", "warningsOther": "{{count}} adım başarısız oldu, devam ediliyor", diff --git a/ui/public/locales/zh-CN/translation.json b/ui/public/locales/zh-CN/translation.json index 60bfeb1af..382584dc3 100644 --- a/ui/public/locales/zh-CN/translation.json +++ b/ui/public/locales/zh-CN/translation.json @@ -69,6 +69,11 @@ "processCurrent": "正在处理当前图片", "processAll": "正在批量处理所有图片", "imageProgress": "图片 {{current}} / {{total}}", + "exportingRendered": "正在导出渲染后的图像", + "exportingInpainted": "正在导出修复后的图像", + "fileProgress": "文件 {{current}} / {{total}}", + "exportNoPages": "还没有页面包含该图层。", + "exportFailures": "{{total}} 页中有 {{failed}} 页导出失败", "stepProgress": "步骤 {{current}} / {{total}}:{{step}}", "warningsOne": "1 个步骤失败,继续处理", "warningsOther": "{{count}} 个步骤失败,继续处理", diff --git a/ui/public/locales/zh-TW/translation.json b/ui/public/locales/zh-TW/translation.json index 973fac145..7347eb779 100644 --- a/ui/public/locales/zh-TW/translation.json +++ b/ui/public/locales/zh-TW/translation.json @@ -69,6 +69,11 @@ "processCurrent": "正在處理目前圖片", "processAll": "正在批次處理所有圖片", "imageProgress": "圖片 {{current}} / {{total}}", + "exportingRendered": "正在匯出渲染後的影像", + "exportingInpainted": "正在匯出修補後的影像", + "fileProgress": "檔案 {{current}} / {{total}}", + "exportNoPages": "尚未有頁面包含該圖層。", + "exportFailures": "{{total}} 頁中有 {{failed}} 頁匯出失敗", "stepProgress": "步驟 {{current}} / {{total}}:{{step}}", "warningsOne": "1 個步驟失敗,繼續處理", "warningsOther": "{{count}} 個步驟失敗,繼續處理", diff --git a/ui/tests/lib/io/pagesIo.test.ts b/ui/tests/lib/io/pagesIo.test.ts index 04a21a932..71f7a832c 100644 --- a/ui/tests/lib/io/pagesIo.test.ts +++ b/ui/tests/lib/io/pagesIo.test.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from 'msw' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getGetSceneJsonQueryKey } from '@/lib/api/default/default' import { queryClient } from '@/lib/queryClient' @@ -15,18 +15,22 @@ vi.mock('@/lib/io/openFiles', () => ({ })) vi.mock('@/lib/io/saveBlob', async () => { // Keep the real `filenameFromContentDisposition` so the export flow can - // read server-provided filenames from `Content-Disposition`. Only stub - // `saveBlob` itself, since it touches the filesystem / Tauri dialog. + // read server-provided filenames from `Content-Disposition`. Only stub the + // members that touch the filesystem / Tauri dialog. const actual = await vi.importActual('@/lib/io/saveBlob') return { ...actual, saveBlob: vi.fn().mockResolvedValue(true), + pickSaveDirectory: vi.fn().mockResolvedValue('/out'), + writeFileInDir: vi.fn().mockResolvedValue(undefined), } }) import { openImageFiles, openImageFolder, openKhrFile } from '@/lib/io/openFiles' import { exportCurrentProjectAs, importKhrFile, importPages } from '@/lib/io/pagesIo' -import { saveBlob } from '@/lib/io/saveBlob' +import { pickSaveDirectory, saveBlob, writeFileInDir } from '@/lib/io/saveBlob' +import { useEditorUiStore } from '@/lib/stores/editorUiStore' +import { useExportStore } from '@/lib/stores/exportStore' const asMock = unknown>(fn: T) => fn as unknown as ReturnType @@ -211,3 +215,186 @@ describe('exportCurrentProjectAs', () => { expect((blob as Blob).type).toBe('image/png') }) }) + +// --------------------------------------------------------------------------- +// Streaming folder export (desktop): folder first, then one page at a time. +// --------------------------------------------------------------------------- + +/** Minimal page carrying an `Image { role }` node, as `findImageBlob` sees it. */ +function pageWith(roles: string[]) { + return { + id: 'x', + name: 'x', + width: 10, + height: 10, + nodes: Object.fromEntries( + roles.map((role, i) => [ + `n${i}`, + { id: `n${i}`, visible: true, kind: { image: { role, blob: `blob-${role}` } } }, + ]), + ), + } +} + +function setScene(pages: Record) { + queryClient.setQueryData(getGetSceneJsonQueryKey(), { + epoch: 0, + scene: { pages, project: {} as never }, + }) +} + +describe('exportCurrentProjectAs — streaming folder export', () => { + // `isTauri()` sniffs this global; setting it takes the desktop branch + // without mocking the whole backend module. + beforeEach(() => { + ;(window as unknown as Record).__TAURI_INTERNALS__ = {} + asMock(pickSaveDirectory).mockResolvedValue('/out') + asMock(writeFileInDir).mockResolvedValue(undefined) + useExportStore.getState().finish() + useEditorUiStore.getState().clearError() + }) + afterEach(() => { + delete (window as unknown as Record).__TAURI_INTERNALS__ + }) + + /** Record export POSTs and disk writes on one timeline, in call order. */ + function trace() { + const events: string[] = [] + server.use( + http.post('/api/v1/projects/current/export', async ({ request }) => { + const body = (await request.json()) as { pages?: string[] } + events.push(`post:${body.pages?.join(',')}`) + return HttpResponse.arrayBuffer(new Uint8Array([137, 80, 78, 71]).buffer, { + headers: { 'content-type': 'image/png' }, + }) + }), + ) + asMock(writeFileInDir).mockImplementation(async (_dir: string, name: string) => { + events.push(`write:${name}`) + }) + return events + } + + it('opens the folder picker before requesting a single page', async () => { + setScene({ p1: pageWith(['rendered']), p2: pageWith(['rendered']) }) + const events = trace() + asMock(pickSaveDirectory).mockImplementation(async () => { + events.push('picker') + return '/out' + }) + + await exportCurrentProjectAs('rendered') + + // The regression: nothing was rendered or buffered before the user chose + // a destination. + expect(events[0]).toBe('picker') + expect(events).toEqual([ + 'picker', + 'post:p1', + 'write:page-001-p1.png', + 'post:p2', + 'write:page-002-p2.png', + ]) + }) + + it('does no work at all when the picker is cancelled', async () => { + setScene({ p1: pageWith(['rendered']) }) + const events = trace() + asMock(pickSaveDirectory).mockResolvedValue(null) + + await exportCurrentProjectAs('rendered') + + expect(events).toEqual([]) + expect(saveBlob).not.toHaveBeenCalled() + }) + + it('skips pages without the layer but keeps their index in the filename', async () => { + setScene({ + a: pageWith(['rendered']), + b: pageWith(['source']), + c: pageWith(['rendered']), + }) + const events = trace() + + await exportCurrentProjectAs('rendered') + + // Matches the server's numbering, which enumerates every page and leaves + // a gap where a page lacks the layer. + expect(events).toEqual([ + 'post:a', + 'write:page-001-a.png', + 'post:c', + 'write:page-003-c.png', + ]) + }) + + it('honours the inpainted role and an explicit page subset', async () => { + setScene({ + a: pageWith(['inpainted']), + b: pageWith(['inpainted']), + c: pageWith(['inpainted']), + }) + const events = trace() + + await exportCurrentProjectAs('inpainted', ['c', 'a']) + + expect(events).toEqual([ + 'post:c', + 'write:page-001-c.png', + 'post:a', + 'write:page-002-a.png', + ]) + }) + + it('stops requesting pages once cancel is asked for', async () => { + setScene({ a: pageWith(['rendered']), b: pageWith(['rendered']), c: pageWith(['rendered']) }) + const events = trace() + asMock(writeFileInDir).mockImplementation(async (_dir: string, name: string) => { + events.push(`write:${name}`) + useExportStore.getState().requestCancel() + }) + + await exportCurrentProjectAs('rendered') + + expect(events).toEqual(['post:a', 'write:page-001-a.png']) + // The card is torn down and the flag reset for the next export. + expect(useExportStore.getState().active).toBeNull() + expect(useExportStore.getState().cancelRequested).toBe(false) + }) + + it('reports instead of exporting when no page has the layer', async () => { + setScene({ a: pageWith(['source']) }) + const events = trace() + + await exportCurrentProjectAs('rendered') + + expect(events).toEqual([]) + expect(pickSaveDirectory).not.toHaveBeenCalled() + expect(useEditorUiStore.getState().error?.message).toBeTruthy() + }) + + it('keeps going after a failed page and reports the failures', async () => { + setScene({ a: pageWith(['rendered']), b: pageWith(['rendered']) }) + const events: string[] = [] + server.use( + http.post('/api/v1/projects/current/export', async ({ request }) => { + const body = (await request.json()) as { pages?: string[] } + events.push(`post:${body.pages?.join(',')}`) + if (body.pages?.[0] === 'a') { + return HttpResponse.json({ message: 'boom' }, { status: 500 }) + } + return HttpResponse.arrayBuffer(new Uint8Array([137, 80, 78, 71]).buffer, { + headers: { 'content-type': 'image/png' }, + }) + }), + ) + asMock(writeFileInDir).mockImplementation(async (_dir: string, name: string) => { + events.push(`write:${name}`) + }) + + await exportCurrentProjectAs('rendered') + + expect(events).toEqual(['post:a', 'post:b', 'write:page-002-b.png']) + expect(useEditorUiStore.getState().error?.message).toBeTruthy() + }) +})