Skip to content
Open
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
24 changes: 14 additions & 10 deletions crates/koharu-rpc/src/psd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,20 @@ pub fn png_bytes_for_page(
page_id: PageId,
role: ImageRole,
) -> Result<Option<Vec<u8>>> {
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);
};
Expand Down
18 changes: 12 additions & 6 deletions crates/koharu-rpc/src/routes/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
67 changes: 66 additions & 1 deletion ui/components/ActivityBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useTranslation>['t']
Expand Down Expand Up @@ -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 (
<BubbleCard>
<div data-testid='export-card' className='flex items-start gap-3'>
<div className='mt-1 h-2.5 w-2.5 rounded-full bg-primary shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 flex-col gap-1'>
<div className='text-sm font-semibold text-foreground'>{title}</div>
<div
className='block max-w-full truncate text-xs text-muted-foreground'
title={subtitle}
>
{subtitle}
</div>
</div>
<ProgressBar percent={percent} />
<div className='mt-3 flex justify-end'>
<Button
data-testid='export-cancel'
variant='outline'
size='sm'
onClick={onCancel}
className='text-xs font-semibold'
>
{t('operations.cancel')}
</Button>
</div>
</div>
</div>
</BubbleCard>
)
}

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',
Expand All @@ -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 (
<div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
{errorMessage && <ErrorCard message={errorMessage} onDismiss={clearUiError} t={t} />}
{exportActivity && (
<ExportCard
activity={exportActivity}
onCancel={() => useExportStore.getState().requestCancel()}
t={t}
/>
)}
{runningJobs.map((job) => (
<JobCard key={job.id} job={job} onCancel={() => void cancelOperation(job.id)} t={t} />
))}
Expand Down
108 changes: 105 additions & 3 deletions ui/lib/io/pagesIo.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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<string, unknown>): Promise<void> {
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<Scene> {
const cached = queryClient.getQueryData<SceneSnapshot>(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<void> {
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<void> {
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())
Expand Down
49 changes: 36 additions & 13 deletions ui/lib/io/saveBlob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,57 @@

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<string | null> {
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<void> {
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<boolean> {
// Zip detection must come from the actual content type — a single-file
// export (PNG/PSD/khr) whose filename happens to end in `.zip` would
// otherwise be fed to `unzipSync` and throw.
const isZip = blob.type === 'application/zip'

if (isTauri()) {
const { open, save } = await import('@tauri-apps/plugin-dialog')
const { writeFile, mkdir } = await import('@tauri-apps/plugin-fs')

if (isZip) {
const folder = await open({ directory: true, multiple: false })
if (!folder || typeof folder !== 'string') return false
const folder = await pickSaveDirectory()
if (!folder) return false
const { unzipSync } = await import('fflate')
const entries = unzipSync(new Uint8Array(await blob.arrayBuffer()))
for (const [name, bytes] of Object.entries(entries)) {
const normalized = name.replace(/\\/g, '/')
const full = `${folder}/${normalized}`
const slash = full.lastIndexOf('/')
if (slash > 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()))
Expand Down
2 changes: 2 additions & 0 deletions ui/lib/io/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,13 @@ export async function uploadKhrArchive(file: File): Promise<ProjectSummary> {
*/
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)
Expand Down
Loading
Loading