mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
403 lines
19 KiB
Rust
403 lines
19 KiB
Rust
use super::batch::run_batch;
|
||
use super::encoding::{encode_gain_map_jpeg, encode_rendered, export_metadata, removes_location};
|
||
use super::options::ExportFormat;
|
||
use super::params::apple_photos_unsupported;
|
||
use super::{ExportOptions, Metadata, OutputDepth};
|
||
|
||
/// One exported file.
|
||
pub struct Exported {
|
||
pub file_name: String,
|
||
pub bytes: Vec<u8>,
|
||
pub width: usize,
|
||
pub height: usize,
|
||
/// Files written next to it, named like it with another extension: `(extension, bytes)` (the
|
||
/// XMP sidecar of an `Original` export).
|
||
pub sidecars: Vec<(&'static str, Vec<u8>)>,
|
||
/// Set when the render was tried on the GPU and fell back to the CPU: why (see
|
||
/// `docs/gpu-pipeline.md`). The file holds the CPU render either way.
|
||
pub gpu_fallback: Option<String>,
|
||
}
|
||
|
||
/// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`).
|
||
pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) {
|
||
let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||
// whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie
|
||
let (w, h) = (w.round().max(1.0), h.round().max(1.0));
|
||
match &o.resize {
|
||
Some(r) => r.apply(w, h),
|
||
None => (w as usize, h as usize),
|
||
}
|
||
}
|
||
|
||
/// Render photo `id` at the requested size and encode it (or copy / convert its original for
|
||
/// [`ExportFormat::Original`] / [`ExportFormat::Dng`]).
|
||
pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result<Exported, String> {
|
||
prepare_export(session, id, o, seq)?.run()
|
||
}
|
||
|
||
/// One photo's export, set up from the session ([`prepare_export`]). [`PreparedExport::run`] does
|
||
/// the heavy part (read, decode, render, encode) without the session, so it can run on another
|
||
/// thread.
|
||
pub struct PreparedExport {
|
||
pub photo: lightcraft_catalog::PhotoId,
|
||
pub file_name: String,
|
||
/// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None`
|
||
/// for a demo photo or one in the browser's storage.
|
||
pub source_dir: Option<String>,
|
||
work: Work,
|
||
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
|
||
pub(super) guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||
/// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`]
|
||
/// while it runs beside other photos of a batch.
|
||
pub(super) weight: usize,
|
||
}
|
||
|
||
struct RenderWork {
|
||
job: crate::media::RenderJob,
|
||
/// The HDR rendition of a gain map JPEG (`job` renders its SDR base).
|
||
hdr_job: Option<crate::media::RenderJob>,
|
||
meta: Option<Metadata>,
|
||
opts: ExportOptions,
|
||
}
|
||
|
||
enum Work {
|
||
Render(Box<RenderWork>),
|
||
/// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the
|
||
/// raw data re-encoded as a lossless DNG with the edits in its XMP).
|
||
File {
|
||
path: String,
|
||
read: Option<crate::merge::ByteReader>,
|
||
packet: String,
|
||
dng: Option<DngCompression>,
|
||
/// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size).
|
||
quality: u8,
|
||
lossy_edge: Option<u32>,
|
||
/// The original must not carry a location ([`removes_location`]).
|
||
remove_location: bool,
|
||
label: String,
|
||
size: (usize, usize),
|
||
},
|
||
}
|
||
|
||
/// Set up the export of photo `id` at 1-based position `seq` of a batch. (For a whole batch use
|
||
/// [`prepare_batch`]: it looks at the library's originals once.)
|
||
pub fn prepare_export(
|
||
session: &mut crate::Session,
|
||
id: lightcraft_catalog::PhotoId,
|
||
o: &ExportOptions,
|
||
seq: usize,
|
||
) -> Result<PreparedExport, String> {
|
||
let guard = std::sync::Arc::new(session.original_guard());
|
||
prepare_guarded(session, id, o, seq, guard)
|
||
}
|
||
|
||
/// [`prepare_export`] for each of `ids` in order (`{seq}` = position, from 1).
|
||
pub fn prepare_batch(session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], o: &ExportOptions) -> Result<Vec<PreparedExport>, String> {
|
||
let guard = std::sync::Arc::new(session.original_guard());
|
||
ids.iter().enumerate().map(|(i, id)| prepare_guarded(session, *id, o, i + 1, guard.clone())).collect()
|
||
}
|
||
|
||
fn prepare_guarded(
|
||
session: &mut crate::Session,
|
||
id: lightcraft_catalog::PhotoId,
|
||
o: &ExportOptions,
|
||
seq: usize,
|
||
guard: std::sync::Arc<crate::originals::OriginalGuard>,
|
||
) -> Result<PreparedExport, String> {
|
||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||
let file_name = o.file_name_for(p, seq);
|
||
let source_dir = original_folder(&p.source);
|
||
// HDR output only for photos edited in HDR; the rest of the batch exports as usual
|
||
let sdr_opts;
|
||
let o = if o.hdr && !p.develop.hdr.enabled {
|
||
sdr_opts = ExportOptions { hdr: false, ..o.clone() };
|
||
&sdr_opts
|
||
} else {
|
||
o
|
||
};
|
||
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
|
||
let (work, weight) = if o.format.is_rendered() {
|
||
let (w, h) = output_size(p, o);
|
||
let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||
let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize;
|
||
let label = p.file_name.clone();
|
||
let meta = export_metadata(p, &session.catalog, o);
|
||
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
|
||
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
|
||
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
|
||
if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {
|
||
return Err(format!("{label}: {why}"));
|
||
}
|
||
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
|
||
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
|
||
} else {
|
||
let lightcraft_catalog::Source::File { path } = &p.source else {
|
||
return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name));
|
||
};
|
||
let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression);
|
||
// the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again
|
||
let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 });
|
||
let work = Work::File {
|
||
path: path.clone(),
|
||
read: session.media.file_bytes.clone(),
|
||
packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)),
|
||
dng,
|
||
quality: o.quality,
|
||
lossy_edge: o.resize.is_some().then(|| {
|
||
let (w, h) = output_size(p, o);
|
||
u32::try_from(w.max(h)).unwrap_or(u32::MAX)
|
||
}),
|
||
remove_location: removes_location(p, &session.catalog, o),
|
||
label: p.file_name.clone(),
|
||
size: (p.width as usize, p.height as usize),
|
||
};
|
||
(work, weight)
|
||
};
|
||
Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight })
|
||
}
|
||
|
||
/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo
|
||
/// photo, and on the web, where an original lives in the browser's storage, not in a folder.
|
||
fn original_folder(source: &lightcraft_catalog::Source) -> Option<String> {
|
||
let lightcraft_catalog::Source::File { path } = source else { return None };
|
||
if cfg!(target_arch = "wasm32") {
|
||
return None;
|
||
}
|
||
let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string();
|
||
(!dir.is_empty()).then_some(dir)
|
||
}
|
||
|
||
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
|
||
/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export
|
||
/// enlarged past the photo's size that needs more memory than the computer has would fail to
|
||
/// allocate, which ends the process rather than the export. Full-size and smaller exports, and
|
||
/// computers whose memory isn't known, are never refused.
|
||
pub(super) fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option<usize>) -> Option<String> {
|
||
let ram = ram?;
|
||
if out.0.saturating_mul(out.1) <= native_px || weight <= ram {
|
||
return None;
|
||
}
|
||
let gb = |b: usize| b as f64 / (1u64 << 30) as f64;
|
||
Some(format!(
|
||
"exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size",
|
||
out.0,
|
||
out.1,
|
||
gb(weight).ceil(),
|
||
gb(ram)
|
||
))
|
||
}
|
||
|
||
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
|
||
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
|
||
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
|
||
/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB.
|
||
pub(super) fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize {
|
||
let edge = level.max_edge().min(long.max(1));
|
||
let scale = edge as f64 / long.max(1) as f64;
|
||
let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize;
|
||
src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36))
|
||
}
|
||
|
||
impl PreparedExport {
|
||
pub fn run(self) -> Result<Exported, String> {
|
||
let file_name = self.file_name;
|
||
match self.work {
|
||
Work::Render(w) => {
|
||
let RenderWork { job, hdr_job, meta, opts } = *w;
|
||
let result = job.run();
|
||
let r = result.rendered?;
|
||
let gpu_fallback = result.gpu_fallback;
|
||
let bytes = match hdr_job {
|
||
Some(hj) => {
|
||
let hr = hj.run().rendered?;
|
||
let hdr = hr.deep.ok_or("the HDR render has no float samples")?;
|
||
encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())?
|
||
}
|
||
None => encode_rendered(&r, &opts, meta.as_ref())?,
|
||
};
|
||
Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback })
|
||
}
|
||
Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => {
|
||
let bytes = match &read {
|
||
Some(r) => r(&path)?,
|
||
None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?,
|
||
};
|
||
let Some(dng) = dng else {
|
||
// the file is copied byte for byte: refuse rather than leak a location it embeds
|
||
if remove_location {
|
||
let metadata = lightcraft_meta::try_extract(&bytes)
|
||
.map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?;
|
||
if metadata.gps.is_some() {
|
||
return Err(format!(
|
||
"{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead"
|
||
));
|
||
}
|
||
}
|
||
return Ok(Exported {
|
||
file_name,
|
||
bytes,
|
||
width: size.0,
|
||
height: size.1,
|
||
sidecars: vec![("xmp", packet.into_bytes())],
|
||
gpu_fallback: None,
|
||
});
|
||
};
|
||
if lightcraft_raw::probe(&bytes).is_none() {
|
||
return Err(format!("{label}: DNG export needs a raw photo"));
|
||
}
|
||
let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?;
|
||
crate::camera_preview::with_starting_colour(&mut raw, &bytes);
|
||
drop(bytes);
|
||
let compression = match dng {
|
||
DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 },
|
||
DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false },
|
||
DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed,
|
||
DngCompression::Lossy => {
|
||
lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge }
|
||
}
|
||
};
|
||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() })
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new(), gpu_fallback: None })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How an exported DNG stores its raw data.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum DngCompression {
|
||
/// Lossless JPEG (the usual DNG compression).
|
||
#[default]
|
||
Lossless,
|
||
/// Deflate (zip) tiles with prediction.
|
||
Deflate,
|
||
Uncompressed,
|
||
/// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize
|
||
/// setting when there is one (Lightroom's "Use Lossy Compression").
|
||
Lossy,
|
||
}
|
||
|
||
/// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy
|
||
/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each
|
||
/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file
|
||
/// already there is replaced). Either way a catalogued original (or its sidecar)
|
||
/// is never written over: see [`crate::originals`].
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct Destination {
|
||
pub dir: String,
|
||
pub exact: Option<String>,
|
||
}
|
||
|
||
/// What happens after an export has written its files (Lightroom's Post-Processing): nothing, or
|
||
/// adding them to Apple Photos ([`ExportOptions::add_to_photos`]). Made by
|
||
/// [`crate::Session::after_export`] before the export starts, so an export asking for something
|
||
/// this session can't do fails before writing anything; for Photos it holds the import slot
|
||
/// (a [`crate::apple_photos::Reservation`]) until the files are handed over, and frees it when
|
||
/// dropped unused (the export failed or never ran). `Send`: a background export takes it to its
|
||
/// worker.
|
||
#[derive(Default)]
|
||
pub struct AfterExport {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
apple_photos: Option<crate::apple_photos::Reservation>,
|
||
}
|
||
|
||
impl AfterExport {
|
||
/// Add the files to Apple Photos with `runner`, into `album` when it isn't blank, reserving
|
||
/// the import slot of `imports` now. (Front ends get this from
|
||
/// [`crate::Session::after_export`], which checks the platform first.)
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn apple_photos(runner: crate::apple_photos::Runner, album: &str, imports: &crate::apple_photos::Imports) -> Result<Self, String> {
|
||
Ok(Self { apple_photos: Some(imports.reserve(runner, album)?) })
|
||
}
|
||
|
||
/// Nothing to do.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn is_none(&self) -> bool {
|
||
self.apple_photos.is_none()
|
||
}
|
||
|
||
/// Nothing to do.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub fn is_none(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
/// Do it now for an export's per-photo results ([`run_batch`]'s: only the files written
|
||
/// count), waiting for Photos: for a worker thread (the app's background export) or a caller
|
||
/// without a window (MCP, lightcraft-cli). `None` when there was nothing to do, else the
|
||
/// outcome to report as the export's `applePhotos`: the finished job (`{job, running: false,
|
||
/// requested, album}` with `{imported, ids, warning?}`, `{error}`, or `{skipped}` when the
|
||
/// export was cancelled or wrote nothing). Never fails the export: its files are written
|
||
/// either way.
|
||
pub fn run(self, files: &[serde_json::Value], cancelled: bool) -> Option<serde_json::Value> {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(r) = self.apple_photos {
|
||
return Some(r.run(files, cancelled));
|
||
}
|
||
let _ = (files, cancelled);
|
||
None
|
||
}
|
||
|
||
/// Like [`AfterExport::run`], without waiting: Photos works on a worker thread and the
|
||
/// outcome is the running job (`{job, running: true, requested, album}`; its result comes
|
||
/// from `export.photosImports`, and the desktop app announces it). For a caller on the UI
|
||
/// thread, which must not wait minutes for Photos.
|
||
pub fn start(self, files: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(r) = self.apple_photos {
|
||
return Some(r.start(files));
|
||
}
|
||
let _ = files;
|
||
None
|
||
}
|
||
|
||
/// [`AfterExport::start`] where the session answers on a UI thread
|
||
/// ([`crate::Session::apple_photos_background`]), else [`AfterExport::run`].
|
||
pub fn finish(self, s: &crate::Session, files: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if s.apple_photos_background {
|
||
return self.start(files);
|
||
}
|
||
let _ = s;
|
||
self.run(files, false)
|
||
}
|
||
}
|
||
|
||
impl crate::Session {
|
||
/// What an export with `o` does after writing its files; an error, before anything is written,
|
||
/// when it asks for Apple Photos and this platform or session has no way to reach it, or
|
||
/// another import is reserved or running. For Photos the import slot is reserved from now on.
|
||
pub fn after_export(&self, o: &ExportOptions) -> Result<AfterExport, String> {
|
||
if !o.add_to_photos {
|
||
return Ok(AfterExport::default());
|
||
}
|
||
if let Some(why) = apple_photos_unsupported() {
|
||
return Err(why.into());
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let runner = self.apple_photos.clone().ok_or_else(|| crate::apple_photos::unavailable().to_string())?;
|
||
let mut after = AfterExport::apple_photos(runner, &o.photos_album, &self.apple_photos_imports)?;
|
||
after.apple_photos = after.apple_photos.map(|r| r.shown_in(self.activity.clone()));
|
||
Ok(after)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
Err("Apple Photos is only available on macOS".into())
|
||
}
|
||
}
|
||
|
||
/// Export `ids` in order ([`prepare_batch`] + [`run_batch`]), stopping at the first error.
|
||
pub fn export_batch(
|
||
session: &mut crate::Session,
|
||
ids: &[lightcraft_catalog::PhotoId],
|
||
o: &ExportOptions,
|
||
to: &Destination,
|
||
write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>,
|
||
exists: &dyn Fn(&str) -> bool,
|
||
) -> Result<Vec<serde_json::Value>, String> {
|
||
let items = prepare_batch(session, ids, o)?;
|
||
run_batch(items, o, to, write, exists, true, &mut |_, _| true)
|
||
}
|