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() + }) +})