diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 31a3b620..9972da85 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,6 +1,6 @@
---
name: Feature request
-about: A new format, preset option or idea
+about: A preset option, engine or idea (a file type Filee can't open goes in "Format request")
labels: enhancement
---
diff --git a/.github/ISSUE_TEMPLATE/format_request.yml b/.github/ISSUE_TEMPLATE/format_request.yml
new file mode 100644
index 00000000..9776cea2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/format_request.yml
@@ -0,0 +1,49 @@
+name: Format request
+description: A file type Filee can't open yet
+title: "Format request: "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ > [!IMPORTANT]
+ > **Filee can't convert files that are encrypted or copy-protected (DRM), whatever their format.** Converting them means breaking that protection, so these requests are closed.
+ >
+ > Some examples, not a complete list:
+ > - music downloaded in a streaming app (NetEase Cloud Music `.ncm`, QQ Music `.qmc` / `.mflac` / `.mgg`, Kugou `.kgm`, Apple Music, Spotify)
+ > - e-books bought with DRM (Kindle `.azw` / `.kfx`, Adobe DRM EPUB or PDF)
+ > - videos saved by streaming apps or sites that don't allow downloading
+ > - files locked with a password you don't have
+ >
+ > If the app that made the file can export or save it in a common format, that is the way to go.
+ - type: input
+ id: extension
+ attributes:
+ label: File extension
+ placeholder: ".xyz"
+ validations:
+ required: true
+ - type: input
+ id: source
+ attributes:
+ label: What makes these files?
+ description: The program, device or website.
+ validations:
+ required: true
+ - type: input
+ id: target
+ attributes:
+ label: What should Filee convert them to?
+ placeholder: "PDF, MP3, PNG…"
+ - type: textarea
+ id: details
+ attributes:
+ label: Anything else?
+ description: A sample file if you can share one, a link to the format's documentation, or an open-source tool that reads it.
+ - type: checkboxes
+ id: not-protected
+ attributes:
+ label: Protection
+ options:
+ - label: These files aren't encrypted or copy-protected (DRM), and I can open them without a password.
+ required: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 59faee05..4065b353 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,6 +15,11 @@ Thanks for helping! Filee is built to be easy to extend: most contributions touc
Engines lets you choose a specific copy. Microsoft Office is not used.
4. `dotnet run --project src/Filee.App`, `dotnet test`.
+To try a build next to the Filee you use every day (testing, measuring memory), set `FILEE_DATA_DIR` to an empty
+folder first. Settings, logs and downloaded engines then live there, the copy is its own single instance (later
+launches with the same variable, `--quit` included, reach it and never your installed Filee), and it registers no
+startup entry, context menu or Explorer menu.
+
Any IDE works (Visual Studio 2022+, Rider, VS Code with C# Dev Kit).
## Linux and macOS development
diff --git a/README.ko.md b/README.ko.md
index 6a3d6ae4..01f2a94c 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -57,7 +57,7 @@
## 기능
- **커서 옆 도넛**: 키를 누른 채 파일을 끌어 형식 위에 놓으면 끝. 파일에 맞는 형식만 나와요.
-- **파일 종류마다 툴바**를 드래그로 배치하고, 품질·크기·DPI·PDF 페이지·동영상·오디오·저장 위치·파일 이름을
+- **파일 종류마다 툴바**를 드래그로 배치하고, 품질·크기·DPI·PDF 페이지와 압축·동영상·오디오·저장 위치·파일 이름을
프리셋으로 정해요.
- **여러 파일을 한 번에** 병렬로 변환해요. 여러 단계가 필요한 변환(HWP → HWPX → DOCX)도 알아서 찾아요.
- **자동 변환 폴더**에 들어온 파일을 바로 변환해요.
@@ -83,7 +83,7 @@
|---|---|
| **이미지**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR 등; GIMP XCF, 카메라 RAW(CR2, CR3, NEF, ARW, DNG 등), DICOM, OpenEXR, HDR은 읽기 | 위 형식끼리(HEIC⁵), PDF, SVG(벡터화), 크기 조정, 흑백 |
| **벡터**: SVG, SVGZ, EMF, WMF, AI · EPS, PS¹ · CDR, VSD, CGM, ODG, FODG² | PDF(벡터), PNG 등 이미지, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² |
-| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX(페이지마다 그림 한 장), SVG²; 합치기, 나누기, 페이지 범위 |
+| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX(페이지마다 그림 한 장), SVG²; 합치기, 나누기, 페이지 범위, 압축 (더 작은 PDF) |
| **문서**: DOCX, DOCM, DOTX, EML, Outlook MSG · DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602 등² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML · ODT, FODT, OTT² |
| **스프레드시트**: XLSX, XLSM, XLS, XLT, ODS, CSV, TSV · OTS, FODS, Numbers, ET² | PDF, XLSX, ODS, CSV, TSV, JSON, HWPX, HTML · XLT, OTS² |
| **프레젠테이션**: PPTX, PPTM, POTX, PPSX · PPT, ODP, OTP, FODP, Keynote² | PDF, PNG, JPG, DOCX, HWPX · OTP² |
diff --git a/README.md b/README.md
index 0547710b..4d175151 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ Filee shows new releases in its menu and updates itself, keeping your settings a
## Features
- **A donut at the cursor**: hold a key, drag files and drop them on a format. Only formats that fit the files appear.
-- **One toolbar per file type**, arranged by drag and drop, with presets for quality, size, DPI, PDF pages,
+- **One toolbar per file type**, arranged by drag and drop, with presets for quality, size, DPI, PDF pages and compression,
video and audio, output folder and file names.
- **Many files at once**, in parallel. Routes with several steps (HWP → HWPX → DOCX) are found automatically.
- **Watch folders** convert every file that lands in them.
@@ -83,7 +83,7 @@ Filee shows new releases in its menu and updates itself, keeping your settings a
|---|---|
| **Images**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR, …; GIMP XCF, camera RAW (CR2, CR3, NEF, ARW, DNG, …), DICOM, OpenEXR, HDR read | any of these (HEIC⁵), PDF, SVG (traced), resized, grayscale |
| **Vector**: SVG, SVGZ, EMF, WMF, AI · EPS, PS¹ · CDR, VSD, CGM, ODG, FODG² | PDF (vector), PNG and other images, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² |
-| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX (a picture of each page), SVG²; merge, split, page ranges |
+| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX (a picture of each page), SVG²; merge, split, page ranges, compress (smaller PDFs) |
| **Documents**: DOCX, DOCM, DOTX, EML, Outlook MSG · DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602, …² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML · ODT, FODT, OTT² |
| **Spreadsheets**: XLSX, XLSM, XLS, XLT, ODS, CSV, TSV · OTS, FODS, Numbers, ET² | PDF, XLSX, ODS, CSV, TSV, JSON, HWPX, HTML · XLT, OTS² |
| **Presentations**: PPTX, PPTM, POTX, PPSX · PPT, ODP, OTP, FODP, Keynote² | PDF, PNG, JPG, DOCX, HWPX · OTP² |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c6fe1dce..8cf5d8f0 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -57,7 +57,7 @@
## 功能
- **光标旁的圆环**,按住按键拖动文件,放到格式上即可。只显示适合这些文件的格式。
-- **每种文件类型一个工具栏**,拖放排列。质量、尺寸、DPI、PDF 页面、视频音频、保存位置和文件名都能用预设设置。
+- **每种文件类型一个工具栏**,拖放排列。质量、尺寸、DPI、PDF 页面和压缩、视频音频、保存位置和文件名都能用预设设置。
- **一次转换多个文件**,并行处理。需要多步的转换(HWP → HWPX → DOCX)会自动找到路线。
- **监视文件夹**,放入的文件自动转换。
- **其他方式** 右键菜单、键盘快捷键、拖放区域和 [`filee` 命令](#命令行)。
@@ -82,7 +82,7 @@
|---|---|
| **图片**:JPG、PNG、WEBP、AVIF、TIFF、BMP、GIF、APNG、ICO、ICNS、JPEG XL、JPEG 2000、PSD/PSB、TGA、PPM、HEIC、QOI、DDS、PCX、MNG、CUR 等;GIMP XCF、相机 RAW(CR2、CR3、NEF、ARW、DNG 等)、DICOM、OpenEXR、HDR 仅读取 | 上述格式互转(HEIC⁵)、PDF、SVG(描摹)、缩放、灰度 |
| **矢量图**:SVG、SVGZ、EMF、WMF、AI · EPS、PS¹ · CDR、VSD、CGM、ODG、FODG² | PDF(矢量)、PNG 等图片、SVG ↔ SVGZ、SVG → DXF/DWG、PDF → EPS/PS¹、SVG → EMF、WMF、ODG² |
-| **PDF** | DOCX、HWPX、TXT、PNG、JPG、TIFF、CBZ、PPTX(每页一张图片)、SVG²;合并、拆分、页码范围 |
+| **PDF** | DOCX、HWPX、TXT、PNG、JPG、TIFF、CBZ、PPTX(每页一张图片)、SVG²;合并、拆分、页码范围、压缩(更小的 PDF) |
| **文档**:DOCX、DOCM、DOTX、EML、Outlook MSG · DOC、ODT、FODT、OTT、RTF、WPS、WPD、Pages、ClarisWorks、MacWrite、T602 等² | PDF、DOCX、HWPX、EPUB、TXT、HTML、MSG → EML · ODT、FODT、OTT² |
| **表格**:XLSX、XLSM、XLS、XLT、ODS、CSV、TSV · OTS、FODS、Numbers、ET² | PDF、XLSX、ODS、CSV、TSV、JSON、HWPX、HTML · XLT、OTS² |
| **演示文稿**:PPTX、PPTM、POTX、PPSX · PPT、ODP、OTP、FODP、Keynote² | PDF、PNG、JPG、DOCX、HWPX · OTP² |
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index e9d6873c..549d4cb9 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -8,7 +8,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), Photo CD, OpenEXR, Radiance HDR and EMF/WMF (Windows) read |
| Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF |
| ICNS | built in | images → ICNS (16–1024 px), ICNS → images |
-| PDFsharp | built in (library) | images → PDF, merge, split, page ranges |
+| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) |
| PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) |
| Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML |
| Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT** (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) |
@@ -29,8 +29,8 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP, 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, DMG, … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files |
| LibreOffice + H2Orestart + Java | **optional download** (~420 MB, 1.3 GB on disk) | older and rare formats only: DOC, XLS, PPT, RTF and OpenDocument → PDF and to each other, output as DOCX/ODT/ODS/ODP; HWP/HWPX → DOCX/ODT; read-only formats (see below) → PDF and their kind's editable formats; FODT, OpenDocument templates (OTT, OTS, OTP) and XLT out; SVG → EMF/WMF/ODG, a PDF page → SVG |
| Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides |
-| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium) |
-| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG or a still frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, images → HEIC |
+| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it |
+| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, images → HEIC |
| Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR |
DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX
@@ -423,6 +423,49 @@ editable**: there are no text boxes to change, the PDF's layout is not rebuilt.
Password-protected PDFs fail with the same message as PDF → DOCX.
+## PDF compression
+
+"Compress PDF" (and any PDF → PDF preset with *Compress* set, also with a page range or one file per page) makes a
+PDF smaller while it stays a PDF, for e-mail attachments and upload limits. `Pdf/PdfCompressor.cs` does it with
+PDFsharp and Magick.NET; nothing has to be installed.
+
+| Level | Pictures scaled down to | JPEG quality |
+|---|---|---|
+| Smallest (`Screen`) | 72 dpi | 55 |
+| Balanced (`Ebook`, the preset's) | 150 dpi | 65 |
+| High quality (`Print`) | 300 dpi | 85 |
+
+- **Pictures**: `Pdf/PdfPictureUses.cs` follows the content streams of the pages, of the forms they draw and of
+ annotation appearances (`q`/`Q`/`cm`/`Do`) to find the largest size each picture is drawn at. A picture drawn at more
+ than 1.5 × the level's resolution is scaled down to it (as Ghostscript does); a picture whose drawing can't be
+ followed is taken as large as the page holds it, so it is never scaled down too far. Photos are saved as JPEG at the
+ level's quality, pictures with up to 256 colours (charts, screenshots, logos) losslessly (Flate with PNG
+ prediction). A JPEG that keeps its size is encoded again only when that saves at least 10 %. A picture is replaced
+ only when the result is smaller.
+- **Which pictures**: 8 bits per component, gray, RGB (also ICC-based and calibrated) and CMYK, stored as JPEG, Flate
+ (with or without PNG prediction), RunLength or uncompressed. CMYK JPEGs, 1-bit pictures and masks, indexed
+ (palette) pictures, spot colours, JPEG 2000, JBIG2, CCITT and pictures with a colour-key mask stay as they are.
+- **Transparency**: a soft mask is scaled with its picture and kept lossless, so transparent parts stay transparent.
+- **Duplicates**: a picture stored more than once with the same data (a logo embedded again on every page) is kept once.
+- **Everything else**: every stream is deflated at the highest level when that's smaller; page thumbnails and other
+ applications' private data (`PieceInfo`, such as Illustrator's editing data) are removed, as are unused objects.
+ When the preset doesn't keep metadata, the XMP metadata of pages and pictures goes too. The document's own XMP is
+ written afresh from its document information by PDFsharp, as for every PDF it saves.
+- **Unchanged**: text, fonts, vector drawings, links, bookmarks and form fields. The whole file is compressed as it
+ is; a page range or single pages are first copied into new PDFs, as without compression (which leaves bookmarks and
+ form fields behind), and so is a PDF locked only against changes.
+- **Never bigger**: when nothing gets smaller (a PDF of text only, one compressed before), the PDF is written as it was
+ and the toast (and `filee convert`) says that it couldn't be made smaller. Password-protected PDFs fail with the same
+ message as the other PDF conversions.
+- **Ghostscript** (optional, *Also try Ghostscript* in the preset): when it is installed, the PDF is also run through
+ `pdfwrite` with `-dPDFSETTINGS=/screen`, `/ebook` or `/printer` (the same 72, 150 and 300 dpi) and
+ `-dCompatibilityLevel=1.6`, and the smallest of the two results is kept; when Ghostscript fails, the built-in result
+ stands. It is off by default: Ghostscript rewrites the whole file (fonts are subset again, structure and form fields
+ may change), so it is the user's choice. It also writes object streams, which PDFsharp can't, and its JPEGs are
+ somewhat smaller at the Balanced level.
+- **Not done**: object streams (PDFsharp writes none), font subsetting or removal of unused glyphs, converting
+ colour to gray, flattening transparency or form fields, recompressing JBIG2/CCITT scans.
+
## Office Open XML variants
`Office/OoxmlConverter.cs` converts between the variants of a family (Word, Excel, PowerPoint) by copying the
@@ -801,8 +844,8 @@ Filee (with the GPL source offer), then an `engines.json` component and an optio
## FFmpeg
`Media/FfmpegConverter.cs` converts every video format to every video format, to animated GIF or APNG, to a still PNG
-frame, to every audio format (the sound track) and to SRT, ASS and WebVTT (the subtitles); every audio format to every
-audio format; and GIF, APNG and PNG to MP4, WEBM and MOV.
+frame or a folder of every frame, to every audio format (the sound track) and to SRT, ASS and WebVTT (the subtitles);
+every audio format to every audio format; and GIF, APNG and PNG to MP4, WEBM and MOV.
Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on MP3 files.
- The download is the gyan.dev **9.0.2 "full_build-shared"** Windows build (GPL-3.0, run as a separate program).
@@ -856,6 +899,29 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on
for the rest. The encoders are looked up with `ffmpeg -encoders` once per FFmpeg, so a copy the user picked without
x265 or AV1 fails with a clear message instead of an FFmpeg error. x265 and SVT-AV1 log only errors
(`-x265-params log-level=error`, `SVT_LOG=1`).
+- **Hardware encoding** (*Hardware acceleration* in the video card, `MediaOptions.HardwareEncoding`; off by default and
+ for older presets, so nobody's results change without asking). H.264 and HEVC encodes (not VP9, AV1 and the
+ MPEG-style codecs, not stream copies, not pictures → video) go to the graphics chip (`Media/HardwareVideo.cs`,
+ `Media/HardwareProbe.cs`):
+
+ | System | Tried in this order |
+ |---|---|
+ | Windows | `*_nvenc` (NVIDIA), `*_qsv` (Intel Quick Sync), `*_amf` (AMD) |
+ | macOS | `*_videotoolbox` with constant quality (Apple Silicon), then with a bitrate (Intel Macs reject `-q:v`) |
+ | Linux | `*_nvenc`, then `*_vaapi` on `/dev/dri/renderD128` (`-vaapi_device`, frames uploaded with `format=nv12,hwupload`) |
+
+ FFmpeg lists encoders the computer can't run (NVENC without an NVIDIA card), so only the ones `ffmpeg -encoders`
+ lists are tried, each first with a test encode with the real options (0.1 s of `testsrc` at 256 × 144 to the null
+ muxer, 15 s limit). The first that succeeds is used; results are kept per FFmpeg executable, encoder, rate control
+ and bit depth until Filee restarts, and written to the log. Quality: *High/Balanced/Small* become quantizer 19/24/29
+ for H.264 and 23/27/31 for HEVC (one above x264's and x265's CRF: hardware encoders spend bits less wisely) as NVENC
+ `-rc vbr -cq N -b:v 0 -preset p5`, Quick Sync `-global_quality N -preset medium` (ICQ), AMF `-rc cqp -qp_p N -qp_i N−2`
+ (`-quality balanced`) and VA-API `-rc_mode CQP -qp N`; VideoToolbox gets `-q:v` 70/60/50, or on Intel Macs
+ `-b:v` from 0.14/0.09/0.05 bits per pixel per frame (× 0.65 for HEVC). 8-bit video is NV12 (H.264 High, HEVC Main);
+ 10-bit and HDR sources stay P010 HEVC Main 10 with their colour tags when the encoder passes a 10-bit test encode,
+ otherwise x265 encodes them. HEVC keeps the `hvc1` tag in MP4/M4V/MOV. When a hardware encode fails anyway (a
+ picture too large for the chip, a driver error), the partial output is deleted and the file is encoded again with
+ x264/x265; that is logged, not shown as a failure.
- DSD (DSF, DFF) decodes at 2.8 MHz or more: lossless and PCM targets get 88.2 kHz, Vorbis and WMA 44.1 kHz, the
other lossy encoders their nearest supported rate.
- Subtitle extraction takes the first text subtitle track (SubRip, ASS/SSA, WebVTT, MP4 timed text, CEA-608, …).
@@ -867,12 +933,34 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on
FLAC. Portrait phone videos are turned upright. Odd sizes are evened out, frame rates that MPEG-2/MXF/MPEG-4 part 2
cannot store are converted, and channels WMA or AMR cannot hold are mixed down.
- Progress comes from `-progress` (position ÷ duration from ffprobe); the GIF palette pass reports per-frame stats of
- a discarded copy of its frames. Cancelling kills the process tree and deletes the partial output. There is no
- time limit, but FFmpeg is stopped when it reports nothing for 10 minutes. At most two conversions run at a time
- (FFmpeg's encoders use every core themselves).
+ a discarded copy of its frames. Cancelling kills the process tree, waits (up to 10 seconds) until it has exited and
+ deletes the partial output. There is no time limit, but FFmpeg is stopped when it reports nothing for 10 minutes.
+ At most two conversions run at a time (FFmpeg's encoders use every core themselves).
- Video → PNG writes one still frame (a tenth into the video, where the picture has usually faded in); the image
engines turn it into JPG, WEBP, PDF, …. The GIF edge costs more, so these routes never go through an animated GIF
of the whole video.
+- **Every frame** (*Save every frame of a video* on a PNG or JPG preset, `MediaOptions.SaveEveryFrame`; the presets
+ "Extract frames (PNG)" and "Extract frames (JPG)", on no donut): `Media/FrameSequenceWriter.cs` writes
+ `frame_000001.png`, `frame_000002.png`, … into one folder, named by the output rule with `_frames` added
+ (`clip_frames`, `clip_frames (2)`; skipped, or filled over like an extracted archive, by the conflict rule).
+ - Routes: the frame edges (`ConversionEdge.FrameSequence`, video → PNG and video → JPG) are taken only when
+ `Preset.SavesFramesOf` the file (the option is set, the target is PNG or JPG and the file is a video), and then as
+ the whole route (`RoutePlanner.Plan(from, to, frames: true)`). Without the option video → JPG stays the still frame
+ above, which the image engines turn, resize and so on; other files and targets ignore the option.
+ - *Every Nth frame* (1–1000) keeps frames 1, N + 1, 2N + 1, … (`select`, `-fps_mode vfr`). Otherwise every decoded
+ frame is written once (`-fps_mode passthrough`): a variable frame rate phone video gives as many pictures as it
+ has frames, not one per 1/30 s with the slow parts repeated. The pictures are numbered, not named by time.
+ - Only the first video stream is read (`-map 0:V:0`). Pixels are made square and portrait videos are turned
+ upright, as for the still frame; nothing else of the image options is applied (no resizing, rotating or
+ metadata). JPG uses the preset's quality (100 → `-q:v 2`, 90 → 5, 1 → 31). HDR video (PQ, HLG) is not
+ tone-mapped, so its frames look flat and grey; 10-bit video becomes 16-bit PNG.
+ - Before anything is written the pictures are estimated (duration × frame rate ÷ interval). More than 100,000
+ (`MediaOptions.MaxFrames`, about an hour at 30 fps), or more than the free space of the output drive (estimated at
+ 1.5 bytes a pixel for PNG, 3 for 10-bit video, 0.25 for JPG), fails with a reason in the user's language
+ (`ConversionRefusedException`); a file that doesn't say its length or frame rate is not checked. The editor shows
+ how many pictures a minute of 30 fps video gives at the chosen interval.
+ - The frames go into a hidden folder next to the output and are moved into place when FFmpeg has finished, so a
+ cancelled or failed run leaves no half-filled folder.
- Pictures → MP4/WEBM/MOV go through a full-size PNG from the image engines (HEIC → PNG → MOV): a 5-second still at
30 fps, one quality step above the preset (identical frames cost almost nothing; x264 `-tune stillimage`). The
PNG's ICC profile is read (`-flags2 +icc_profiles`), so primaries and transfer are tagged as the photo's (Display
diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs
index 74ac774f..ba48ba9c 100644
--- a/src/Filee.App/App.axaml.cs
+++ b/src/Filee.App/App.axaml.cs
@@ -23,6 +23,9 @@ public partial class App : Application
private IDisposable? _convertDebounce;
private bool _selectionPending;
+ /// Forwarded launches and empty selections can come in bursts: a few lines a minute are enough.
+ private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30));
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -39,6 +42,9 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}
+ /// Ends Filee as the tray's Quit does.
+ public static void Quit() => (Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown();
+
private void Start(IClassicDesktopStyleApplicationLifetime desktop)
{
// macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while
@@ -47,12 +53,20 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
Filee.Platform.MacOS.MacOSServicesProvider.Register(files =>
Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files]))));
var log = AppHost.Get>();
+ // Exceptions in UI event handlers end Filee; write them down first (they are not marked handled).
+ Dispatcher.UIThread.UnhandledException += (_, e) =>
+ log.LogCritical(e.Exception, "Unhandled exception on the UI thread");
+ var watchdog = new UiWatchdog(action => Dispatcher.UIThread.Post(action), AppHost.Get>());
+ watchdog.Start();
var store = AppHost.Get();
store.Load();
var loc = AppHost.Get();
loc.Attach(this);
ApplySettings(store.Settings);
+ // Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts
+ // every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile.
+ _ = AppHost.Get().CheckInBackgroundAsync();
// Global gestures → donut toolbar
var triggers = AppHost.Get();
@@ -71,6 +85,11 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection();
if (files.Count > 0 && !store.Settings.Paused)
radial.ShowForFiles(e.X, e.Y, files);
+ else if (files.Count == 0 && Throttle.TryEnter("empty-selection", out var held))
+ log.LogInformation("The selection shortcut found no selected files in {Window}{Elevation}{Held}",
+ platform.DescribeForegroundWindow() is { Length: > 0 } window ? window : "the foreground window",
+ Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator and can't read Explorer)" : "",
+ LogThrottle.HeldText(held));
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
@@ -109,13 +128,13 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
// Later launches (context menu, Send To, double-clicking the exe) forward their arguments here.
if (Program.Instance is { } instance)
- {
- instance.ArgumentsReceived += args => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args)));
- instance.StartListening();
- }
+ ListenForLaterLaunches(instance, log);
+ Program.InstanceAcquired += again => ListenForLaterLaunches(again, log);
desktop.Exit += (_, _) =>
{
+ watchdog.Dispose();
+ AppHost.Get().Dispose();
AppHost.Get().Dispose();
AppHost.Get().Dispose();
AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5));
@@ -123,6 +142,21 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop)
HandleCommandLine(Program.Options, firstLaunch: true);
WatchForUpdates(store);
+ // Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it):
+ // say so once, next to the tray, and on the Home page for as long as it lasts.
+ if (AppHost.Get().ShellCannotReachFilee)
+ DispatcherTimer.RunOnce(() => AppHost.Get().ShowElevationNotice(), TimeSpan.FromSeconds(2));
+ }
+
+ private void ListenForLaterLaunches(SingleInstance instance, ILogger log)
+ {
+ instance.ArgumentsReceived += args =>
+ {
+ if (Throttle.TryEnter("forwarded", out var held))
+ log.LogInformation("A later launch handed over {Count} argument(s){Held}", args.Length, LogThrottle.HeldText(held));
+ Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args), forwarded: true));
+ };
+ instance.StartListening();
}
///
@@ -198,8 +232,13 @@ private static void ApplySettings(AppSettings settings)
AppHost.Get().Apply(settings);
}
- private void HandleCommandLine(CommandLine options, bool firstLaunch = false)
+ private void HandleCommandLine(CommandLine options, bool firstLaunch = false, bool forwarded = false)
{
+ // Pairs with "A later launch handed over …": the request reached the UI thread too.
+ if (forwarded && Throttle.TryEnter("forwarded-handled", out var held))
+ AppHost.Get>().LogInformation("Handling a later launch: {Request}{Held}",
+ options.Quit ? "quit" : options.ConvertFiles.Count > 0 ? $"convert {options.ConvertFiles.Count} file(s)" : "show Filee",
+ LogThrottle.HeldText(held));
if (options.Quit)
{
// The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this).
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 6e8622ac..52c51462 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -30,6 +30,13 @@
"home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.",
"home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.",
"home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility.\nIt works as soon as you switch it on.",
+ "home.elevated": "Filee is running as administrator, so Explorer can't drop files on it or use its menu.\nThe shortcut can't read Explorer's selection either.\nRestart Filee without administrator rights to fix this.",
+ "home.elevated_restart": "Restart without administrator rights",
+ "home.elevated_restart_failed": "Filee couldn't restart itself.\nQuit Filee from the tray, then start it again.",
+ "elevation.notice_title": "Filee is running as administrator",
+ "elevation.notice_text": "Explorer can't drop files on Filee or use its menu while it runs this way.\nRestart it without administrator rights.",
+ "startup.elevated_other": "Another Filee is running as administrator.\nExplorer can't hand files to it.\nQuit Filee from the tray, then start it again.",
+ "startup.no_answer": "Filee is running but didn't answer.\nIf it runs as administrator, quit it from the tray and start it again.",
"home.recent": "Recent conversions",
"home.recent_empty": "Your converted files will show up here.",
"home.open_folder": "Open folder",
@@ -61,9 +68,15 @@
"toast.cancel": "Cancel",
"error.unsupported_source": "This file type isn't supported",
+ "format_request.request": "Request this format",
+ "format_request.notice_title": "Filee can't convert {0} files yet",
+ "format_request.notice_text": "Tell us which format you need.\nRequests decide what Filee supports next.\nEncrypted or copy-protected (DRM) files can't be converted, whatever the format.",
"error.no_route": "No engine can do this conversion",
"error.cannot_turn": "Rotate and flip work on images only",
+ "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"",
+ "error.not_enough_space": "Not enough free disk space for the pictures",
"error.conversion_failed": "The engine reported an error",
+ "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is",
"engine.reason.not_installed": "Not installed",
"engine.reason.error": "Error while checking",
@@ -71,7 +84,7 @@
"engine.reason.needs_package": "Needs {0}. Install it in Settings → Engines",
"engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, camera RAW and more.\nQuality, size and DPI follow your preset.",
"engine.pdfium.description": "Renders PDF pages to PNG, JPG and TIFF (also multi-page TIFF and GIF) at the preset's resolution.",
- "engine.pdfsharp.description": "Makes PDFs from images and merges, splits or cuts page ranges out of PDFs.",
+ "engine.pdfsharp.description": "Makes PDFs from images and merges, splits or cuts page ranges out of PDFs.\nAlso makes PDFs smaller by scaling down their pictures.",
"engine.rhwp.description": "Renders HWP/HWPX to PDF and converts HWP ↔ HWPX.\nIt also draws documents that reach PDF through HWPX (DOCX, XLSX, PPTX, e-books…).",
"engine.unhwp.description": "Extracts text, Markdown and HTML from HWP and HWPX.",
"engine.markdown.description": "Markdown → HTML and plain text.",
@@ -133,6 +146,7 @@
"preset.flip_vertical.short": "Vertical",
"preset.merge_pdf": "Merge PDF",
"preset.split_pdf": "Split PDF",
+ "preset.compress_pdf": "Compress PDF",
"preset.long_image": "Long image",
"preset.pdf_slides": "PPTX (slides)",
"preset.extract": "Extract",
@@ -140,6 +154,8 @@
"preset.extract_subtitles": "Extract subtitles",
"preset.trace_svg": "Trace to SVG",
"preset.trace_line_art": "Line art to SVG",
+ "preset.extract_frames_png": "Extract frames (PNG)",
+ "preset.extract_frames_jpg": "Extract frames (JPG)",
"format.folder": "Folder",
"format.category.Image": "Image",
@@ -373,6 +389,10 @@
"presets.webp_lossless": "Lossless WEBP",
"presets.multipage_tiff": "Put every file into one multi-page TIFF",
"presets.multipage_tiff_hint": "Each image and each PDF or TIFF page becomes a page, in order.\nOne PDF is enough for a multi-page TIFF.",
+ "presets.save_every_frame": "Save every frame of a video",
+ "presets.save_every_frame_hint": "A video becomes a folder with a picture of each frame instead of one still frame.\nA long video gives many files.",
+ "presets.frame_interval": "Every Nth frame",
+ "presets.frame_estimate": "One minute of 30 fps video gives {0} pictures, at most {1} per video.",
"presets.ico_sizes": "Icon sizes",
"presets.group.pdf": "PDF",
"presets.merge": "Merge all files into one PDF",
@@ -385,6 +405,15 @@
"presets.page_range": "Pages",
"presets.page_range_hint": "All pages, or e.g. 1-3, 5",
"presets.split": "One file per page",
+ "presets.pdf_compression": "Compress",
+ "presets.pdf_compression.None": "Don't compress",
+ "presets.pdf_compression.Screen": "Smallest (pictures at 72 dpi)",
+ "presets.pdf_compression.Ebook": "Balanced (pictures at 150 dpi)",
+ "presets.pdf_compression.Print": "High quality (pictures at 300 dpi)",
+ "presets.pdf_compression_hint": "Pictures are scaled down and saved as JPEG.\nText, fonts and drawings stay as they are.\nIf the result isn't smaller, the original is kept.",
+ "presets.pdf_ghostscript": "Also try Ghostscript (when installed)",
+ "presets.pdf_ghostscript_hint": "Often smaller, but it rewrites the whole file.\nThe smallest result is kept.",
+ "presets.pdf_keep_metadata": "Keep page and picture metadata (XMP)",
"presets.combine_pages_hint": "Puts all pages one under another in a single image.\nVery long results are scaled to the format's limit (JPG 65,500 px, WebP 16,383 px).",
"presets.combine_pages": "Stack the pages into one tall image (when a PDF is converted)",
"presets.trim_page_margins": "Cut the blank space above and below each page",
@@ -422,6 +451,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265 (smaller)",
"presets.video_codec.av1": "AV1 (smallest, slow)",
+ "presets.hardware_encoding": "Use hardware acceleration (graphics chip)",
+ "presets.hardware_encoding_hint": "Encodes H.264 and HEVC on the graphics chip.\nMuch faster and uses less power.\nSlightly lower quality at the same file size.\nWithout a usable chip, the video is encoded as usual.",
"presets.audio_codec": "Audio codec",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC (lossless)",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 306cafc5..1c53de4d 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -30,6 +30,13 @@
"home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.",
"home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요.\n켜는 즉시 동작해요.",
+ "home.elevated": "Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n단축키도 탐색기에서 선택한 파일을 읽지 못해요.\n관리자 권한 없이 다시 시작해 주세요.",
+ "home.elevated_restart": "관리자 권한 없이 다시 시작",
+ "home.elevated_restart_failed": "Filee를 다시 시작하지 못했어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.",
+ "elevation.notice_title": "Filee가 관리자 권한으로 실행 중이에요",
+ "elevation.notice_text": "이 상태에서는 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n관리자 권한 없이 다시 시작해 주세요.",
+ "startup.elevated_other": "다른 Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기가 파일을 넘겨줄 수 없어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.",
+ "startup.no_answer": "Filee가 실행 중이지만 응답하지 않아요.\n관리자 권한으로 실행 중이라면 트레이에서 종료한 뒤 다시 실행해 주세요.",
"home.recent": "최근 변환",
"home.recent_empty": "변환한 파일이 여기에 표시됩니다.",
"home.open_folder": "폴더 열기",
@@ -61,9 +68,15 @@
"toast.cancel": "취소",
"error.unsupported_source": "지원하지 않는 파일 형식이에요",
+ "format_request.request": "이 형식 요청하기",
+ "format_request.notice_title": "아직 {0} 파일은 변환할 수 없어요",
+ "format_request.notice_text": "필요한 형식을 알려주세요.\n요청을 보고 다음에 지원할 형식을 정해요.\n암호화되거나 DRM이 걸린 파일은 변환할 수 없어요.",
"error.no_route": "이 변환을 할 수 있는 엔진이 없어요",
"error.cannot_turn": "회전·반전은 이미지에만 할 수 있어요",
+ "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요",
+ "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요",
"error.conversion_failed": "엔진에서 오류가 발생했어요",
+ "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요",
"engine.reason.not_installed": "설치되지 않음",
"engine.reason.error": "확인 중 오류",
@@ -71,7 +84,7 @@
"engine.reason.needs_package": "{0} 필요 (설정 → 변환 엔진에서 설치)",
"engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, 카메라 RAW 등 이미지를 변환해요.\n품질·크기·DPI는 프리셋대로 맞춰요.",
"engine.pdfium.description": "PDF 페이지를 PNG, JPG, TIFF(여러 페이지 TIFF·GIF 포함)로 프리셋 해상도에 맞춰 그려요.",
- "engine.pdfsharp.description": "이미지로 PDF를 만들고, PDF를 합치거나 나누거나 원하는 페이지만 잘라내요.",
+ "engine.pdfsharp.description": "이미지로 PDF를 만들고, PDF를 합치거나 나누거나 원하는 페이지만 잘라내요.\nPDF 속 이미지를 줄여 파일을 작게 만들기도 해요.",
"engine.rhwp.description": "HWP/HWPX를 PDF로 그리고, HWP ↔ HWPX를 바꿔요.\nHWPX를 거쳐 PDF가 되는 문서(DOCX, XLSX, PPTX, 전자책 등)도 그려요.",
"engine.unhwp.description": "HWP와 HWPX에서 텍스트, 마크다운, HTML을 뽑아내요.",
"engine.markdown.description": "마크다운 → HTML, 텍스트.",
@@ -133,6 +146,7 @@
"preset.flip_vertical.short": "상하",
"preset.merge_pdf": "PDF 병합",
"preset.split_pdf": "PDF 분할",
+ "preset.compress_pdf": "PDF 압축",
"preset.long_image": "긴 이미지",
"preset.pdf_slides": "PPTX (슬라이드)",
"preset.extract": "압축 풀기",
@@ -140,6 +154,8 @@
"preset.extract_subtitles": "자막 추출",
"preset.trace_svg": "벡터화",
"preset.trace_line_art": "흑백 선화",
+ "preset.extract_frames_png": "프레임 추출 (PNG)",
+ "preset.extract_frames_jpg": "프레임 추출 (JPG)",
"format.folder": "폴더",
"format.category.Image": "이미지",
@@ -373,6 +389,10 @@
"presets.webp_lossless": "무손실 WEBP",
"presets.multipage_tiff": "모든 파일을 여러 페이지짜리 TIFF 하나로 합치기",
"presets.multipage_tiff_hint": "이미지 한 장, PDF·TIFF 한 페이지가 순서대로 한 페이지씩 들어가요.\nPDF 하나만 넣어도 여러 페이지 TIFF가 돼요.",
+ "presets.save_every_frame": "동영상의 모든 프레임 저장하기",
+ "presets.save_every_frame_hint": "동영상에서 정지 이미지 한 장 대신 프레임마다 이미지를 만들어 폴더 하나에 담아요.\n긴 동영상은 파일이 아주 많이 생겨요.",
+ "presets.frame_interval": "N번째 프레임마다",
+ "presets.frame_estimate": "30fps 동영상 1분이면 이미지 {0}장이 생겨요. 동영상 하나에 최대 {1}장까지예요.",
"presets.ico_sizes": "아이콘 크기",
"presets.group.pdf": "PDF",
"presets.merge": "모든 파일을 하나의 PDF로 합치기",
@@ -385,6 +405,15 @@
"presets.page_range": "페이지",
"presets.page_range_hint": "전체, 또는 예: 1-3, 5",
"presets.split": "페이지마다 파일 하나",
+ "presets.pdf_compression": "압축",
+ "presets.pdf_compression.None": "압축 안 함",
+ "presets.pdf_compression.Screen": "가장 작게 (이미지 72 dpi)",
+ "presets.pdf_compression.Ebook": "균형 (이미지 150 dpi)",
+ "presets.pdf_compression.Print": "고화질 (이미지 300 dpi)",
+ "presets.pdf_compression_hint": "이미지를 줄이고 JPEG로 저장해요.\n글자, 글꼴, 도형은 그대로예요.\n결과가 더 작지 않으면 원본을 그대로 둬요.",
+ "presets.pdf_ghostscript": "Ghostscript로도 해 보기 (설치된 경우)",
+ "presets.pdf_ghostscript_hint": "더 작아질 때가 많지만 파일 전체를 다시 써요.\n가장 작은 결과를 남겨요.",
+ "presets.pdf_keep_metadata": "페이지·이미지 메타데이터(XMP) 유지",
"presets.combine_pages_hint": "모든 페이지를 위아래로 이어 이미지 한 장으로 만들어요.\n너무 길면 형식의 최대 높이에 맞게 줄여요(JPG 65,500px, WebP 16,383px).",
"presets.combine_pages": "모든 페이지를 세로로 이어 한 장으로 (PDF를 변환할 때)",
"presets.trim_page_margins": "페이지마다 위아래 빈 여백 잘라내기",
@@ -422,6 +451,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265 (더 작게)",
"presets.video_codec.av1": "AV1 (가장 작게, 느림)",
+ "presets.hardware_encoding": "하드웨어 가속 사용 (그래픽 칩)",
+ "presets.hardware_encoding_hint": "H.264와 HEVC를 그래픽 칩으로 인코딩해요.\n훨씬 빠르고 전력도 덜 써요.\n같은 용량이면 화질이 조금 낮아요.\n쓸 수 있는 칩이 없으면 평소처럼 인코딩해요.",
"presets.audio_codec": "오디오 코덱",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC (무손실)",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 1a98c447..abb0b93a 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -30,6 +30,13 @@
"home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。",
"home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。\n打开后立即生效。",
+ "home.elevated": "Filee 正以管理员身份运行。\n因此无法从资源管理器拖放文件,也无法使用右键菜单。\n快捷键也无法读取资源管理器中选中的文件。\n请以非管理员身份重新启动 Filee。",
+ "home.elevated_restart": "以非管理员身份重新启动",
+ "home.elevated_restart_failed": "Filee 无法自行重新启动。\n请从托盘退出 Filee,然后再次启动。",
+ "elevation.notice_title": "Filee 正以管理员身份运行",
+ "elevation.notice_text": "此时无法从资源管理器拖放文件,也无法使用右键菜单。\n请以非管理员身份重新启动。",
+ "startup.elevated_other": "另一个 Filee 正以管理员身份运行。\n资源管理器无法把文件交给它。\n请从托盘退出 Filee,然后再次启动。",
+ "startup.no_answer": "Filee 正在运行,但没有响应。\n如果它以管理员身份运行,请从托盘退出,然后再次启动。",
"home.recent": "最近的转换",
"home.recent_empty": "转换后的文件会显示在这里。",
"home.open_folder": "打开文件夹",
@@ -61,9 +68,15 @@
"toast.cancel": "取消",
"error.unsupported_source": "不支持此文件类型",
+ "format_request.request": "请求支持此格式",
+ "format_request.notice_title": "Filee 暂时无法转换 {0} 文件",
+ "format_request.notice_text": "告诉我们你需要的格式。\n我们会根据请求决定接下来支持哪些格式。\n加密或有复制保护 (DRM) 的文件,无论什么格式都无法转换。",
"error.no_route": "没有引擎可以完成此转换",
"error.cannot_turn": "只能旋转和翻转图片",
+ "error.too_many_frames": "帧数太多,请调大“每 N 帧”",
+ "error.not_enough_space": "磁盘空间不足,无法保存这些图片",
"error.conversion_failed": "转换引擎出错",
+ "notice.pdf_not_smaller": "无法再缩小,已原样复制",
"engine.reason.not_installed": "未安装",
"engine.reason.error": "检查时出错",
@@ -71,7 +84,7 @@
"engine.reason.needs_package": "需要 {0},请在“设置 → 转换引擎”中安装",
"engine.magick.description": "JPG、PNG、WEBP、AVIF、HEIC、TIFF、GIF、APNG、PSD、相机 RAW 等图片。\n质量、尺寸和 DPI 按预设处理。",
"engine.pdfium.description": "按预设分辨率将 PDF 页面渲染为 PNG、JPG、TIFF(也支持多页 TIFF 和 GIF)。",
- "engine.pdfsharp.description": "用图片生成 PDF,合并、拆分 PDF 或截取指定页码。",
+ "engine.pdfsharp.description": "用图片生成 PDF,合并、拆分 PDF 或截取指定页码。\n还能缩小 PDF 中的图片来减小文件。",
"engine.rhwp.description": "将 HWP/HWPX 渲染为 PDF,并在 HWP ↔ HWPX 之间转换。\n经 HWPX 转为 PDF 的文档(DOCX、XLSX、PPTX、电子书等)也由它渲染。",
"engine.unhwp.description": "从 HWP 和 HWPX 中提取文本、Markdown 和 HTML。",
"engine.markdown.description": "Markdown → HTML 和纯文本。",
@@ -133,6 +146,7 @@
"preset.flip_vertical.short": "垂直",
"preset.merge_pdf": "合并 PDF",
"preset.split_pdf": "拆分 PDF",
+ "preset.compress_pdf": "压缩 PDF",
"preset.long_image": "长图",
"preset.pdf_slides": "PPTX(幻灯片)",
"preset.extract": "解压",
@@ -140,6 +154,8 @@
"preset.extract_subtitles": "提取字幕",
"preset.trace_svg": "描摹",
"preset.trace_line_art": "黑白描摹",
+ "preset.extract_frames_png": "提取帧 (PNG)",
+ "preset.extract_frames_jpg": "提取帧 (JPG)",
"format.folder": "文件夹",
"format.category.Image": "图片",
@@ -373,6 +389,10 @@
"presets.webp_lossless": "无损 WEBP",
"presets.multipage_tiff": "将所有文件合并为一个多页 TIFF",
"presets.multipage_tiff_hint": "每张图片以及 PDF、TIFF 的每一页按顺序各成一页。\n只放一个 PDF 也能得到多页 TIFF。",
+ "presets.save_every_frame": "保存视频的每一帧",
+ "presets.save_every_frame_hint": "视频的每一帧各保存为一张图片,放在一个文件夹中,而不是只保存一张静态图片。\n长视频会生成大量文件。",
+ "presets.frame_interval": "每 N 帧",
+ "presets.frame_estimate": "30 fps 的视频每分钟生成 {0} 张图片,每个视频最多 {1} 张。",
"presets.ico_sizes": "图标尺寸",
"presets.group.pdf": "PDF",
"presets.merge": "将所有文件合并为一个 PDF",
@@ -385,6 +405,15 @@
"presets.page_range": "页面",
"presets.page_range_hint": "全部,或如 1-3, 5",
"presets.split": "每页一个文件",
+ "presets.pdf_compression": "压缩",
+ "presets.pdf_compression.None": "不压缩",
+ "presets.pdf_compression.Screen": "最小(图片 72 dpi)",
+ "presets.pdf_compression.Ebook": "均衡(图片 150 dpi)",
+ "presets.pdf_compression.Print": "高质量(图片 300 dpi)",
+ "presets.pdf_compression_hint": "图片会缩小并存为 JPEG。\n文字、字体和图形保持不变。\n结果没有变小时保留原文件。",
+ "presets.pdf_ghostscript": "也试试 Ghostscript(已安装时)",
+ "presets.pdf_ghostscript_hint": "通常更小,但会重写整个文件。\n保留最小的结果。",
+ "presets.pdf_keep_metadata": "保留页面和图片元数据 (XMP)",
"presets.combine_pages_hint": "把所有页面上下相接,生成一张图片。\n过长时会缩小到该格式的最大高度(JPG 65,500 像素,WebP 16,383 像素)。",
"presets.combine_pages": "把所有页面上下拼成一张长图(转换 PDF 时)",
"presets.trim_page_margins": "裁掉每页上下的空白",
@@ -422,6 +451,8 @@
"presets.video_codec.vp9": "VP9",
"presets.video_codec.hevc": "HEVC / H.265(更小)",
"presets.video_codec.av1": "AV1(最小,较慢)",
+ "presets.hardware_encoding": "使用硬件加速(显卡)",
+ "presets.hardware_encoding_hint": "用显卡编码 H.264 和 HEVC。\n速度快得多,也更省电。\n相同文件大小时画质略低。\n没有可用的显卡时照常编码。",
"presets.audio_codec": "音频编码",
"presets.audio_codec.aac": "AAC",
"presets.audio_codec.alac": "ALAC(无损)",
diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs
index 45d9e23c..bcd7fa63 100644
--- a/src/Filee.App/Program.cs
+++ b/src/Filee.App/Program.cs
@@ -1,6 +1,8 @@
// Entry point. Order matters:
+// 0. The log and the handlers for unhandled exceptions, so even a start that fails leaves a line; then one line
+// describing this launch (version, path, rights, OS).
// 1. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments
-// to the running instance and exits.
+// to the running instance and exits. When it can't (the running Filee is elevated, or hung) it says so.
// 2. Installer commands (--quit, --uninstall-cleanup) run without any window.
// 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit).
@@ -9,6 +11,10 @@
using Avalonia.Controls;
using Avalonia.Media;
using Filee.App.Services;
+using Filee.Core.Platform;
+using Filee.Core.Settings;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Filee.App;
@@ -26,20 +32,67 @@ internal static class Program
/// How long a later launch waits for a Filee that is still starting to take its arguments.
private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(30);
+ /// Integrity levels of this process and Explorer, read at start-up (Unknown off Windows).
+ public static ElevationState Elevation { get; private set; } = ElevationState.Unknown;
+
+ /// Program's own log, set up before anything else so even a failed start leaves a line.
+ internal static ILogger Log { get; private set; } = NullLogger.Instance;
+
+ /// Raised on the UI thread when the single-instance lock was taken again after a failed restart.
+ public static event Action? InstanceAcquired;
+
[STAThread]
public static int Main(string[] args)
{
Options = CommandLine.Parse(args);
- Instance = SingleInstance.Acquire();
- if (Options.Quit || Options.UninstallCleanup)
- return RunInstallerCommand(Instance);
- if (!Instance.IsFirst)
+ Log = StartupDiagnostics.CreateEarlyLogger(Path.Combine(UserDataStore.DefaultDirectory, "logs"));
+ StartupDiagnostics.LogUnhandledExceptions(Log);
+ Elevation = Filee.Platform.Windows.Elevation.Probe();
+ var startup = StartupDiagnostics.Describe(UpdateService.CurrentVersion, Environment.ProcessPath,
+ StartupDiagnostics.InstallKind(UpdateService.IsInstalled, UpdateService.IsReleaseBuild), Elevation,
+ StartupDiagnostics.OperatingSystemText(), args.Length);
+ var installerCommand = Options.Quit || Options.UninstallCleanup;
+
+ SingleInstance instance;
+ try
{
- Instance.ForwardToFirst(args, ForwardTimeout);
- Instance.Dispose();
- return 0;
+ instance = SingleInstance.Acquire();
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ // The lock belongs to a Filee running as administrator: this process may not even open it. Before this
+ // was caught, Filee ended here without a word (Explorer's menu showed a busy cursor, then nothing).
+ Log.LogWarning("{Startup}", startup);
+ Log.LogWarning(ex, "Another Filee is running with administrator rights; this launch can't reach it");
+ if (!installerCommand)
+ TellUserOnce("startup.elevated_other");
+ return 1;
+ }
+ Instance = instance;
+ if (installerCommand)
+ {
+ Log.LogInformation("{Startup}", startup);
+ return RunInstallerCommand(instance);
+ }
+ if (!instance.IsFirst)
+ {
+ var result = instance.Forward(args, ForwardTimeout);
+ if (result != ForwardResult.Delivered)
+ {
+ // Logged only when it failed: Explorer starts one Filee per selected file.
+ Log.LogWarning("{Startup}", startup);
+ Log.LogWarning("Could not hand {Count} argument(s) to the running Filee: {Result}", args.Length, result);
+ TellUserOnce(result == ForwardResult.AccessDenied ? "startup.elevated_other" : "startup.no_answer");
+ }
+ ReleaseInstance();
+ return result == ForwardResult.Delivered ? 0 : 1;
}
+ Log.LogInformation("{Startup}", startup);
+ if (Elevation.ShellCannotReachFilee)
+ Log.LogWarning("Filee runs as administrator ({Elevation}) while Explorer doesn't: Explorer can't drop files on it, " +
+ "use its menu or have its selection read", Elevation);
+
try
{
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
@@ -55,7 +108,66 @@ public static int Main(string[] args)
}
finally
{
- Instance.Dispose();
+ ReleaseInstance();
+ }
+ }
+
+ /// Lets go of the single-instance lock (at exit, or before restarting without administrator rights).
+ public static void ReleaseInstance()
+ {
+ Instance?.Dispose();
+ Instance = null;
+ }
+
+ /// Takes the lock back after a restart that didn't happen, so later launches find this Filee again.
+ public static void ReacquireInstance()
+ {
+ if (Instance is not null)
+ return;
+ try
+ {
+ var instance = SingleInstance.Acquire();
+ if (!instance.IsFirst)
+ {
+ // Another Filee started meanwhile (perhaps the restart did happen after all): it is the running one.
+ Log.LogWarning("Another Filee took the single-instance lock during the restart");
+ instance.Dispose();
+ return;
+ }
+ Instance = instance;
+ InstanceAcquired?.Invoke(instance);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Log.LogWarning(ex, "Could not take the single-instance lock back");
+ }
+ }
+
+ ///
+ /// Shows a launch problem in a system message box (Filee's own windows aren't available in this process). Explorer
+ /// starts one Filee per selected file: a lock shared between them lets only the first one show it.
+ ///
+ private static void TellUserOnce(string key)
+ {
+ if (!OperatingSystem.IsWindows())
+ return;
+ try
+ {
+ using var shown = new Mutex(initiallyOwned: true, @"Local\" + SingleInstance.DefaultName + "-notice", out var createdNew);
+ if (!createdNew)
+ return;
+ try
+ {
+ Filee.Platform.Windows.Elevation.ShowWarning(LocalizationService.EarlyText(key));
+ }
+ finally
+ {
+ shown.ReleaseMutex();
+ }
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException)
+ {
+ Log.LogWarning(ex, "Could not show the launch problem");
}
}
diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs
index 5b03168f..ea18c16a 100644
--- a/src/Filee.App/Services/AppHost.cs
+++ b/src/Filee.App/Services/AppHost.cs
@@ -44,10 +44,11 @@ public static IServiceProvider Build(string? dataDirectory = null)
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Conversion engines
services.AddSingleton(_ => new EngineEnvironment(dataDir));
- services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService()));
+ services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService(), sp.GetRequiredService()));
services.AddSingleton(sp => new ConverterCatalog(sp.GetRequiredService>()));
services.AddSingleton(sp => new JobQueue(
sp.GetRequiredService(),
@@ -65,8 +66,8 @@ public static IServiceProvider Build(string? dataDirectory = null)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
_services = services.BuildServiceProvider();
return _services;
diff --git a/src/Filee.App/Services/ElevationService.cs b/src/Filee.App/Services/ElevationService.cs
new file mode 100644
index 00000000..33291e8c
--- /dev/null
+++ b/src/Filee.App/Services/ElevationService.cs
@@ -0,0 +1,81 @@
+// Filee running as administrator while Explorer doesn't (see ElevationState): the Home page shows why gestures, drops
+// and the Explorer menu do nothing, and offers to restart Filee with Explorer's rights. The restart lets go of the
+// single-instance lock first, so the new Filee becomes the running one instead of handing its arguments to this one
+// (which it couldn't, being refused by an elevated process anyway), then quits this Filee.
+
+using CommunityToolkit.Mvvm.ComponentModel;
+using Filee.Core.Platform;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.Services;
+
+/// Elevation state of this run and the restart without administrator rights.
+public sealed partial class ElevationService : ObservableObject
+{
+ private readonly ILogger _log;
+ private readonly Func _relaunch;
+ private readonly Action _releaseInstance;
+ private readonly Action _reacquireInstance;
+ private readonly Action _shutdown;
+
+ /// The app's service: the state Program read at start-up, the real relaunch and shutdown.
+ public ElevationService(ILogger log) : this(log, Program.Elevation, RelaunchThroughShell,
+ Program.ReleaseInstance, Program.ReacquireInstance, App.Quit)
+ {
+ }
+
+ /// Starts the executable with Explorer's rights; returns null when started, else why not.
+ /// Lets go of the single-instance lock.
+ /// Takes the lock back when the restart failed.
+ /// Ends this Filee.
+ internal ElevationService(ILogger log, ElevationState state, Func relaunch,
+ Action releaseInstance, Action reacquireInstance, Action shutdown)
+ {
+ _log = log;
+ State = state;
+ _relaunch = relaunch;
+ _releaseInstance = releaseInstance;
+ _reacquireInstance = reacquireInstance;
+ _shutdown = shutdown;
+ }
+
+ public ElevationState State { get; }
+
+ /// Explorer can't drop files on Filee, use its menu, or have its selection read: show the warning.
+ public bool ShellCannotReachFilee => State.ShellCannotReachFilee;
+
+ /// The last restart attempt failed (the Home page says so).
+ [ObservableProperty] private bool _restartFailed;
+
+ ///
+ /// Starts Filee again through Explorer and quits this one. Returns false (and sets )
+ /// when Explorer couldn't start it; this Filee then keeps running as before.
+ ///
+ public bool RestartWithoutAdministratorRights(string? executable = null)
+ {
+ executable ??= Environment.ProcessPath;
+ if (string.IsNullOrEmpty(executable))
+ {
+ _log.LogWarning("Restart without administrator rights: the path of Filee.exe is unknown");
+ RestartFailed = true;
+ return false;
+ }
+
+ _log.LogInformation("Restarting without administrator rights through Explorer");
+ _releaseInstance();
+ var error = _relaunch(executable);
+ if (error is null)
+ {
+ RestartFailed = false;
+ _shutdown();
+ return true;
+ }
+ _log.LogWarning("Restart without administrator rights failed: {Error}", error);
+ _reacquireInstance();
+ RestartFailed = true;
+ return false;
+ }
+
+ private static string? RelaunchThroughShell(string executable) =>
+ Filee.Platform.Windows.Elevation.RelaunchThroughShell(executable, "", out var error) ? null : error ?? "unknown error";
+}
diff --git a/src/Filee.App/Services/FileLogger.cs b/src/Filee.App/Services/FileLogger.cs
index 4de17498..6a26a98e 100644
--- a/src/Filee.App/Services/FileLogger.cs
+++ b/src/Filee.App/Services/FileLogger.cs
@@ -6,8 +6,9 @@ namespace Filee.App.Services;
public sealed class FileLoggerProvider : ILoggerProvider
{
+ // Shared by every provider: Program's early logger and AppHost's write to the same file.
+ private static readonly Lock WriteLock = new();
private readonly string _directory;
- private readonly object _lock = new();
public FileLoggerProvider(string directory)
{
@@ -16,7 +17,7 @@ public FileLoggerProvider(string directory)
foreach (var old in Directory.EnumerateFiles(directory, "filee-*.log")
.Where(f => File.GetLastWriteTime(f) < DateTime.Now.AddDays(-7)))
{
- try { File.Delete(old); } catch (IOException) { }
+ try { File.Delete(old); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
@@ -26,13 +27,13 @@ public void Dispose() { }
private void Write(string line)
{
- lock (_lock)
+ lock (WriteLock)
{
try
{
File.AppendAllText(Path.Combine(_directory, $"filee-{DateTime.Now:yyyyMMdd}.log"), line + Environment.NewLine);
}
- catch (IOException) { }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
diff --git a/src/Filee.App/Services/FormatRequest.cs b/src/Filee.App/Services/FormatRequest.cs
new file mode 100644
index 00000000..17a8b0c8
--- /dev/null
+++ b/src/Filee.App/Services/FormatRequest.cs
@@ -0,0 +1,37 @@
+// "Request this format": a file type Filee can't read becomes a GitHub issue (the "Format request" form) with its
+// extension filled in. Only the extension goes in the link, never a file name; the user reads it and submits it.
+
+using Filee.Core.Formats;
+
+namespace Filee.App.Services;
+
+public static class FormatRequest
+{
+ /// Extensions shown in one request at most; the rest of a big mixed drop is left out.
+ private const int MaxExtensions = 3;
+
+ ///
+ /// The extensions (".ncm") of that Filee doesn't know, lower-case and each once. Folders
+ /// and files without an extension have nothing to request.
+ ///
+ public static IReadOnlyList UnknownExtensions(IEnumerable paths) =>
+ paths.Where(p => FormatRegistry.Detect(p) is null && !Directory.Exists(p))
+ .Select(p => FormatRegistry.ExtensionOf(p).ToLowerInvariant())
+ .Where(e => e.Length > 0)
+ .Distinct()
+ .Take(MaxExtensions)
+ .Select(e => "." + e)
+ .ToList();
+
+ /// ".ncm" or ".ncm, .qmc".
+ public static string Label(IReadOnlyList extensions) => string.Join(", ", extensions);
+
+ /// The new-issue page of the "Format request" form, its title and extension field filled in.
+ public static Uri Url(IReadOnlyList extensions)
+ {
+ var label = Label(extensions);
+ return new Uri(UpdateService.RepositoryUrl.TrimEnd('/') + "/issues/new?template=format_request.yml" +
+ "&title=" + Uri.EscapeDataString("Format request: " + label) +
+ "&extension=" + Uri.EscapeDataString(label));
+ }
+}
diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs
index f09685a0..9d1dbf83 100644
--- a/src/Filee.App/Services/LocalizationService.cs
+++ b/src/Filee.App/Services/LocalizationService.cs
@@ -100,13 +100,35 @@ public static string Resolve(string setting)
return "en";
}
- /// Reads a translation file. Missing files yield an empty dictionary (English is used instead).
- internal static Dictionary LoadStrings(string code)
+ ///
+ /// Reads a translation file. Missing files yield an empty dictionary (English is used instead).
+ /// reads assets before Avalonia is set up (a message shown by Program).
+ ///
+ internal static Dictionary LoadStrings(string code, IAssetLoader? loader = null)
{
var uri = new Uri($"avares://Filee/Assets/i18n/{code}.json");
- if (!AssetLoader.Exists(uri))
+ if (!(loader?.Exists(uri) ?? AssetLoader.Exists(uri)))
return [];
- using var stream = AssetLoader.Open(uri);
+ using var stream = loader?.Open(uri) ?? AssetLoader.Open(uri);
return JsonSerializer.Deserialize(stream, AppJsonContext.Default.DictionaryStringString) ?? [];
}
+
+ ///
+ /// One string in the system's language, before Avalonia (and the settings) are loaded: for the messages a launch
+ /// shows when it can't reach the running Filee. Falls back to English, then to the key.
+ ///
+ internal static string EarlyText(string key)
+ {
+ try
+ {
+ var loader = new StandardAssetLoader(typeof(LocalizationService).Assembly);
+ var code = Resolve("auto");
+ if (LoadStrings(code, loader).TryGetValue(key, out var text) || LoadStrings("en", loader).TryGetValue(key, out text))
+ return text;
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException or ArgumentException)
+ {
+ }
+ return key;
+ }
}
diff --git a/src/Filee.App/Services/LogThrottle.cs b/src/Filee.App/Services/LogThrottle.cs
new file mode 100644
index 00000000..d539fecb
--- /dev/null
+++ b/src/Filee.App/Services/LogThrottle.cs
@@ -0,0 +1,37 @@
+// Keeps diagnostics that can come in bursts to a few log lines: Explorer starts one Filee per selected file, and a
+// gesture or shortcut can repeat many times a minute. The first event of a kind is logged, then at most one per
+// interval, saying how many were held back in between.
+
+namespace Filee.App.Services;
+
+/// Rate limit for log lines, per kind of event. Thread-safe.
+internal sealed class LogThrottle(TimeSpan interval, TimeProvider? time = null)
+{
+ private readonly TimeProvider _time = time ?? TimeProvider.System;
+ private readonly Dictionary _kinds = new(StringComparer.Ordinal);
+ private readonly Lock _lock = new();
+
+ ///
+ /// True when an event of kind may be logged now. is how many of
+ /// that kind were held back since the last one logged (0 when it returns false).
+ ///
+ public bool TryEnter(string kind, out int held)
+ {
+ lock (_lock)
+ {
+ var now = _time.GetUtcNow();
+ if (_kinds.TryGetValue(kind, out var entry) && now - entry.Last < interval)
+ {
+ _kinds[kind] = (entry.Last, entry.Held + 1);
+ held = 0;
+ return false;
+ }
+ held = entry.Held;
+ _kinds[kind] = (now, 0);
+ return true;
+ }
+ }
+
+ /// " (3 more since the last one)", or "" when none were held back.
+ public static string HeldText(int held) => held > 0 ? $" ({held} more since the last one)" : "";
+}
diff --git a/src/Filee.App/Services/MemoryTrimmer.cs b/src/Filee.App/Services/MemoryTrimmer.cs
new file mode 100644
index 00000000..85f94a38
--- /dev/null
+++ b/src/Filee.App/Services/MemoryTrimmer.cs
@@ -0,0 +1,115 @@
+// Gives memory back while Filee waits in the tray (issue #119): a while after the last conversion finished, and after
+// the main window was released. Large images grow the managed heap and the engines' caches, and the garbage collector
+// keeps that memory committed for the next allocation, so Filee stayed hundreds of MB above its idle size.
+
+using System.Diagnostics;
+using Filee.Core.Conversion;
+using Filee.Engines.Infrastructure;
+using Filee.Platform.Windows;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.Services;
+
+public sealed class MemoryTrimmer : IDisposable
+{
+ /// How long no conversion may have run before memory is trimmed.
+ public static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(30);
+
+ private readonly Func _busy;
+ private readonly Action _trim;
+ private readonly TimeSpan _delay;
+ private readonly Timer _timer;
+ private readonly Lock _lock = new();
+ private bool _requested;
+ private bool _disposed;
+ private int _trims;
+
+ public MemoryTrimmer(JobQueue queue, ILogger log)
+ : this(() => queue.IsBusy, () => Trim(log), IdleDelay)
+ {
+ queue.JobFinished += (_, _) => Request();
+ }
+
+ internal MemoryTrimmer(Func busy, Action trim, TimeSpan delay)
+ {
+ _busy = busy;
+ _trim = trim;
+ _delay = delay;
+ _timer = new Timer(_ => OnQuiet());
+ }
+
+ /// How often memory was trimmed.
+ internal int Trims => Volatile.Read(ref _trims);
+
+ ///
+ /// Memory was freed (a job finished, the main window closed): trim once has passed without
+ /// another request and no conversion runs. A request meanwhile moves the time on; nothing happens without one.
+ ///
+ public void Request()
+ {
+ lock (_lock)
+ {
+ if (_disposed)
+ return;
+ _requested = true;
+ _timer.Change(_delay, Timeout.InfiniteTimeSpan);
+ }
+ }
+
+ private void OnQuiet()
+ {
+ lock (_lock)
+ {
+ if (!_requested || _disposed)
+ return;
+ if (_busy())
+ {
+ // A conversion started meanwhile: never during one. Look again later (its end requests too).
+ _timer.Change(_delay, Timeout.InfiniteTimeSpan);
+ return;
+ }
+ _requested = false;
+ }
+ _trim();
+ Interlocked.Increment(ref _trims);
+ }
+
+ /// Drops the engines' caches, collects and compacts every heap, then trims the working set (Windows).
+ internal static void Trim(ILogger log)
+ {
+ try
+ {
+ var watch = Stopwatch.StartNew();
+ var before = PrivateMegabytes();
+ EngineCaches.Release();
+ // Without compacting: a compacting collection moves old objects, and native code (the UI's Skia renderer)
+ // can hold pointers into them; the test run crashed in Skia half a minute after such a trim. Garbage is
+ // freed and empty heap regions are decommitted all the same.
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false);
+ GC.WaitForPendingFinalizers();
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false);
+ ProcessMemory.TrimWorkingSet();
+ log.LogInformation("Trimmed memory: private {Before} MB → {After} MB (managed heap {Heap} MB) in {Elapsed} ms",
+ before, PrivateMegabytes(), GC.GetTotalMemory(false) / (1024 * 1024), watch.ElapsedMilliseconds);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ log.LogWarning(ex, "Could not trim memory");
+ }
+ }
+
+ private static long PrivateMegabytes()
+ {
+ using var process = Process.GetCurrentProcess();
+ return process.PrivateMemorySize64 / (1024 * 1024);
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ _disposed = true;
+ _timer.Dispose();
+ }
+ }
+}
diff --git a/src/Filee.App/Services/Motion.cs b/src/Filee.App/Services/Motion.cs
index 5b19cb77..db141a98 100644
--- a/src/Filee.App/Services/Motion.cs
+++ b/src/Filee.App/Services/Motion.cs
@@ -66,7 +66,10 @@ public static void Set(bool enabled)
public static void Track(Window window)
{
lock (Windows)
+ {
+ Windows.RemoveAll(w => !w.TryGetTarget(out _)); // the main window is a new one each time it opens
Windows.Add(new WeakReference(window));
+ }
Apply(window);
}
diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs
index d3ffc447..742786eb 100644
--- a/src/Filee.App/Services/PresetAvailability.cs
+++ b/src/Filee.App/Services/PresetAvailability.cs
@@ -35,10 +35,11 @@ bool Possible(RoutePlanner planner) => formats.Any(f =>
var target = TargetFor(f);
if (merge && f.Id == "pdf" && !turns)
return true; // merged as it is
+ var frames = preset.SavesFramesOf(f.Id);
if (!turns)
- return planner.CanConvert(f.Id, target);
+ return planner.CanConvert(f.Id, target, frames);
// Rotate / flip: a route only counts when an engine on it turns images (not a PDF or a video copied).
- if (planner.Plan(f.Id, target) is not { } route)
+ if (planner.Plan(f.Id, target, frames) is not { } route)
return false;
if (route.FirstTransformStep() >= 0)
return true;
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index 202d9bc4..3456df0a 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -19,6 +19,7 @@
using Filee.App.Views;
using Filee.Core.Conversion;
using Filee.Core.Platform;
+using Filee.Core.Profiles;
using Filee.Core.Settings;
using Microsoft.Extensions.Logging;
@@ -46,6 +47,12 @@ public sealed class RadialController(
private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder"
private IDisposable? _handOffTimer;
private int _dragGeneration;
+ private bool _dragReachedDonut;
+
+ /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then.
+ private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1));
+ private bool _converted; // a conversion started from the files on the donut now
+ private readonly HashSet _formatRequestsOffered = new(StringComparer.OrdinalIgnoreCase);
///
/// How long a drag that never brought files may keep the invisible window up, in case the button release is
@@ -69,6 +76,8 @@ public void ShowForDrag(int x, int y)
// A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up.
window.DonutControl.LeaveProgressMode();
_dropped = false;
+ _dragReachedDonut = false;
+ _converted = false;
viewModel.ResetForDrag();
window.DonutControl.AllowContextEdit = false;
PlaceAndShow(window, x, y, activate: false);
@@ -89,6 +98,7 @@ public void ShowForFiles(int x, int y, IReadOnlyList files)
ReleaseProgressToToast();
var window = EnsureWindow();
_dropped = false;
+ _converted = false;
viewModel.Load(files, clickMode: true);
viewModel.FitToThisDonut();
window.DonutControl.AllowContextEdit = true;
@@ -101,6 +111,12 @@ public void OnDragGestureEnded()
{
if (!_visible || viewModel.IsClickMode)
return;
+ // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept
+ // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart.
+ if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held))
+ log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}",
+ Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "",
+ LogThrottle.HeldText(held));
// Nothing was brought to the donut: the window is invisible but catches clicks, so go right away.
if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode)
{
@@ -144,6 +160,22 @@ public void Close(bool animate = true)
});
else
window.Hide();
+ OfferFormatRequest();
+ }
+
+ ///
+ /// The donut closed on files Filee can't read at all (".ncm"): offer to request the format, once per extension
+ /// while Filee runs. Files it reads, even mixed with unknown ones, get a conversion and the toast instead.
+ ///
+ private void OfferFormatRequest()
+ {
+ if (_converted || viewModel.Files.Count == 0 || ProfileSelector.DetectFormats(viewModel.Files).Count > 0)
+ return;
+ var extensions = FormatRequest.UnknownExtensions(viewModel.Files).Where(_formatRequestsOffered.Add).ToList();
+ if (extensions.Count == 0)
+ return;
+ log.LogInformation("Donut closed on files Filee can't read: {Extensions}", FormatRequest.Label(extensions));
+ windows.ShowFormatRequestNotice(extensions);
}
private RadialWindow EnsureWindow()
@@ -154,6 +186,7 @@ private RadialWindow EnsureWindow()
var window = new RadialWindow { DataContext = viewModel };
var donut = window.DonutControl;
+ window.DragEntered += () => _dragReachedDonut = true;
window.FilesEntered += files =>
{
if (viewModel.IsClickMode || SameFiles(files, viewModel.Files))
@@ -214,6 +247,7 @@ private void Convert(int index, int sub, IReadOnlyList files)
Close();
return;
}
+ _converted = true;
ShowProgress(job);
}
diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs
index 64285de3..93a25dfa 100644
--- a/src/Filee.App/Services/SingleInstance.cs
+++ b/src/Filee.App/Services/SingleInstance.cs
@@ -3,6 +3,7 @@
using System.IO.Pipes;
using System.Text.Json;
+using Filee.Core.Settings;
namespace Filee.App.Services;
@@ -10,8 +11,7 @@ namespace Filee.App.Services;
public sealed class SingleInstance : IDisposable
{
// string.GetHashCode() is randomized per process, so a stable hash is required for both processes to agree.
- private static readonly string DefaultName = "Filee-" + Convert.ToHexString(
- System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Environment.UserName)))[..12];
+ internal static readonly string DefaultName = NameFor(Environment.UserName, UserDataStore.OverrideDirectory);
private readonly Mutex _mutex;
private readonly string _name;
private readonly CancellationTokenSource _cts = new();
@@ -30,6 +30,16 @@ private SingleInstance(Mutex mutex, string name, bool isFirst)
public static SingleInstance Acquire() => Acquire(DefaultName);
+ ///
+ /// The mutex and pipe name for a user. A copy started with FILEE_DATA_DIR ()
+ /// gets a name of its own, so it neither forwards to nor receives from the Filee the user runs.
+ ///
+ internal static string NameFor(string userName, string? dataDirectoryOverride)
+ {
+ var key = dataDirectoryOverride is null ? userName : userName + "|" + dataDirectoryOverride.ToUpperInvariant();
+ return "Filee-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key)))[..12];
+ }
+
/// The instance under another name (tests, which must not reach a Filee that is running).
internal static SingleInstance Acquire(string name)
{
@@ -67,6 +77,19 @@ public void StartListening()
{
// A client disconnected half-way; keep serving.
}
+ catch (UnauthorizedAccessException)
+ {
+ // The pipe name is still held by a Filee with other rights (one that just restarted this one
+ // without administrator rights and is closing): try again once it is gone.
+ try
+ {
+ await Task.Delay(500, _cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
}
});
}
@@ -75,11 +98,19 @@ public void StartListening()
/// Sends this process's arguments to the first instance. It only listens once its services are up, which can take
/// over ten seconds on a slow or busy computer: a second launch (or the installer's --quit) right after the first
/// lost its arguments after one 3-second try. Tries again until has passed; false when the
- /// first instance never answered (it is shutting down, or hung).
+ /// first instance never answered (it is shutting down, or hung) or refused this process.
///
- public bool ForwardToFirst(string[] args, TimeSpan wait)
+ public bool ForwardToFirst(string[] args, TimeSpan wait) => Forward(args, wait) == ForwardResult.Delivered;
+
+ ///
+ /// Like , telling why it failed. A pipe that keeps refusing access belongs to a Filee
+ /// running with other rights (as administrator, while this one isn't): that never changes, so it gives up after a
+ /// few tries instead of waiting the whole time.
+ ///
+ public ForwardResult Forward(string[] args, TimeSpan wait)
{
var deadline = DateTime.UtcNow + wait;
+ var refusals = 0;
while (true)
{
var left = deadline - DateTime.UtcNow;
@@ -89,17 +120,29 @@ public bool ForwardToFirst(string[] args, TimeSpan wait)
client.Connect((int)Math.Clamp(left.TotalMilliseconds, 100, 1000));
Filee.Platform.Windows.Foreground.AllowOtherProcess(); // its donut takes the focus (Esc, click away)
JsonSerializer.Serialize(client, args, AppJsonContext.Default.StringArray);
- return true;
+ return ForwardResult.Delivered;
}
- catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException)
+ catch (UnauthorizedAccessException)
{
+ // Also thrown for a moment while the pipe is being recreated between two launches: try a few times.
+ if (++refusals >= RefusalsBeforeGivingUp)
+ return ForwardResult.AccessDenied;
if (DateTime.UtcNow >= deadline)
- return false;
+ return ForwardResult.NoAnswer;
+ Thread.Sleep(100);
+ }
+ catch (Exception ex) when (ex is IOException or TimeoutException)
+ {
+ if (DateTime.UtcNow >= deadline)
+ return ForwardResult.NoAnswer;
Thread.Sleep(100); // the pipe is busy with another launch, or not there yet
}
}
}
+ /// Access-denied answers in a row after which stops trying.
+ private const int RefusalsBeforeGivingUp = 5;
+
///
/// Waits until the first instance has exited (it releases the mutex then), e.g. after forwarding --quit.
/// Returns false on timeout.
@@ -121,8 +164,12 @@ public bool WaitForFirstToExit(TimeSpan timeout)
return true;
}
+ /// Stops listening and releases the mutex. Safe to call twice (a restart releases it before shutting down).
public void Dispose()
{
+ if (_disposed)
+ return;
+ _disposed = true;
_cts.Cancel();
if (IsFirst)
{
@@ -134,4 +181,19 @@ public void Dispose()
}
_mutex.Dispose();
}
+
+ private bool _disposed;
+}
+
+/// Outcome of .
+public enum ForwardResult
+{
+ /// The running Filee has the arguments.
+ Delivered,
+
+ /// Nobody took them in time: the running Filee is starting, shutting down or hung.
+ NoAnswer,
+
+ /// The running Filee's pipe refuses this process: it runs with other rights (as administrator).
+ AccessDenied,
}
diff --git a/src/Filee.App/Services/StartupDiagnostics.cs b/src/Filee.App/Services/StartupDiagnostics.cs
new file mode 100644
index 00000000..aa6df551
--- /dev/null
+++ b/src/Filee.App/Services/StartupDiagnostics.cs
@@ -0,0 +1,56 @@
+// What Filee writes to its log before and around start-up: one line describing this launch, and every exception that
+// would otherwise end the process (or vanish in a task) without a trace. A report of a Filee that ignored every
+// gesture came with an empty log; these lines are what tells such cases apart.
+
+using System.Runtime.InteropServices;
+using Filee.Core.Platform;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Filee.App.Services;
+
+internal static class StartupDiagnostics
+{
+ ///
+ /// A logger for Program, before the service container exists. It writes to the same daily file. A log folder that
+ /// can't be created gives a logger that drops everything: logging must never stop Filee from starting.
+ ///
+ public static ILogger CreateEarlyLogger(string logDirectory)
+ {
+ try
+ {
+ return new FileLoggerProvider(logDirectory).CreateLogger("Filee.App.Program");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ return NullLogger.Instance;
+ }
+ }
+
+ ///
+ /// The start-up line: "Filee 1.11.0 starting: C:\…\Filee.exe (installed), integrity Medium, Explorer Medium,
+ /// Microsoft Windows 10.0.26200 X64, 2 argument(s)". Arguments are counted, not written: they are file paths.
+ ///
+ public static string Describe(string version, string? executable, string installKind, ElevationState elevation,
+ string operatingSystem, int argumentCount) =>
+ $"Filee {version} starting: {executable ?? "(unknown path)"} ({installKind}), {elevation}, {operatingSystem}, " +
+ $"{argumentCount} argument(s)";
+
+ /// How this copy got here: Setup, a release archive, or a build from source.
+ public static string InstallKind(bool installed, bool release) =>
+ installed ? "installed" : release ? "portable" : "development build";
+
+ /// "Microsoft Windows 10.0.26200 X64".
+ public static string OperatingSystemText() =>
+ $"{RuntimeInformation.OSDescription.Trim()} {RuntimeInformation.OSArchitecture}";
+
+ /// Writes exceptions from any thread and unobserved task failures to .
+ public static void LogUnhandledExceptions(ILogger log)
+ {
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ log.LogCritical(e.ExceptionObject as Exception, "Unhandled exception (process ending: {Terminating}): {Object}",
+ e.IsTerminating, e.ExceptionObject is Exception ? "see below" : e.ExceptionObject);
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ log.LogError(e.Exception, "A background task failed and nobody looked at the error");
+ }
+}
diff --git a/src/Filee.App/Services/SystemIntegrationService.cs b/src/Filee.App/Services/SystemIntegrationService.cs
index 569da464..a2097700 100644
--- a/src/Filee.App/Services/SystemIntegrationService.cs
+++ b/src/Filee.App/Services/SystemIntegrationService.cs
@@ -12,14 +12,28 @@ public sealed class SystemIntegrationService(IPlatformServices platform, ILocali
private (bool Enabled, string Executable, string Label)? _menu;
private bool _shortcutApplied;
private string? _shortcut;
+ private bool _skipLogged;
public string? Error { get; private set; }
internal bool RegistrationEnabled { get; init; } = UpdateService.IsReleaseBuild;
internal string? ExecutablePath { get; init; } = Environment.ProcessPath;
+ ///
+ /// A copy started with FILEE_DATA_DIR (): its startup entry, context
+ /// menu and Explorer menu would replace those of the Filee the user installed, so it registers nothing.
+ ///
+ internal bool SeparateDataDirectory { get; init; } = UserDataStore.OverrideDirectory is not null;
+
public void Apply(AppSettings settings)
{
Error = null;
+ if (SeparateDataDirectory)
+ {
+ if (!_skipLogged)
+ log.LogInformation("{Variable} is set: startup entry, context menu and Explorer menu are left alone", UserDataStore.DirectoryVariable);
+ _skipLogged = true;
+ return;
+ }
if (!RegistrationEnabled || ExecutablePath is not { } executable)
return;
var errors = new List();
diff --git a/src/Filee.App/Services/UiWatchdog.cs b/src/Filee.App/Services/UiWatchdog.cs
new file mode 100644
index 00000000..8d397c18
--- /dev/null
+++ b/src/Filee.App/Services/UiWatchdog.cs
@@ -0,0 +1,86 @@
+// Notices when the UI thread stops answering. A report said every gesture, shortcut and drop did nothing while the log
+// stayed empty; a line saying the UI thread was stuck (and for how long) tells a hang from events that never arrived.
+// A background timer posts a ping to the UI thread every second. While a ping waits longer than the threshold, one
+// warning is written for that episode; when the ping finally runs, one line says how long the UI thread was gone.
+
+using Microsoft.Extensions.Logging;
+
+namespace Filee.App.Services;
+
+/// Logs UI thread stalls longer than a threshold, once per episode.
+internal sealed class UiWatchdog : IDisposable
+{
+ /// How often the UI thread is pinged.
+ public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1);
+
+ /// Stalls shorter than this aren't worth a line (a big folder list, a first window).
+ public static readonly TimeSpan DefaultThreshold = TimeSpan.FromSeconds(5);
+
+ private readonly Action _post;
+ private readonly ILogger _log;
+ private readonly TimeProvider _time;
+ private readonly TimeSpan _threshold;
+ private readonly Lock _lock = new();
+ private DateTimeOffset? _pingSentAt;
+ private bool _reported;
+ private ITimer? _timer;
+
+ /// Queues an action on the UI thread (Dispatcher.UIThread.Post in the app).
+ public UiWatchdog(Action post, ILogger log, TimeProvider? time = null, TimeSpan? threshold = null)
+ {
+ _post = post;
+ _log = log;
+ _time = time ?? TimeProvider.System;
+ _threshold = threshold ?? DefaultThreshold;
+ }
+
+ /// Starts pinging every on a thread-pool timer.
+ public void Start() => _timer ??= _time.CreateTimer(_ => Tick(), null, Interval, Interval);
+
+ /// One timer tick: sends a ping, or checks how long the last one has been waiting.
+ internal void Tick()
+ {
+ lock (_lock)
+ {
+ if (_pingSentAt is { } sent)
+ {
+ var waited = _time.GetUtcNow() - sent;
+ if (!_reported && waited > _threshold)
+ {
+ _reported = true;
+ _log.LogWarning("UI thread blocked for {Seconds:0} s", waited.TotalSeconds);
+ }
+ return;
+ }
+ _pingSentAt = _time.GetUtcNow();
+ }
+ try
+ {
+ _post(Pong);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
+ {
+ // The dispatcher is shutting down: nothing left to watch.
+ lock (_lock)
+ _pingSentAt = null;
+ }
+ }
+
+ /// Runs on the UI thread when it got to the ping.
+ private void Pong()
+ {
+ lock (_lock)
+ {
+ if (_reported && _pingSentAt is { } sent)
+ _log.LogInformation("UI thread responsive again after {Seconds:0.0} s", (_time.GetUtcNow() - sent).TotalSeconds);
+ _pingSentAt = null;
+ _reported = false;
+ }
+ }
+
+ public void Dispose()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ }
+}
diff --git a/src/Filee.App/Services/UninstallCleanup.cs b/src/Filee.App/Services/UninstallCleanup.cs
index 4d5cef3a..8d8a51a7 100644
--- a/src/Filee.App/Services/UninstallCleanup.cs
+++ b/src/Filee.App/Services/UninstallCleanup.cs
@@ -1,6 +1,7 @@
// Removes per-user integration and downloaded engines after Filee exits. User settings remain for reinstalling.
using Filee.Core.Platform;
+using Filee.Core.Settings;
using Filee.Platform.Linux;
using Filee.Platform.MacOS;
using Filee.Platform.Windows;
@@ -15,11 +16,16 @@ public static bool Run()
: OperatingSystem.IsMacOS() ? new MacOSPlatformServices()
: OperatingSystem.IsLinux() ? new LinuxPlatformServices() : new NullPlatformServices();
var succeeded = true;
- Clean(() => platform.SetContextMenu(false, "", ""));
- Clean(() => platform.SetStartWithSystem(false, ""));
- if (OperatingSystem.IsLinux() && platform is LinuxPlatformServices linux)
+ // A copy with its own data folder (FILEE_DATA_DIR) never registered anything: only its engines go.
+ var integrated = UserDataStore.OverrideDirectory is null;
+ if (integrated)
+ {
+ Clean(() => platform.SetContextMenu(false, "", ""));
+ Clean(() => platform.SetStartWithSystem(false, ""));
+ }
+ if (integrated && OperatingSystem.IsLinux() && platform is LinuxPlatformServices linux)
Clean(() => linux.SetThunarShortcut(null));
- if (OperatingSystem.IsWindows() && ExplorerMenuRegistration.IsSupportedOs)
+ if (integrated && OperatingSystem.IsWindows() && ExplorerMenuRegistration.IsSupportedOs)
{
Clean(() =>
{
diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs
index 1c23eec7..a5ded8cb 100644
--- a/src/Filee.App/Services/WindowService.cs
+++ b/src/Filee.App/Services/WindowService.cs
@@ -14,25 +14,52 @@
namespace Filee.App.Services;
-public sealed class WindowService(ILocalizer loc, IServiceProvider services)
+public sealed class WindowService(ILocalizer loc, IServiceProvider services, MemoryTrimmer trimmer)
{
private MainWindow? _main;
+ private MainWindowPlacement? _mainPlacement;
private ToastWindow? _toast;
private EngineSetupWindow? _engineSetup;
private UpdateNoticeWindow? _updateNotice;
private EngineUpdateNoticeWindow? _engineNotice;
private FeedbackNoticeWindow? _feedbackNotice;
+ private FormatRequestNoticeWindow? _formatRequestNotice;
+
+ internal FormatRequestNoticeWindow? FormatRequestNotice => _formatRequestNotice;
public MainWindow? Main => _main;
- /// Shows (or brings to front) the main window, optionally on a page.
+ ///
+ /// Shows (or brings to front) the main window, optionally on a page. Going to the tray closes it
+ /// (): a new one opens where and on the page the last one was.
+ ///
public void ShowMain(string? page = null)
{
Motion.Refresh();
if (_main is null)
{
- _main = new MainWindow { DataContext = services.GetRequiredService() };
- _main.Closed += (_, _) => _main = null;
+ var model = ActivatorUtilities.CreateInstance(services);
+ var main = new MainWindow { DataContext = model };
+ if (_mainPlacement is { } placement)
+ {
+ placement.ApplyTo(main);
+ if (page is null)
+ model.Navigate(placement.Page);
+ }
+ main.Closing += (_, _) =>
+ {
+ // While it is still on screen: the release closes it hidden, when its position may no longer be known.
+ if (main.IsVisible)
+ _mainPlacement = MainWindowPlacement.Of(main, model, _mainPlacement);
+ };
+ main.Closed += (_, _) =>
+ {
+ if (_main == main)
+ _main = null;
+ model.Dispose();
+ trimmer.Request();
+ };
+ _main = main;
}
if (page is not null && _main.DataContext is MainWindowViewModel vm)
vm.Navigate(page);
@@ -130,6 +157,33 @@ public void ShowFeedbackNotice()
_feedbackNotice.Show();
}
+ /// Shows the "Filee is running as administrator" card in the bottom-right corner (once per run).
+ public void ShowElevationNotice()
+ {
+ if (_elevationNoticeShown)
+ return;
+ _elevationNoticeShown = true;
+ new ElevationNoticeWindow(services.GetRequiredService()).Show();
+ }
+
+ private bool _elevationNoticeShown;
+
+ ///
+ /// Shows the "Filee can't convert .ncm files yet" card with "Request this format", replacing an earlier one.
+ ///
+ public void ShowFormatRequestNotice(IReadOnlyList extensions)
+ {
+ _formatRequestNotice?.Close();
+ var notice = new FormatRequestNoticeWindow(extensions, loc.Format("format_request.notice_title", FormatRequest.Label(extensions)));
+ notice.Closed += (_, _) =>
+ {
+ if (_formatRequestNotice == notice)
+ _formatRequestNotice = null;
+ };
+ _formatRequestNotice = notice;
+ notice.Show();
+ }
+
/// Opens the preset popup. Returns true when the user saved (the preset object was updated).
public async Task EditPresetAsync(Preset preset, Window? owner)
{
@@ -248,4 +302,34 @@ private async Task ShowMessageAsync(Window? owner, string message, bool de
private static Window? MainWindowOrNull() =>
(Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+
+ /// Where the main window was and which page it showed, for the next one.
+ private sealed record MainWindowPlacement(PixelPoint? Position, Size? Size, bool Maximized, string Page)
+ {
+ /// The window's placement. Maximized or minimized, it keeps the position and size it had before.
+ public static MainWindowPlacement Of(MainWindow window, MainWindowViewModel vm, MainWindowPlacement? last) =>
+ window.WindowState switch
+ {
+ WindowState.Normal => new(window.Position, window.ClientSize, false, vm.SelectedItem.Key),
+ WindowState.Maximized => (last ?? new(null, null, false, "")) with { Maximized = true, Page = vm.SelectedItem.Key },
+ _ => (last ?? new(null, null, false, "")) with { Page = vm.SelectedItem.Key },
+ };
+
+ public void ApplyTo(MainWindow window)
+ {
+ // Only on a screen that is still there (a monitor may have been unplugged since).
+ if (Position is { } position && window.Screens.ScreenFromPoint(position) is not null)
+ {
+ window.WindowStartupLocation = WindowStartupLocation.Manual;
+ window.Position = position;
+ }
+ if (Size is { } size)
+ {
+ window.Width = size.Width;
+ window.Height = size.Height;
+ }
+ if (Maximized)
+ window.WindowState = WindowState.Maximized;
+ }
+ }
}
diff --git a/src/Filee.App/ViewModels/JobViewModel.cs b/src/Filee.App/ViewModels/JobViewModel.cs
index 3044df29..6cf19a25 100644
--- a/src/Filee.App/ViewModels/JobViewModel.cs
+++ b/src/Filee.App/ViewModels/JobViewModel.cs
@@ -2,6 +2,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using Filee.App.Services;
using Filee.Core.Conversion;
using Filee.Core.Localization;
using Filee.Core.Platform;
@@ -41,6 +42,16 @@ public JobViewModel(ConversionJob job, ILocalizer loc, IPlatformServices platfor
[ObservableProperty] private string? _errorDetails;
[ObservableProperty] private bool _canRetry;
+ /// Notes on files that converted (e.g. a PDF that couldn't be made smaller and was copied), one per line.
+ [ObservableProperty] private string? _notices;
+ [ObservableProperty] private bool _hasNotices;
+ /// Extensions of failed files Filee can't read (".ncm"), for "Request this format".
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanRequestFormat))]
+ private IReadOnlyList _unknownExtensions = [];
+
+ public bool CanRequestFormat => UnknownExtensions.Count > 0;
+
/// Source files that failed.
public IEnumerable FailedSources => Job.Files.Where(f => f.State == FileState.Failed).Select(f => f.SourcePath);
@@ -60,6 +71,15 @@ public void Refresh()
ErrorDetails = failed.Count == 0 ? null : string.Join(Environment.NewLine,
failed.Select(f => $"{Path.GetFileName(f.SourcePath)} — {FailureText.Reason(_loc, f.ErrorKey, f.ErrorDetail)}"));
CanRetry = _retry is not null && !IsRunning && failed.Count > 0;
+ IReadOnlyList unknown = IsRunning ? [] : FormatRequest.UnknownExtensions(
+ failed.Where(f => f.ErrorKey == "error.unsupported_source").Select(f => f.SourcePath));
+ if (!unknown.SequenceEqual(UnknownExtensions))
+ UnknownExtensions = unknown;
+
+ var noted = Job.Files.Where(f => f.State == FileState.Done && f.NoticeKey is not null).ToList();
+ HasNotices = noted.Count > 0;
+ Notices = noted.Count == 0 ? null : string.Join(Environment.NewLine,
+ noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {_loc[f.NoticeKey!]}"));
Status = Job.State switch
{
diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs
index 629d9a81..07161c55 100644
--- a/src/Filee.App/ViewModels/MainWindowViewModel.cs
+++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs
@@ -26,10 +26,15 @@ public sealed partial class NavItem(string key, string titleKey, string iconKey,
public void Refresh(ILocalizer loc) => Title = loc[TitleKey];
}
-public sealed partial class MainWindowViewModel : ObservableObject
+///
+/// One per main window (); disposed with it, which drops its subscriptions on the
+/// long-lived services and the current page.
+///
+public sealed partial class MainWindowViewModel : ObservableObject, IDisposable
{
private readonly IServiceProvider _services;
private readonly ILocalizer _loc;
+ private bool _disposed;
/// The repository, for the GitHub button above the version.
public string RepositoryUrl => UpdateService.RepositoryUrl;
@@ -39,11 +44,7 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ
_services = services;
_loc = loc;
Updates = updates;
- updates.PropertyChanged += (_, e) =>
- {
- if (UpdateTexts.Affects(e.PropertyName))
- RefreshVersionTexts();
- };
+ updates.PropertyChanged += OnUpdatesChanged;
RefreshVersionTexts();
Items =
[
@@ -59,13 +60,7 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ
];
foreach (var item in Items)
item.Refresh(loc);
- loc.LanguageChanged += (_, _) =>
- {
- foreach (var item in Items)
- item.Refresh(loc);
- RefreshVersionTexts();
- CurrentPage = CreatePage(SelectedItem); // rebuild so code-generated labels update too
- };
+ loc.LanguageChanged += OnLanguageChanged;
_selectedItem = Items[0];
_currentPage = CreatePage(Items[0]);
}
@@ -93,6 +88,20 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ
[RelayCommand]
private Task DownloadUpdate() => Updates.UpdateAsync();
+ private void OnUpdatesChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (UpdateTexts.Affects(e.PropertyName))
+ RefreshVersionTexts();
+ }
+
+ private void OnLanguageChanged(object? sender, EventArgs e)
+ {
+ foreach (var item in Items)
+ item.Refresh(_loc);
+ RefreshVersionTexts();
+ CurrentPage = CreatePage(SelectedItem); // rebuild so code-generated labels update too
+ }
+
private void RefreshVersionTexts()
{
VersionText = _loc.Format("about.version", UpdateService.CurrentVersion);
@@ -122,4 +131,14 @@ partial void OnSelectedItemChanged(NavItem? oldValue, NavItem newValue)
private ObservableObject CreatePage(NavItem item) =>
(ObservableObject)ActivatorUtilities.CreateInstance(_services, item.PageType);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ Updates.PropertyChanged -= OnUpdatesChanged;
+ _loc.LanguageChanged -= OnLanguageChanged;
+ (CurrentPage as IDisposable)?.Dispose();
+ }
}
diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
index d595e3bf..c67b9e2e 100644
--- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
@@ -86,10 +86,10 @@ partial void OnDefaultSubfolderChanged(string value) =>
[ObservableProperty] private Choice _closeChoice;
- partial void OnCloseChoiceChanged(Choice value)
+ partial void OnCloseChoiceChanged(Choice? oldValue, Choice newValue)
{
- if (_store.Settings.CloseAction != value.Value)
- Save(s => s.CloseAction = value.Value);
+ if (!Choices.Restore(oldValue, newValue, v => CloseChoice = v) && _store.Settings.CloseAction != newValue.Value)
+ Save(s => s.CloseAction = newValue.Value);
}
// The close dialog may answer "Don't ask again" while this page stays open behind the hidden window.
diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
index 274e7c6d..189d3d26 100644
--- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
@@ -83,17 +83,21 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable
private readonly IPlatformServices _platform;
private readonly Services.Triggers.TriggerService? _triggers;
private readonly ConversionService? _conversions;
+ private readonly ElevationService? _elevation;
public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null,
- ConversionService? conversions = null)
+ ConversionService? conversions = null, ElevationService? elevation = null)
{
_store = store;
_loc = loc;
_platform = platform;
_triggers = triggers;
_conversions = conversions;
+ _elevation = elevation;
if (triggers is not null)
triggers.StatusChanged += OnTriggerStatusChanged;
+ if (elevation is not null)
+ elevation.PropertyChanged += OnElevationChanged;
var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag);
DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers));
store.HistoryChanged += OnHistoryChanged;
@@ -107,10 +111,27 @@ public void Dispose()
_store.HistoryChanged -= OnHistoryChanged;
if (_triggers is not null)
_triggers.StatusChanged -= OnTriggerStatusChanged;
+ if (_elevation is not null)
+ _elevation.PropertyChanged -= OnElevationChanged;
}
private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(NeedsAccessibility));
+ private void OnElevationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) =>
+ OnPropertyChanged(nameof(ElevationRestartFailed));
+
+ ///
+ /// Windows: Filee runs as administrator while Explorer doesn't, so drops, the Explorer menu and the shortcut can't
+ /// reach it (the warning above the drop zone).
+ ///
+ public bool IsElevated => _elevation?.ShellCannotReachFilee == true;
+
+ /// The restart button didn't work: the warning says what to do instead.
+ public bool ElevationRestartFailed => _elevation?.RestartFailed == true;
+
+ [RelayCommand]
+ private void RestartWithoutAdministratorRights() => _elevation?.RestartWithoutAdministratorRights();
+
/// macOS: gestures wait for the Accessibility permission (the reminder above the drop zone).
public bool NeedsAccessibility => _triggers?.NeedsPermission == true;
diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs
index 2541b32a..cbba2856 100644
--- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs
+++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs
@@ -80,6 +80,7 @@ .. FormatRegistry.Known
Rotations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.rotation.{v}"])).ToList();
Flips = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.flip.{v}"])).ToList();
PageSizes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.page_size.{v}"])).ToList();
+ CompressionLevels = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList();
Locations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList();
Conflicts = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.conflict.{v}"])).ToList();
TiffCompressions = Enum.GetValues().Select(v => new Choice(v, v.ToString().ToUpperInvariant())).ToList();
@@ -99,6 +100,8 @@ .. FormatRegistry.Known
_rotation = Rotations.FirstOrDefault(r => r.Value == _edit.Image.Rotation) ?? Rotations[0];
_flip = Flips.FirstOrDefault(f => f.Value == _edit.Image.Flip) ?? Flips[0];
_pageSize = PageSizes.First(p => p.Value == _edit.Pdf.PageSize);
+ _compressionLevel = CompressionLevels.FirstOrDefault(c => c.Value == _edit.Pdf.Compression) ?? CompressionLevels[0];
+ _compressWithGhostscript = _edit.Pdf.CompressWithGhostscript;
_location = Locations.First(l => l.Value == _edit.Output.Location);
_conflict = Conflicts.First(c => c.Value == _edit.Output.Conflict);
_tiffCompression = TiffCompressions.First(t => t.Value == _edit.Image.TiffCompression);
@@ -111,8 +114,11 @@ .. FormatRegistry.Known
_wavFormat = WavFormats.First(f => f.Value == _edit.Media.WavFormat);
_videoCodecValue = _edit.Media.VideoCodec;
_audioCodecValue = _edit.Media.AudioCodec;
+ _hardwareEncoding = _edit.Media.HardwareEncoding;
_archiveLevel = ArchiveLevels.First(l => l.Value == _edit.Archive.Level);
_combineIntoOne = _edit.Archive.CombineIntoOne;
+ _saveEveryFrame = _edit.Media.SaveEveryFrame;
+ _frameInterval = _edit.Media.FrameInterval > 0 ? _edit.Media.FrameInterval : 1;
_traceMode = TraceModes.First(m => m.Value == _edit.Trace.Mode);
_traceColors = Math.Clamp(_edit.Trace.Colors, TracingOptions.MinColors, TracingOptions.MaxColors);
_traceDetail = Math.Clamp(_edit.Trace.Detail, 0, 100);
@@ -156,6 +162,9 @@ .. FormatRegistry.Known
public IReadOnlyList> Rotations { get; }
public IReadOnlyList> Flips { get; }
public IReadOnlyList> PageSizes { get; }
+
+ /// PDF → PDF: don't compress, or the level (pictures at 72, 150 or 300 dpi).
+ public IReadOnlyList> CompressionLevels { get; }
public IReadOnlyList> Locations { get; }
public IReadOnlyList> Conflicts { get; }
public IReadOnlyList> TiffCompressions { get; }
@@ -174,10 +183,12 @@ .. FormatRegistry.Known
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco),
nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowPdfSplit), nameof(ShowPdfCombine),
+ nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions),
nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio),
- nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec),
+ nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding),
nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec),
- nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), nameof(ShowTrace))]
+ nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote),
+ nameof(ShowSaveEveryFrame), nameof(ShowTrace))]
private Choice _target;
private VideoCodec _videoCodecValue;
@@ -209,6 +220,7 @@ public Choice VideoCodecChoice
return;
_videoCodecValue = value.Value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(ShowHardwareEncoding));
}
}
@@ -293,6 +305,11 @@ public bool ColorProfileChecked
[ObservableProperty] private string _pageRange;
[ObservableProperty] private bool _splitPages;
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPdfCompressionOptions))]
+ private Choice _compressionLevel;
+
+ [ObservableProperty] private bool _compressWithGhostscript;
+
/// PDF → image: one tall image of all pages.
[ObservableProperty] private bool _combinePages;
[ObservableProperty] private bool _trimPageMargins;
@@ -309,8 +326,19 @@ public bool ColorProfileChecked
[ObservableProperty] private Choice _sampleRate;
[ObservableProperty] private Choice _wavFormat;
[ObservableProperty] private bool _removeAudio;
+
+ /// Encode H.264 / HEVC on the graphics chip when one works (MediaOptions.HardwareEncoding).
+ [ObservableProperty] private bool _hardwareEncoding;
[ObservableProperty] private Choice _archiveLevel;
[ObservableProperty] private bool _combineIntoOne;
+ [ObservableProperty] private bool _saveEveryFrame;
+
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(FrameEstimate))]
+ private int _frameInterval = 1;
+
+ /// How many pictures a minute of 30 fps video gives at the chosen interval, and the limit per video.
+ public string FrameEstimate => _loc.Format("presets.frame_estimate",
+ (int)Math.Ceiling(1800.0 / Math.Clamp(FrameInterval, 1, 1000)), MediaOptions.MaxFrames.ToString("N0", System.Globalization.CultureInfo.InvariantCulture));
[ObservableProperty, NotifyPropertyChangedFor(nameof(ShowTraceColors))]
private Choice _traceMode;
@@ -336,6 +364,8 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice<
partial void OnWavFormatChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => WavFormat = v);
partial void OnArchiveLevelChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => ArchiveLevel = v);
partial void OnTraceModeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => TraceMode = v);
+ partial void OnCompressionLevelChanged(Choice? oldValue, Choice newValue) =>
+ Choices.Restore(oldValue, newValue, v => CompressionLevel = v);
private string TargetId => Target.Value;
private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category;
@@ -361,6 +391,10 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice<
public bool ShowPdfBuild => TargetId == "pdf";
public bool ShowPdfSplit => TargetId == "pdf";
+ /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on.
+ public bool ShowPdfCompression => TargetId == "pdf";
+ public bool ShowPdfCompressionOptions => ShowPdfCompression && CompressionLevel.Value != PdfCompression.None;
+
/// PDF → a single-image format: the pages can be stacked into one tall image (TIFF, GIF, APNG and MNG keep pages).
public bool ShowPdfCombine => TargetCategory == FormatCategory.Image
&& TargetId is not ("tiff" or "gif" or "apng" or "mng" or "ico" or "cur" or "icns" or "svg" or "pdf");
@@ -372,6 +406,8 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice<
public bool ShowWav => TargetId is "wav" or "w64";
/// MP4, MKV, MOV and WebM can be written with HEVC or AV1 besides their usual codec.
public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1;
+ /// Hardware encoding applies to H.264 and HEVC only (not VP9, AV1, MPEG-2, …).
+ public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue);
/// M4A / M4B can hold Apple Lossless besides AAC.
public bool ShowAudioCodec => AudioCodecs.Count > 1;
public bool ShowMediaQuality => TargetIsLossyMedia;
@@ -382,6 +418,8 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice<
public bool ShowArchiveOptions => ShowArchive && TargetId != FormatRegistry.Folder;
/// "Extract" has nothing to set; a line says what it does instead.
public bool ShowExtractNote => TargetId == FormatRegistry.Folder;
+ /// "Save every frame" of a video: PNG and JPG (format ids, ).
+ public bool ShowSaveEveryFrame => MediaOptions.FrameTargets.Contains(TargetId);
///
/// Tracing options: raster images reach SVG (and DXF / DWG through it) only by tracing. For other sources (a
/// drawing, a CAD file) the options are ignored.
@@ -448,6 +486,8 @@ public bool Apply()
pdf.CombinePages = CombinePages;
pdf.TrimPageMargins = TrimPageMargins;
pdf.SplitTallImage = SplitTallImage;
+ pdf.Compression = CompressionLevel.Value;
+ pdf.CompressWithGhostscript = CompressWithGhostscript;
_original.Document.PdfA = PdfA;
var media = _original.Media;
@@ -460,6 +500,10 @@ public bool Apply()
media.WavFormat = WavFormat.Value;
media.VideoCodec = MediaCodecs.Effective(TargetId, _videoCodecValue);
media.AudioCodec = MediaCodecs.Effective(TargetId, _audioCodecValue);
+ media.HardwareEncoding = HardwareEncoding;
+ // Only where the option is shown: a box ticked for PNG and left behind by another target saves nothing.
+ media.SaveEveryFrame = ShowSaveEveryFrame && SaveEveryFrame;
+ media.FrameInterval = media.SaveEveryFrame ? Math.Clamp(FrameInterval, 1, 1000) : 1;
_original.Archive.Level = ArchiveLevel.Value;
_original.Archive.CombineIntoOne = CombineIntoOne;
_original.Trace.Mode = TraceMode.Value;
diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml b/src/Filee.App/Views/ElevationNoticeWindow.axaml
new file mode 100644
index 00000000..6eaa795a
--- /dev/null
+++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs b/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs
new file mode 100644
index 00000000..76dd5cd4
--- /dev/null
+++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs
@@ -0,0 +1,51 @@
+// Bottom-right card saying Filee runs as administrator, with the restart that fixes it (ElevationService).
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Filee.App.Services;
+
+namespace Filee.App.Views;
+
+/// One-time notice for an elevated Filee. "Restart" starts it again through Explorer and quits this one.
+public partial class ElevationNoticeWindow : Window
+{
+ private const int ScreenMargin = 12;
+ private readonly ElevationService? _elevation;
+
+ /// For the XAML previewer.
+ public ElevationNoticeWindow() : this(null)
+ {
+ }
+
+ public ElevationNoticeWindow(ElevationService? elevation)
+ {
+ _elevation = elevation;
+ InitializeComponent();
+ Motion.Track(this);
+ SizeChanged += (_, _) => PlaceBottomRight();
+ }
+
+ private void OnRestart(object? sender, RoutedEventArgs e)
+ {
+ if (_elevation is null)
+ return;
+ if (_elevation.RestartWithoutAdministratorRights())
+ Close();
+ else
+ FailedText.IsVisible = true;
+ }
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+
+ private void PlaceBottomRight()
+ {
+ var screen = Screens.Primary;
+ if (screen is null)
+ return;
+ var area = screen.WorkingArea;
+ var width = (int)Math.Ceiling(Bounds.Width * screen.Scaling);
+ var height = (int)Math.Ceiling(Bounds.Height * screen.Scaling);
+ Position = new PixelPoint(area.Right - width - ScreenMargin, area.Bottom - height - ScreenMargin);
+ }
+}
diff --git a/src/Filee.App/Views/FormatRequestNoticeWindow.axaml b/src/Filee.App/Views/FormatRequestNoticeWindow.axaml
new file mode 100644
index 00000000..1c563bdf
--- /dev/null
+++ b/src/Filee.App/Views/FormatRequestNoticeWindow.axaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/FormatRequestNoticeWindow.axaml.cs b/src/Filee.App/Views/FormatRequestNoticeWindow.axaml.cs
new file mode 100644
index 00000000..8317bfc0
--- /dev/null
+++ b/src/Filee.App/Views/FormatRequestNoticeWindow.axaml.cs
@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Filee.App.Services;
+
+namespace Filee.App.Views;
+
+/// Bottom-right card for files of a type Filee doesn't know: "Request this format" opens the GitHub form.
+public partial class FormatRequestNoticeWindow : Window
+{
+ private const int ScreenMargin = 12;
+
+ public FormatRequestNoticeWindow() => InitializeComponent();
+
+ /// The unknown extensions, e.g. ".ncm" (see ).
+ public FormatRequestNoticeWindow(IReadOnlyList extensions, string title) : this()
+ {
+ Extensions = extensions;
+ TitleText.Text = title;
+ Motion.Track(this);
+ SizeChanged += (_, _) => PlaceBottomRight();
+ }
+
+ public IReadOnlyList Extensions { get; } = [];
+
+ private async void OnRequest(object? sender, RoutedEventArgs e)
+ {
+ await Launcher.LaunchUriAsync(FormatRequest.Url(Extensions));
+ Close();
+ }
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+
+ private void PlaceBottomRight()
+ {
+ var screen = Screens.Primary;
+ if (screen is null)
+ return;
+ var area = screen.WorkingArea;
+ var width = (int)Math.Ceiling(Bounds.Width * screen.Scaling);
+ var height = (int)Math.Ceiling(Bounds.Height * screen.Scaling);
+ Position = new PixelPoint(area.Right - width - ScreenMargin, area.Bottom - height - ScreenMargin);
+ }
+}
diff --git a/src/Filee.App/Views/MainWindow.axaml.cs b/src/Filee.App/Views/MainWindow.axaml.cs
index 4e3db3e1..f5d743a5 100644
--- a/src/Filee.App/Views/MainWindow.axaml.cs
+++ b/src/Filee.App/Views/MainWindow.axaml.cs
@@ -13,6 +13,8 @@ namespace Filee.App.Views;
public partial class MainWindow : Window
{
private bool _asking;
+ private bool _releasePosted;
+ private bool _releasing;
public MainWindow()
{
@@ -35,12 +37,12 @@ private async void OnSponsor(object? sender, RoutedEventArgs e) =>
protected override void OnClosing(WindowClosingEventArgs e)
{
base.OnClosing(e);
- if (e.CloseReason is not WindowCloseReason.WindowClosing)
+ if (e.CloseReason is not WindowCloseReason.WindowClosing || _releasing)
return;
e.Cancel = true;
- // Close() from code only hides, as before; the setting is for the title bar's close button and Alt+F4.
+ // Close() from code goes to the tray, as before; the setting is for the title bar's close button and Alt+F4.
if (e.IsProgrammatic)
- Hide();
+ HideToTray();
else
CloseAsTheUserChose();
}
@@ -52,7 +54,7 @@ internal void CloseAsTheUserChose()
switch (store.Settings.CloseAction)
{
case CloseAction.Tray:
- Hide();
+ HideToTray();
break;
case CloseAction.Quit:
Quit();
@@ -81,7 +83,7 @@ private async Task AskAsync(UserDataStore store)
if (action == CloseAction.Quit)
Quit();
else
- Hide();
+ HideToTray();
}
finally
{
@@ -92,4 +94,25 @@ private async Task AskAsync(UserDataStore store)
// Posted: shutting down closes this window again, which must not happen inside its own Closing handler.
private static void Quit() => Dispatcher.UIThread.Post(() =>
(Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown());
+
+ ///
+ /// Leaves Filee in the tray: hides the window at once, then closes it for real, so its pages, visuals and images
+ /// don't stay in memory until the next time (issue #119). opens a new one.
+ ///
+ internal void HideToTray()
+ {
+ Hide();
+ if (_releasePosted)
+ return;
+ _releasePosted = true;
+ // Posted: a window can't close inside its own Closing handler.
+ Dispatcher.UIThread.Post(() =>
+ {
+ _releasePosted = false;
+ if (IsVisible)
+ return; // shown again meanwhile
+ _releasing = true;
+ Close();
+ });
+ }
}
diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml
index 4eab3558..65434a92 100644
--- a/src/Filee.App/Views/Pages/HomePage.axaml
+++ b/src/Filee.App/Views/Pages/HomePage.axaml
@@ -39,6 +39,21 @@
+
+
+
+
+
+
+
+
+
+
+
+ /// The first drag over the drop zone of this run is logged: a log without it after "drops don't work" means the
+ /// drag never reached Filee (Windows refuses drops from Explorer into a Filee running as administrator).
+ ///
+ private static int _dragEnterLogged;
+
public HomePage()
{
InitializeComponent();
DragDrop.AddDragEnterHandler(DropZone, (_, e) =>
{
DropZone.Classes.Add("over");
- e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None;
+ var files = e.DataTransfer.Contains(DataFormat.File);
+ e.DragEffects = files ? DragDropEffects.Copy : DragDropEffects.None;
+ if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0)
+ AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files);
});
DragDrop.AddDragOverHandler(DropZone, (_, e) =>
e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None);
diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml
index 01686024..cebecfbd 100644
--- a/src/Filee.App/Views/PresetEditorView.axaml
+++ b/src/Filee.App/Views/PresetEditorView.axaml
@@ -115,6 +115,17 @@
+
+
+
+
+
+
+
+
+
+
@@ -163,6 +174,10 @@
+
+
+
+
@@ -216,6 +231,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/RadialWindow.axaml.cs b/src/Filee.App/Views/RadialWindow.axaml.cs
index cf7b0407..0cfaab79 100644
--- a/src/Filee.App/Views/RadialWindow.axaml.cs
+++ b/src/Filee.App/Views/RadialWindow.axaml.cs
@@ -24,6 +24,9 @@ public RadialWindow()
public DonutMenu DonutControl => Donut;
+ /// A drag entered the window, with or without files (for the log: did the drag reach the donut at all).
+ public event Action? DragEntered;
+
/// Files entered the window during a drag.
public event Action>? FilesEntered;
@@ -44,6 +47,7 @@ public RadialWindow()
private void OnDragEnter(object? sender, DragEventArgs e)
{
+ DragEntered?.Invoke();
var files = LocalFiles(e);
if (files.Count == 0)
{
diff --git a/src/Filee.App/Views/ToastWindow.axaml b/src/Filee.App/Views/ToastWindow.axaml
index 390c9f91..9d8498c9 100644
--- a/src/Filee.App/Views/ToastWindow.axaml
+++ b/src/Filee.App/Views/ToastWindow.axaml
@@ -83,6 +83,11 @@
+
+
+
diff --git a/src/Filee.App/Views/ToastWindow.axaml.cs b/src/Filee.App/Views/ToastWindow.axaml.cs
index b4f911e6..26fc9a80 100644
--- a/src/Filee.App/Views/ToastWindow.axaml.cs
+++ b/src/Filee.App/Views/ToastWindow.axaml.cs
@@ -1,8 +1,10 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
+using Avalonia.Interactivity;
using Avalonia.Threading;
using Filee.App.Services;
+using Filee.App.ViewModels;
namespace Filee.App.Views;
@@ -19,6 +21,12 @@ public ToastWindow()
}
private ConversionService? _service;
+
+ private async void OnRequestFormat(object? sender, RoutedEventArgs e)
+ {
+ if ((sender as Control)?.DataContext is JobViewModel { CanRequestFormat: true } job)
+ await Launcher.LaunchUriAsync(FormatRequest.Url(job.UnknownExtensions));
+ }
private bool _closed;
protected override void OnDataContextChanged(EventArgs e)
diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs
index 1e8c94a6..23e928ce 100644
--- a/src/Filee.Cli/Cli.cs
+++ b/src/Filee.Cli/Cli.cs
@@ -152,7 +152,8 @@ private static string Describe(FileResult file, ILocalizer texts)
var source = Shorten(file.SourcePath);
return file.State switch
{
- FileState.Done => $"ok {source} -> {string.Join(", ", file.Outputs.Select(Shorten))}",
+ FileState.Done => $"ok {source} -> {string.Join(", ", file.Outputs.Select(Shorten))}"
+ + (file.NoticeKey is null ? "" : $" ({texts[file.NoticeKey]})"),
FileState.Skipped => $"skip {source} (the output file already exists)",
FileState.Cancelled => $"stop {source} (cancelled)",
_ => $"fail {source}: {Error(file, texts)}",
@@ -195,6 +196,8 @@ private static void WriteJson(ConversionJob job, ILocalizer texts, TextWriter ou
json.WriteEndArray();
if (file.State is FileState.Failed or FileState.Cancelled)
json.WriteString("error", Error(file, texts));
+ else if (file.NoticeKey is not null)
+ json.WriteString("notice", texts[file.NoticeKey]);
json.WriteEndObject();
}
json.WriteEndArray();
diff --git a/src/Filee.Core/Conversion/ConversionJob.cs b/src/Filee.Core/Conversion/ConversionJob.cs
index 7ef352c3..fcd1f06c 100644
--- a/src/Filee.Core/Conversion/ConversionJob.cs
+++ b/src/Filee.Core/Conversion/ConversionJob.cs
@@ -37,6 +37,12 @@ public sealed class FileResult
/// Raw error detail for logs / tooltips.
public string? ErrorDetail { get; set; }
+
+ ///
+ /// Localization key of a note on a file that converted (e.g. notice.pdf_not_smaller: compressing saved
+ /// nothing, so the PDF was copied as it is), if any.
+ ///
+ public string? NoticeKey { get; set; }
}
/// A batch of files converted with one preset.
diff --git a/src/Filee.Core/Conversion/ConverterCatalog.cs b/src/Filee.Core/Conversion/ConverterCatalog.cs
index 584765fc..886923e1 100644
--- a/src/Filee.Core/Conversion/ConverterCatalog.cs
+++ b/src/Filee.Core/Conversion/ConverterCatalog.cs
@@ -6,7 +6,9 @@ namespace Filee.Core.Conversion;
public sealed class ConverterCatalog
{
private readonly IReadOnlyList _all;
- private Dictionary? _statusCache;
+ private volatile Dictionary? _statusCache;
+ private int _generation;
+ private readonly Lock _statusLock = new();
private IReadOnlyList _priority = [];
public ConverterCatalog(IEnumerable converters) => _all = converters.ToList();
@@ -36,19 +38,48 @@ public IReadOnlyList Priority
_all.OrderBy(c => PriorityIndex(c)).ThenBy(c => _all.ToList().IndexOf(c)).ToList();
/// Cached availability of a converter.
- public EngineStatus StatusOf(IConverter converter)
- {
- _statusCache ??= _all.ToDictionary(c => c.Id, SafeStatus);
- return _statusCache[converter.Id];
- }
+ public EngineStatus StatusOf(IConverter converter) => Statuses()[converter.Id];
/// Re-checks every engine (e.g. after the user changed an engine path).
public void Refresh()
{
+ Interlocked.Increment(ref _generation);
_statusCache = null;
Changed?.Invoke(this, EventArgs.Empty);
}
+ ///
+ /// Checks every engine on a background thread, so the first conversion menu doesn't wait for them (some start a
+ /// program to read its version). A caller that needs a status meanwhile waits for this check instead of starting
+ /// its own.
+ ///
+ public Task CheckInBackgroundAsync() => Task.Run(() => Statuses());
+
+ ///
+ /// The statuses, checked once and shared between threads. A during a check makes it check
+ /// again, so a result from before a settings change is never kept.
+ ///
+ private Dictionary Statuses()
+ {
+ if (_statusCache is { } cached)
+ return cached;
+ lock (_statusLock)
+ {
+ while (true)
+ {
+ if (_statusCache is { } done)
+ return done;
+ var generation = Volatile.Read(ref _generation);
+ var statuses = _all.ToDictionary(c => c.Id, SafeStatus);
+ if (generation == Volatile.Read(ref _generation))
+ {
+ _statusCache = statuses;
+ return statuses;
+ }
+ }
+ }
+ }
+
/// Creates a planner over the currently available converters.
public RoutePlanner CreatePlanner() =>
new(_all.Where(c => StatusOf(c).IsAvailable), c => Math.Min(PriorityIndex(c), 9));
diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs
index 0e4da67c..fbaee33f 100644
--- a/src/Filee.Core/Conversion/IConverter.cs
+++ b/src/Filee.Core/Conversion/IConverter.cs
@@ -27,8 +27,28 @@ namespace Filee.Core.Conversion;
/// is what the user asked for. PDF → PPTX puts pictures of the pages on slides; a Word document
/// rendered to PDF must not become pictures on slides, and a picture deck must not be a step towards ODP or PPSX.
///
+///
+/// The edge writes every frame of a video as numbered pictures into a folder ().
+/// It is never part of an ordinary route (a video → JPG stays one still frame), and with
+/// asking for frames it is the whole route.
+///
public readonly record struct ConversionEdge(
- string From, string To, int Cost = 10, bool ImageSourcesOnly = false, bool NotForImageSources = false, bool DirectOnly = false);
+ string From, string To, int Cost = 10, bool ImageSourcesOnly = false, bool NotForImageSources = false,
+ bool DirectOnly = false, bool FrameSequence = false);
+
+///
+/// A conversion an engine refuses with a reason the user can act on, shown in their language (the file's
+/// ) instead of "The engine reported an error".
+///
+/// Localization key of the reason, e.g. error.too_many_frames.
+/// Non-localized detail shown after it in brackets (numbers, a name), or null.
+public sealed class ConversionRefusedException(string errorKey, string? detail = null)
+ : Exception(detail is null ? errorKey : $"{errorKey} ({detail})")
+{
+ public string ErrorKey { get; } = errorKey;
+
+ public string? Detail { get; } = detail;
+}
/// Whether an engine can currently be used.
/// True if conversions can run.
@@ -63,13 +83,22 @@ public interface IOutputAllocator
/// Options chosen by the user.
/// Where to write results.
/// Private scratch directory for this job; deleted afterwards.
+///
+/// The step takes a edge: every frame of the video becomes a picture of
+/// format in one output folder.
+///
+/// Records a note on a step that worked, as a localization key (e.g. notice.pdf_not_smaller): shown with the
+/// file's result. Null when nobody listens.
+///
public sealed record ConversionStep(
string InputPath,
string From,
string To,
Preset Preset,
IOutputAllocator Output,
- string WorkDirectory);
+ string WorkDirectory,
+ bool FrameSequence = false,
+ Action? Notice = null);
///
/// A conversion engine (ImageMagick, LibreOffice, rhwp, ...).
diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs
index 590f3337..5edecdae 100644
--- a/src/Filee.Core/Conversion/JobQueue.cs
+++ b/src/Filee.Core/Conversion/JobQueue.cs
@@ -22,6 +22,7 @@ public sealed class JobQueue : IAsyncDisposable
private readonly ConcurrentDictionary _engineGates = new();
private readonly HashSet _reservedOutputs = new(StringComparer.OrdinalIgnoreCase);
private readonly Task _worker;
+ private int _busy;
/// Converters and their priority.
/// Used by "Merge into one PDF" presets.
@@ -45,10 +46,14 @@ public JobQueue(ConverterCatalog catalog, IPdfMerger? pdfMerger = null, ILogger<
/// Raised (on a background thread) once when a job reaches a final state.
public event EventHandler? JobFinished;
+ /// True while a job waits in the queue or runs (the app frees memory only when this is false).
+ public bool IsBusy => Volatile.Read(ref _busy) > 0;
+
/// Adds a job to the queue and returns it immediately.
public ConversionJob Enqueue(IReadOnlyList sources, Preset preset, string presetDisplayName)
{
var job = new ConversionJob(sources, preset, presetDisplayName);
+ Interlocked.Increment(ref _busy); // until the worker is done with it
_channel.Writer.TryWrite(job);
JobUpdated?.Invoke(this, job);
return job;
@@ -56,6 +61,19 @@ public ConversionJob Enqueue(IReadOnlyList sources, Preset preset, strin
/// Runs a job to completion on the calling task. Used by tests and by 's worker.
public async Task RunAsync(ConversionJob job)
+ {
+ Interlocked.Increment(ref _busy);
+ try
+ {
+ await RunJobAsync(job);
+ }
+ finally
+ {
+ Interlocked.Decrement(ref _busy);
+ }
+ }
+
+ private async Task RunJobAsync(ConversionJob job)
{
job.State = JobState.Running;
Notify(job);
@@ -331,7 +349,9 @@ private async Task> RunFileAsync(
: job.Preset.TargetFormat == BuiltInData.SameAsSource ? source.Id
: job.Preset.TargetFormat;
- var route = planner.Plan(source.Id, target);
+ // "Save every frame": a video becomes a folder of pictures in one step of its own. Never for an intermediate
+ // file, and never on the way to another format (video → JPG is otherwise one still frame).
+ var route = planner.Plan(source.Id, target, frames: finalOutput && job.Preset.SavesFramesOf(source.Id));
if (route is null)
return Fail(file, "error.no_route", $"{source.Id} → {target}");
@@ -378,7 +398,8 @@ private async Task> RunFileAsync(
// Turned once, in step turnAt: every other step gets the preset without rotation and flip,
// also one before it whose engine applies image options but doesn't say it turns.
var preset = s == turnAt ? job.Preset : unturned;
- var stepInfo = new ConversionStep(input, step.From, step.To, preset, allocator, workDir);
+ var stepInfo = new ConversionStep(input, step.From, step.To, preset, allocator, workDir, step.FrameSequence,
+ key => file.NoticeKey = key);
outputs.AddRange(await step.Converter.ConvertAsync(stepInfo, progress, ct));
}
finally
@@ -406,6 +427,11 @@ private async Task> RunFileAsync(
Notify(job);
return [];
}
+ catch (ConversionRefusedException ex)
+ {
+ _log.LogInformation("Converting {File} was refused: {Reason}", file.SourcePath, ex.Message);
+ return Fail(file, ex.ErrorKey, ex.Detail);
+ }
catch (Exception ex)
{
_log.LogWarning(ex, "Converting {File} failed", file.SourcePath);
@@ -457,14 +483,21 @@ private async Task WorkLoopAsync()
{
await foreach (var job in _channel.Reader.ReadAllAsync())
{
- if (job.CancellationToken.IsCancellationRequested)
+ try
+ {
+ if (job.CancellationToken.IsCancellationRequested)
+ {
+ job.State = JobState.Cancelled;
+ Notify(job);
+ JobFinished?.Invoke(this, job);
+ continue;
+ }
+ await RunAsync(job);
+ }
+ finally
{
- job.State = JobState.Cancelled;
- Notify(job);
- JobFinished?.Invoke(this, job);
- continue;
+ Interlocked.Decrement(ref _busy);
}
- await RunAsync(job);
}
}
diff --git a/src/Filee.Core/Conversion/RoutePlanner.cs b/src/Filee.Core/Conversion/RoutePlanner.cs
index 48573bd2..c9c6a7f4 100644
--- a/src/Filee.Core/Conversion/RoutePlanner.cs
+++ b/src/Filee.Core/Conversion/RoutePlanner.cs
@@ -9,7 +9,8 @@
namespace Filee.Core.Conversion;
/// One hop of a route.
-public sealed record RouteStep(IConverter Converter, string From, string To);
+/// The hop saves every frame of a video ().
+public sealed record RouteStep(IConverter Converter, string From, string To, bool FrameSequence = false);
/// A planned conversion from one format to another.
public sealed record Route(IReadOnlyList Steps, int Cost)
@@ -51,16 +52,32 @@ public RoutePlanner(IEnumerable converters, Func? p
}
/// Returns the cheapest route, or null if the conversion is impossible.
- public Route? Plan(string from, string to)
+ ///
+ /// Save every frame of a video (): the route is one
+ /// edge from to , or none.
+ /// Without it those edges are never taken.
+ ///
+ public Route? Plan(string from, string to, bool frames = false)
{
from = from.ToLowerInvariant();
to = to.ToLowerInvariant();
+ if (frames)
+ {
+ var sequence = EdgesFrom(from)
+ .Where(e => e.Edge.FrameSequence && e.Edge.To == to)
+ .OrderBy(e => e.Cost)
+ .FirstOrDefault();
+ return sequence.Converter is null
+ ? null
+ : new Route([new RouteStep(sequence.Converter, from, to, FrameSequence: true)], sequence.Cost);
+ }
+
// Same-format "conversions" (re-encode, split, compress) must use an explicit self edge.
if (from == to)
{
var best = EdgesFrom(from)
- .Where(e => e.Edge.To == to)
+ .Where(e => e.Edge.To == to && !e.Edge.FrameSequence)
.OrderBy(e => e.Cost)
.FirstOrDefault();
return best.Converter is null
@@ -89,7 +106,8 @@ public RoutePlanner(IEnumerable converters, Func? p
foreach (var (converter, edge, edgeCost) in EdgesFrom(node))
{
- if (edge.To == edge.From || (edge.ImageSourcesOnly && !imageSource) || (edge.NotForImageSources && imageSource)
+ if (edge.To == edge.From || edge.FrameSequence
+ || (edge.ImageSourcesOnly && !imageSource) || (edge.NotForImageSources && imageSource)
|| (edge.DirectOnly && (node != from || edge.To != to)))
continue;
var next = cost + edgeCost;
@@ -114,7 +132,8 @@ public RoutePlanner(IEnumerable converters, Func? p
}
/// True if can be converted to .
- public bool CanConvert(string from, string to) => Plan(from, to) is not null;
+ /// Every frame of a video as pictures (see ).
+ public bool CanConvert(string from, string to, bool frames = false) => Plan(from, to, frames) is not null;
private IReadOnlyList<(IConverter Converter, ConversionEdge Edge, int Cost)> EdgesFrom(string format) =>
_edgesFrom.TryGetValue(format, out var edges) ? edges : [];
diff --git a/src/Filee.Core/Platform/ElevationState.cs b/src/Filee.Core/Platform/ElevationState.cs
new file mode 100644
index 00000000..26b6a94b
--- /dev/null
+++ b/src/Filee.Core/Platform/ElevationState.cs
@@ -0,0 +1,57 @@
+// Whether Filee runs with more rights than the desktop shell. Windows keeps a lower process (Explorer) from sending
+// input to a higher one (User Interface Privilege Isolation): drops from Explorer are refused, the second Filee its
+// menu starts can't open the running Filee's mutex or pipe, and the running Filee can't read Explorer's selection.
+// The decision is plain logic over integrity levels, so it is tested everywhere; reading the levels is Windows code.
+
+namespace Filee.Core.Platform;
+
+/// Windows mandatory integrity levels, lowest first.
+public enum IntegrityLevel
+{
+ /// Not known: another operating system, or the token could not be read.
+ Unknown,
+ Untrusted,
+ Low,
+ Medium,
+ MediumPlus,
+ High,
+ System,
+}
+
+/// Integrity levels of this process and of the desktop shell (Explorer).
+/// This process.
+/// The process that owns the desktop window (Explorer), or Unknown when there is none.
+/// True when this process runs with a full administrator token (UAC "Run as administrator").
+public sealed record ElevationState(IntegrityLevel Process, IntegrityLevel Shell, bool Elevated)
+{
+ /// Nothing known (any OS but Windows).
+ public static ElevationState Unknown { get; } = new(IntegrityLevel.Unknown, IntegrityLevel.Unknown, false);
+
+ ///
+ /// True when Filee runs as administrator while Explorer doesn't: Explorer can't drop files on Filee or hand files
+ /// over through its menu. Unknown levels never count, and neither does a PC where Explorer runs elevated too
+ /// (UAC switched off).
+ ///
+ public bool ShellCannotReachFilee =>
+ Process >= IntegrityLevel.High && Shell != IntegrityLevel.Unknown && Shell < Process;
+
+ ///
+ /// Maps the last sub-authority of a mandatory label SID (S-1-16-rid) to a level. Values between the
+ /// well-known ones belong to the level below them, as Windows compares them numerically.
+ ///
+ public static IntegrityLevel FromRid(uint rid) => rid switch
+ {
+ >= 0x4000 => IntegrityLevel.System,
+ >= 0x3000 => IntegrityLevel.High,
+ >= 0x2100 => IntegrityLevel.MediumPlus,
+ >= 0x2000 => IntegrityLevel.Medium,
+ >= 0x1000 => IntegrityLevel.Low,
+ _ => IntegrityLevel.Untrusted,
+ };
+
+ /// For the log: "integrity High (elevated), Explorer Medium".
+ public override string ToString() =>
+ Process == IntegrityLevel.Unknown && Shell == IntegrityLevel.Unknown && !Elevated
+ ? "integrity unknown"
+ : $"integrity {Process}{(Elevated ? " (elevated)" : "")}, Explorer {Shell}";
+}
diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs
index 7a440a48..5a1b0d9f 100644
--- a/src/Filee.Core/Platform/IPlatformServices.cs
+++ b/src/Filee.Core/Platform/IPlatformServices.cs
@@ -31,6 +31,12 @@ static IReadOnlyList AppNames(IEnumerable names)
/// Files currently selected in the foreground file manager window (empty if none).
IReadOnlyList GetFileManagerSelection();
+ ///
+ /// For the log when a shortcut found no selected files: the foreground window's class and process
+ /// ("CabinetWClass / explorer"). Empty where the platform can't tell.
+ ///
+ string DescribeForegroundWindow() => "";
+
/// System drag threshold in the same units as screen coordinates.
int SystemDragThreshold { get; }
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index fd6ed8a3..7ec96870 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -65,7 +65,9 @@ .. ImageToolPresets(),
Id = "pdf-split", NameKey = "preset.split_pdf", TargetFormat = "pdf",
Pdf = { SplitPages = true }, Output = { Location = OutputLocation.Subfolder, SubfolderName = "{name}" },
},
+ .. PdfCompressionPresets(),
.. PdfSlidesPresets(),
+ .. FramePresets(),
new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" },
new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" },
.. HwpPresets(),
@@ -82,6 +84,19 @@ .. ModelPresets(),
.. TracePresets(),
];
+ ///
+ /// "Compress PDF": pictures at 150 dpi, the rest compressed losslessly (added in settings schema 20). On no default
+ /// donut, the PDF donut is full: users add it in Settings → Toolbar.
+ ///
+ public static List PdfCompressionPresets() =>
+ [
+ new()
+ {
+ Id = "compress-pdf", NameKey = "preset.compress_pdf", TargetFormat = "pdf",
+ Pdf = { Compression = PdfCompression.Ebook }, Output = { FileNamePattern = "{name}_compressed" },
+ },
+ ];
+
///
/// PDF → PowerPoint with a picture of each page on its own slide (added in settings schema 18). JPEG at 90 like the
/// JPG preset; the render DPI is the PDF default (150), which the converter never goes below.
@@ -91,6 +106,16 @@ public static List PdfSlidesPresets() =>
new() { Id = "pdf-slides", NameKey = "preset.pdf_slides", TargetFormat = "pptx", Image = { Quality = 90 } },
];
+ ///
+ /// Every frame of a video as PNG or JPG pictures in a folder (added in settings schema 19), on no donut: a long
+ /// video gives thousands of files, so users add them in Settings → Toolbar.
+ ///
+ public static List FramePresets() =>
+ [
+ new() { Id = "extract-frames-png", NameKey = "preset.extract_frames_png", TargetFormat = "png", Media = { SaveEveryFrame = true } },
+ new() { Id = "extract-frames-jpg", NameKey = "preset.extract_frames_jpg", TargetFormat = "jpg", Media = { SaveEveryFrame = true } },
+ ];
+
/// Raster → SVG tracing, in colour and as black line art (added in settings schema 16).
public static List TracePresets() =>
[
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 72550948..305d9b27 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -120,6 +120,16 @@ public sealed class Preset
[JsonIgnore]
public bool IsGroup => Kind == PresetKind.Group;
+ ///
+ /// True if converting a file saves every frame as a picture
+ /// (): the file is a video and the target one of
+ /// . Other files, and other targets, convert as usual.
+ ///
+ public bool SavesFramesOf(string sourceFormat) =>
+ Media.SaveEveryFrame
+ && MediaOptions.FrameTargets.Contains(TargetFormat)
+ && Formats.FormatRegistry.FindById(sourceFormat)?.Category == Formats.FormatCategory.Video;
+
/// Creates a new random preset id.
public static string NewId() => Guid.NewGuid().ToString("N")[..12];
@@ -277,6 +287,22 @@ public enum PdfPageSize
Letter,
}
+///
+/// How hard a PDF → PDF conversion shrinks the file: pictures are scaled down to the level's resolution and saved as
+/// JPEG at its quality, the rest is compressed losslessly. Text, fonts and drawings stay as they are.
+///
+public enum PdfCompression
+{
+ /// Pages are copied as they are.
+ None,
+ /// Smallest: pictures at 72 dpi (reading on screen).
+ Screen,
+ /// Balanced: pictures at 150 dpi (e-mail, uploads).
+ Ebook,
+ /// High quality: pictures at 300 dpi (printing).
+ Print,
+}
+
/// PDF related options.
public sealed class PdfOptions
{
@@ -312,6 +338,19 @@ public sealed class PdfOptions
///
public bool SplitTallImage { get; set; }
+ ///
+ /// For PDF → PDF: make the file smaller (also with a page range or one file per page). A level from a newer Filee
+ /// reads as .
+ ///
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public PdfCompression Compression { get; set; }
+
+ ///
+ /// With : also let Ghostscript rewrite the PDF when it is installed, and keep whichever
+ /// result is smaller. Off by default: it rewrites the whole file (fonts, structure), not only the pictures.
+ ///
+ public bool CompressWithGhostscript { get; set; }
+
public PdfOptions Clone() => (PdfOptions)MemberwiseClone();
}
@@ -367,6 +406,33 @@ public sealed class MediaOptions
/// Audio codec for targets that can hold several ().
public AudioCodec AudioCodec { get; set; } = AudioCodec.Default;
+ ///
+ /// Encode H.264 and HEVC video with the graphics chip's encoder (NVENC, Quick Sync, AMF, VideoToolbox or VA-API)
+ /// when one works on this computer, else with x264 / x265 as usual. Much faster and lighter on power, a little
+ /// larger at the same quality. Off by default (and for presets saved before it existed), so results never change
+ /// without the user asking for it.
+ ///
+ public bool HardwareEncoding { get; set; }
+
+ /// Picture formats a video's frames can be saved as ().
+ public static readonly IReadOnlyList FrameTargets = ["png", "jpg"];
+
+ ///
+ /// Video → PNG or JPG: save every frame as a picture, in a folder, instead of one still frame. Only for video
+ /// sources and a target in ().
+ ///
+ public bool SaveEveryFrame { get; set; }
+
+ /// With : extract every Nth frame (1 = every frame).
+ public int FrameInterval { get; set; } = 1;
+
+ ///
+ /// Most pictures one video may become (): about an hour at 30 fps, already more than
+ /// anyone looks through. A longer video needs a larger .
+ ///
+ public const long MaxFrames = 100_000;
+
+
public MediaOptions Clone() => (MediaOptions)MemberwiseClone();
}
@@ -413,6 +479,20 @@ public static class MediaCodecs
public static VideoCodec Effective(string target, VideoCodec codec) =>
VideoCodecsFor(target).Contains(codec) ? codec : VideoCodec.Default;
+ /// Video targets whose usual codec is H.264 (the others have their container's own: VP9, MPEG-2, …).
+ public static IReadOnlySet H264Targets { get; } = new HashSet { "mp4", "m4v", "mov", "mkv", "flv", "ts", "m2ts", "3gp", "3g2" };
+
+ ///
+ /// True when with is written as H.264 or HEVC, the codecs
+ /// applies to.
+ ///
+ public static bool TakesHardwareEncoding(string target, VideoCodec codec) => Effective(target, codec) switch
+ {
+ VideoCodec.Hevc => true,
+ VideoCodec.Default => H264Targets.Contains(target),
+ _ => false,
+ };
+
/// The codec is written with: the chosen one if the target takes it, else Default.
public static AudioCodec Effective(string target, AudioCodec codec) =>
AudioCodecsFor(target).Contains(codec) ? codec : AudioCodec.Default;
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index ed5434e3..f1fde541 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -64,7 +64,7 @@ public sealed class DonutSettings
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 18;
+ public const int CurrentSchemaVersion = 20;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index a97fbc85..44dd6d23 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -26,9 +26,25 @@ public UserDataStore(string directory, ILogger? log = null)
_log = log ?? (ILogger)NullLogger.Instance;
}
- /// Default data directory: %APPDATA%\Filee on Windows, ~/Library/Application Support/Filee on macOS.
+ ///
+ /// Environment variable that moves all of Filee's per-user data (settings, logs, downloaded engines) to another
+ /// folder and gives that copy its own single instance: a development or test copy then never touches, or talks
+ /// to, the Filee the user runs. A copy started with it also leaves the system integration alone.
+ ///
+ public const string DirectoryVariable = "FILEE_DATA_DIR";
+
+ /// The folder points to (full path), or null when it is not set.
+ public static string? OverrideDirectory => DirectoryFrom(Environment.GetEnvironmentVariable(DirectoryVariable));
+
+ ///
+ /// Default data directory: %APPDATA%\Filee on Windows, ~/Library/Application Support/Filee on macOS, or the
+ /// folder in .
+ ///
public static string DefaultDirectory =>
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Filee");
+ OverrideDirectory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Filee");
+
+ internal static string? DirectoryFrom(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : Path.TrimEndingDirectorySeparator(Path.GetFullPath(value.Trim()));
public string Directory { get; }
@@ -495,6 +511,44 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == "pdf-slides");
+ if (at < 0)
+ at = presets.FindIndex(p => p.Id == "pdf-split");
+ at = at < 0 ? presets.Count : at + 1;
+ foreach (var preset in BuiltInData.FramePresets())
+ {
+ if (presets.Any(p => p.Id == preset.Id))
+ continue;
+ presets.Insert(Math.Min(at++, presets.Count), preset);
+ changed = true;
+ }
+ }
+ // v20: "Compress PDF" (PDF → a smaller PDF), after "Split PDF" as in a new library, on no donut: the PDF donut
+ // is full. Present already but saved since by a Filee before 20 (back and forth between versions): that
+ // one dropped the compression level and left a preset that copies the pages, so the level is put back.
+ if (from < 20)
+ {
+ foreach (var compress in BuiltInData.PdfCompressionPresets())
+ {
+ if (presets.FirstOrDefault(p => p.Id == compress.Id) is { } existing)
+ {
+ if (existing.Pdf.Compression == PdfCompression.None && existing.TargetFormat == compress.TargetFormat)
+ {
+ existing.Pdf.Compression = compress.Pdf.Compression;
+ changed = true;
+ }
+ continue;
+ }
+ var split = presets.FindIndex(p => p.Id == "pdf-split");
+ presets.Insert(split < 0 ? presets.Count : split + 1, compress);
+ changed = true;
+ }
+ }
return changed;
}
diff --git a/src/Filee.Engines/Cad/CadFonts.cs b/src/Filee.Engines/Cad/CadFonts.cs
index 81def30a..eeca4ada 100644
--- a/src/Filee.Engines/Cad/CadFonts.cs
+++ b/src/Filee.Engines/Cad/CadFonts.cs
@@ -62,6 +62,21 @@ internal static class CadFonts
[ThreadStatic]
private static Dictionary? _measuringFonts;
+ // Bumped by ReleaseCache: each thread drops its measuring fonts the next time it measures (only it may touch them).
+ private static int _generation;
+ [ThreadStatic]
+ private static int _measuringGeneration;
+
+ /// Forgets the typefaces looked up so far ().
+ internal static void ReleaseCache()
+ {
+ Interlocked.Increment(ref _generation);
+ Families.Clear();
+ Fallbacks.Clear();
+ Defaults.Clear();
+ FontMetrics.Clear();
+ }
+
///
/// The typeface for a text style: its TrueType family if installed (family name or font file), else the
/// default sans-serif typeface. Never null.
@@ -140,6 +155,14 @@ private static GlyphRun Measure(string text, SKTypeface typeface)
///
private static SKFont MeasuringFont(SKTypeface typeface)
{
+ var generation = Volatile.Read(ref _generation);
+ if (_measuringFonts is not null && _measuringGeneration != generation)
+ {
+ foreach (var old in _measuringFonts.Values)
+ old.Dispose();
+ _measuringFonts = null;
+ }
+ _measuringGeneration = generation;
_measuringFonts ??= [];
if (!_measuringFonts.TryGetValue(typeface, out var font))
_measuringFonts[typeface] = font = CreateFont(typeface, MeasureSize);
diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs
index 33b16281..7d82f4b0 100644
--- a/src/Filee.Engines/EngineRegistry.cs
+++ b/src/Filee.Engines/EngineRegistry.cs
@@ -19,6 +19,7 @@
using Filee.Engines.Subtitles;
using Filee.Engines.Text;
using Filee.Engines.Vector;
+using Microsoft.Extensions.Logging;
namespace Filee.Engines;
@@ -30,7 +31,8 @@ public static class EngineRegistry
/// AppSettings.EnginePriority: built-in engines first, the optional downloads last. Software installed on the
/// system (Microsoft Office, a system-wide LibreOffice) is never used.
///
- public static IReadOnlyList CreateAll(EngineEnvironment env) =>
+ /// The app's log; FFmpeg writes which hardware encoders work and when one failed.
+ public static IReadOnlyList CreateAll(EngineEnvironment env, ILoggerFactory? loggers = null) =>
[
new MagickImageConverter(),
new PdfSharpConverter(),
@@ -51,7 +53,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env) =>
new EmlConverter(),
new DataConverter(),
new SubtitleConverter(),
- new FfmpegConverter(),
+ new FfmpegConverter(loggers?.CreateLogger()),
new CadConverter(),
new ModelConverter(),
new EbookConverter(),
diff --git a/src/Filee.Engines/Hwp/RhwpFonts.cs b/src/Filee.Engines/Hwp/RhwpFonts.cs
index c3056c4c..ed191e19 100644
--- a/src/Filee.Engines/Hwp/RhwpFonts.cs
+++ b/src/Filee.Engines/Hwp/RhwpFonts.cs
@@ -55,6 +55,9 @@ internal static class RhwpFonts
private static readonly ConcurrentDictionary Fonts = new(StringComparer.OrdinalIgnoreCase);
+ /// Forgets the fonts looked up so far ().
+ internal static void ReleaseCache() => Fonts.Clear();
+
/// Replacements by (slot, face): the language, coverage and CFF2 rules above.
internal static Dictionary<(string Slot, string Face), string> Plan(
IReadOnlyList<(string Slot, string Face)> slots, string text, Func lookup)
diff --git a/src/Filee.Engines/Infrastructure/EngineCaches.cs b/src/Filee.Engines/Infrastructure/EngineCaches.cs
new file mode 100644
index 00000000..0e84003f
--- /dev/null
+++ b/src/Filee.Engines/Infrastructure/EngineCaches.cs
@@ -0,0 +1,21 @@
+// Caches engines keep between conversions (typefaces looked up for HWP and CAD text). The app releases them while no
+// conversion runs, so a Filee waiting in the tray gives the memory back. Skia's own caches stay: they are shared with
+// the UI's render thread, and purging them while it draws crashed in native code (SKGraphics.PurgeAllCaches).
+
+using Filee.Engines.Cad;
+using Filee.Engines.Hwp;
+
+namespace Filee.Engines.Infrastructure;
+
+public static class EngineCaches
+{
+ ///
+ /// Drops what the engines cache between conversions. Call only while no conversion runs: the next one looks
+ /// everything up again.
+ ///
+ public static void Release()
+ {
+ RhwpFonts.ReleaseCache();
+ CadFonts.ReleaseCache();
+ }
+}
diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
index 77db4ef3..a94979e6 100644
--- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
+++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs
@@ -5,6 +5,7 @@
// the PC (on the PATH or where its installer puts it), so nobody has to download a second FFmpeg they already have.
using System.Collections.Concurrent;
+using Filee.Core.Settings;
namespace Filee.Engines.Infrastructure;
@@ -18,10 +19,15 @@ public sealed class EngineEnvironment(string dataDirectory)
///
/// Per-user folder for engines downloaded on demand (see ):
/// %LOCALAPPDATA%\Filee\engines. It is outside the app folder, so app updates keep it, and inside Filee's
- /// install root, so uninstalling removes it.
+ /// install root, so uninstalling removes it. With FILEE_DATA_DIR set ()
+ /// it is the engines folder in there.
///
- public static string DownloadRoot { get; set; } =
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Filee", "engines");
+ public static string DownloadRoot { get; set; } = DefaultDownloadRoot(UserDataStore.OverrideDirectory);
+
+ internal static string DefaultDownloadRoot(string? dataDirectoryOverride) =>
+ dataDirectoryOverride is not null
+ ? Path.Combine(dataDirectoryOverride, "engines")
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Filee", "engines");
///
/// Finds an engine directory (engines/<name>): next to the executable (bundled with the installer),
@@ -142,10 +148,48 @@ public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null
SystemCopyFolder(id) is { } folder ? Path.Combine(folder, ProgramName(program)) : null;
/// Searches the PC again (Settings → Engines → Check again, settings changes).
- public static void ForgetSystemCopies() => SystemCopies.Clear();
+ public static void ForgetSystemCopies()
+ {
+ SystemCopies.Clear();
+ ReachableShares.Clear();
+ }
private static string? FindOnSystem(string id) =>
- SystemFolders(id).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null);
+ SystemFolders(id).Where(IsReachable).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null);
+
+ /// How long a network share on the PATH may take to answer before the search leaves it out.
+ private static readonly TimeSpan ShareTimeout = TimeSpan.FromSeconds(2);
+
+ private static readonly ConcurrentDictionary ReachableShares = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// False for a folder on a network share that doesn't answer in time. A PATH entry on a server that is offline
+ /// (a mapped work share at home) made every file check wait for the network timeout, many seconds each, and the
+ /// search runs for every engine. Each share is asked once, until .
+ ///
+ private static bool IsReachable(string folder)
+ {
+ if (ShareOf(folder) is not { } share)
+ return true;
+ return ReachableShares.GetOrAdd(share, s =>
+ {
+ var probe = Task.Run(() => Directory.Exists(s));
+ return probe.Wait(ShareTimeout) && probe.Result;
+ });
+ }
+
+ ///
+ /// The share root of a UNC path (\\server\share), or null for a local path. Device paths (\\?\,
+ /// \\.\) are local. Plain string work, the same on every OS.
+ ///
+ internal static string? ShareOf(string path)
+ {
+ if (!path.StartsWith(@"\\", StringComparison.Ordinal) || path.StartsWith(@"\\?\", StringComparison.Ordinal)
+ || path.StartsWith(@"\\.\", StringComparison.Ordinal))
+ return null;
+ var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries);
+ return parts.Length >= 2 ? $@"\\{parts[0]}\{parts[1]}" : parts.Length == 1 ? $@"\\{parts[0]}" : null;
+ }
private static IEnumerable SystemFolders(string id)
{
diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs
index 5bc2877a..029416ca 100644
--- a/src/Filee.Engines/Media/FfmpegConverter.cs
+++ b/src/Filee.Engines/Media/FfmpegConverter.cs
@@ -1,27 +1,42 @@
// Video and audio conversion with FFmpeg (GPL-3.0 build, an optional download run as a separate program in
-// engines/ffmpeg): video ↔ video (H.264, HEVC, AV1, …), video → animated GIF / APNG or a still frame, audio
-// extraction, subtitle extraction (the first text track as SRT / ASS / WebVTT), audio ↔ audio and GIF, APNG or a still
-// picture → video, and images → HEIC (FFmpeg's HEVC encoder, Filee's own container writer).
-// Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner.
+// engines/ffmpeg): video ↔ video (H.264, HEVC, AV1, …), video → animated GIF / APNG, a still frame or every frame
+// (FrameSequenceWriter), audio extraction, subtitle extraction (the first text track as SRT / ASS / WebVTT), audio ↔
+// audio and GIF, APNG or a still picture → video, and images → HEIC (FFmpeg's HEVC encoder, Filee's own container
+// writer). Container and codec choices live in MediaEncoding, the process handling in FfmpegRunner, hardware encoders
+// in HardwareVideo (options) and HardwareProbe (which one works here).
using System.Collections.Concurrent;
-using System.Diagnostics;
using System.Text.RegularExpressions;
using Filee.Core.Conversion;
using Filee.Core.Formats;
+using Filee.Core.Presets;
using Filee.Engines.Heic;
using Filee.Engines.Infrastructure;
using Filee.Engines.Magick;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
namespace Filee.Engines.Media;
/// Converts video and audio files with Filee's own copy of FFmpeg.
-public sealed partial class FfmpegConverter : IConverter, IImageTransformer
+/// Receives which hardware encoders work and why a hardware encode was redone in software.
+public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer
{
private static readonly ConcurrentDictionary Versions = new();
+ /// ffmpeg -version and -encoders answer at once; a copy that does not is broken or hangs.
+ internal static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(10);
+
+ private readonly ILogger _logger = logger ?? NullLogger.Instance;
+
private (string Ffmpeg, string Ffprobe)? _tools;
+ ///
+ /// Picks the hardware encoder for (ffmpeg, codec, 10-bit, encoders) instead of .
+ /// Tests put an encoder here that FFmpeg does not have, to see the software retry.
+ ///
+ internal Func?, CancellationToken, Task>? SelectHardware { get; init; }
+
public string Id => "ffmpeg";
public string DisplayName => "FFmpeg";
@@ -35,6 +50,7 @@ public sealed partial class FfmpegConverter : IConverter, IImageTransformer
///
/// Every video format (read by FFmpeg's demuxers) → every video target, animated GIF or APNG, a still PNG frame,
+ /// every frame as PNG or JPG pictures (only when a preset asks for it, see ),
/// every audio target and SRT / ASS / WebVTT (its subtitles); every audio format → every audio target (including
/// itself: re-encode with another bitrate); GIF, APNG and PNG → MP4, WEBM, MOV; every image ImageMagick reads →
/// HEIC. Only formats the pinned build can write are targets (see MediaEncoding).
@@ -55,6 +71,8 @@ private static List BuildEdges()
return
[
.. from source in videos from target in videoTargets select new ConversionEdge(source, target),
+ // "Save every frame": a folder of numbered pictures, only when a preset asks for it (never in another route).
+ .. from source in videos from target in MediaOptions.FrameTargets select new ConversionEdge(source, target, FrameSequence: true),
// Subtitles out of a video. SRT is the hub to the other subtitle formats (the subtitle engine takes it on),
// so ASS and WebVTT cost a little more and are only taken when they are the target.
.. from source in videos from target in MediaEncoding.SubtitleTargets.Keys
@@ -95,6 +113,9 @@ public async Task> ConvertAsync(ConversionStep step, IProg
var info = await MediaInfo.ProbeAsync(ffprobe, input, cancellationToken);
progress?.Report(0.02);
+ if (step.FrameSequence)
+ return await FrameSequenceWriter.ConvertAsync(ffmpeg, step, input, info, progress, cancellationToken);
+
var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
if (output is null)
return [];
@@ -107,21 +128,28 @@ public async Task> ConvertAsync(ConversionStep step, IProg
? await CoverArt.PrepareAsync(ffmpeg, input, coverStream, step.To, step.WorkDirectory, cancellationToken)
: null;
+ var media = step.Preset.Media;
+ var encoders = still ? null : EncodersOf(ffmpeg);
+ // Hardware encoding (preset option): the first GPU encoder that passes its test encode, if any.
+ HardwareEncoder? hardware = null;
+ if (!still && MediaEncoding.HardwareCodec(step.To, media, info) is { } codec)
+ {
+ var tenBit = HardwareVideo.TenBit(codec, info.Video!);
+ hardware = SelectHardware is { } select
+ ? await select(ffmpeg, codec, tenBit, encoders, cancellationToken)
+ : await HardwareProbe.SelectAsync(ffmpeg, codec, tenBit, encoders, _logger, cancellationToken);
+ }
+
+ IReadOnlyList Plan(HardwareEncoder? encoder) => still
+ ? MediaEncoding.PlanStill(input, output, step.To, media, info)
+ : MediaEncoding.Plan(input, output, step.To, media, info, step.WorkDirectory, cover, encoders, encoder);
// Throws for a missing stream before anything is written (an existing file of that name stays untouched).
- var passes = still
- ? MediaEncoding.PlanStill(input, output, step.To, step.Preset.Media, info)
- : MediaEncoding.Plan(input, output, step.To, step.Preset.Media, info, step.WorkDirectory, cover, EncodersOf(ffmpeg));
+ var passes = Plan(hardware);
var duration = still ? MediaEncoding.StillDuration : info.Duration;
try
{
- var done = 0.0;
- foreach (var pass in passes)
- {
- await RunPassAsync(ffmpeg, pass, duration, progress, done, cancellationToken);
- done += pass.Weight;
- }
- if (!File.Exists(output) || new FileInfo(output).Length == 0)
- throw new InvalidOperationException("FFmpeg did not write any output.");
+ await RunWithFallbackAsync(passes, hardware, Plan,
+ planned => RunPassesAsync(ffmpeg, planned, output, duration, progress, cancellationToken), output, _logger, cancellationToken);
}
catch
{
@@ -134,8 +162,51 @@ public async Task> ConvertAsync(ConversionStep step, IProg
return [output];
}
+ ///
+ /// Runs , planned with . When a hardware encode fails (a driver
+ /// error, a picture larger than the chip encodes, …) the file is encoded again as plans it
+ /// without hardware: an encoder that passed its test encode can still refuse one particular video, and the user
+ /// asked for a converted file, not for the GPU. The failure is logged, not reported. Cancellation is never retried.
+ ///
+ internal static async Task RunWithFallbackAsync(IReadOnlyList passes, HardwareEncoder? hardware,
+ Func> plan, Func, Task> run, string output, ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ if (hardware is null)
+ {
+ await run(passes);
+ return;
+ }
+ try
+ {
+ await run(passes);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException && !cancellationToken.IsCancellationRequested)
+ {
+ logger.LogWarning("{File}: hardware encoder {Encoder} failed ({Reason}); encoding it again with the software encoder",
+ output, hardware.Name, ex.Message);
+ TryDelete(output);
+ // An FFmpeg without x265 says so here: the hardware encoder was the only way to write HEVC.
+ await run(plan(null));
+ }
+ }
+
+ /// Runs every pass, then checks that FFmpeg wrote something.
+ private static async Task RunPassesAsync(string ffmpeg, IReadOnlyList passes, string output, TimeSpan? duration,
+ IProgress? progress, CancellationToken cancellationToken)
+ {
+ var done = 0.0;
+ foreach (var pass in passes)
+ {
+ await RunPassAsync(ffmpeg, pass, duration, progress, done, cancellationToken);
+ done += pass.Weight;
+ }
+ if (!File.Exists(output) || new FileInfo(output).Length == 0)
+ throw new InvalidOperationException("FFmpeg did not write any output.");
+ }
+
/// Runs one pass and maps FFmpeg's position (seconds) onto this pass' share of the progress.
- private static async Task RunPassAsync(string ffmpeg, FfmpegPass pass, TimeSpan? duration, IProgress? progress, double done, CancellationToken cancellationToken)
+ internal static async Task RunPassAsync(string ffmpeg, FfmpegPass pass, TimeSpan? duration, IProgress? progress, double done, CancellationToken cancellationToken)
{
var total = duration?.TotalSeconds ?? 0;
Action? onPosition = progress is null || total <= 0
@@ -165,26 +236,23 @@ internal static (string Ffmpeg, string Ffprobe)? Locate()
}
/// "9.0.2" from ffmpeg -version; cached per executable so re-checking the engines stays cheap.
- private static string VersionOf(string ffmpeg) =>
+ /// FFmpeg does not start, fails or does not answer within .
+ internal static string VersionOf(string ffmpeg, TimeSpan? timeout = null) =>
Versions.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ =>
{
- using var process = Process.Start(new ProcessStartInfo(ffmpeg, "-hide_banner -version")
+ ProcessResult result;
+ try
{
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- }) ?? throw new InvalidOperationException("FFmpeg could not be started.");
- var firstLine = process.StandardOutput.ReadLine() ?? "";
- process.StandardOutput.ReadToEnd();
- if (!process.WaitForExit(10_000))
+ result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-version"], timeout ?? QueryTimeout);
+ }
+ catch (TimeoutException)
{
- process.Kill(entireProcessTree: true);
throw new InvalidOperationException("FFmpeg did not start in time.");
}
- if (process.ExitCode != 0)
- throw new InvalidOperationException($"FFmpeg does not start (exit code {process.ExitCode}).");
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException($"FFmpeg does not start (exit code {result.ExitCode}).");
// "ffmpeg version 9.0.2-full_build-www.gyan.dev Copyright …" → "9.0.2"
+ var firstLine = result.StandardOutput.Split('\n', 2)[0];
var match = VersionPattern().Match(firstLine);
return match.Success ? match.Groups[1].Value : firstLine.Trim();
});
@@ -198,38 +266,29 @@ private static string VersionOf(string ffmpeg) =>
/// The encoders of (ffmpeg -encoders), cached per executable: a copy the user
/// picked may lack x265 or SVT-AV1. Null (assume Filee's build) when the list can't be read.
///
- private static IReadOnlySet? EncodersOf(string ffmpeg) =>
+ internal static IReadOnlySet? EncodersOf(string ffmpeg, TimeSpan? timeout = null) =>
Encoders.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ =>
{
try
{
- using var process = Process.Start(new ProcessStartInfo(ffmpeg, "-hide_banner -encoders")
- {
- UseShellExecute = false,
- CreateNoWindow = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- });
- if (process is null)
- return null;
- process.ErrorDataReceived += (_, _) => { };
- process.BeginErrorReadLine();
- var list = process.StandardOutput.ReadToEnd();
- if (!process.WaitForExit(10_000) || process.ExitCode != 0)
- return null;
- // " V....D libx265 libx265 H.265 / HEVC (codec hevc)"
- return list.Split('\n')
- .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
- .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] is 'V' or 'A' or 'S')
- .Select(parts => parts[1])
- .ToHashSet(StringComparer.Ordinal);
+ var result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-encoders"], timeout ?? QueryTimeout);
+ return result.ExitCode == 0 ? ParseEncoders(result.StandardOutput) : null;
}
- catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
+ catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
{
return null;
}
});
+ /// The encoder names in ffmpeg -encoders output.
+ internal static IReadOnlySet ParseEncoders(string list) =>
+ // " V....D libx265 libx265 H.265 / HEVC (codec hevc)"
+ list.Split('\n')
+ .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] is 'V' or 'A' or 'S')
+ .Select(parts => parts[1])
+ .ToHashSet(StringComparer.Ordinal);
+
private static void TryDelete(string path)
{
try
diff --git a/src/Filee.Engines/Media/FfmpegRunner.cs b/src/Filee.Engines/Media/FfmpegRunner.cs
index b4cb60b4..c4497197 100644
--- a/src/Filee.Engines/Media/FfmpegRunner.cs
+++ b/src/Filee.Engines/Media/FfmpegRunner.cs
@@ -108,6 +108,48 @@ public static async Task RunAsync(
return new ProcessResult(process.ExitCode, "", stderr);
}
+ ///
+ /// Runs a short query (ffmpeg -version, -encoders) synchronously. Both streams are read in the
+ /// background, so the time limit holds even when the program hangs with its output open (reading it to the end
+ /// first would wait for the hang).
+ ///
+ /// The program did not exit within ; it was killed.
+ /// The program could not be started.
+ internal static ProcessResult RunBriefly(string executable, IReadOnlyList arguments, TimeSpan timeout)
+ {
+ var psi = new ProcessStartInfo(executable)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ foreach (var argument in arguments)
+ psi.ArgumentList.Add(argument);
+
+ using var process = new Process { StartInfo = psi };
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+ process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (stdout) stdout.Append(e.Data).Append('\n'); };
+ process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (stderr) stderr.Append(e.Data).Append('\n'); };
+ if (!process.Start())
+ throw new InvalidOperationException($"Could not start {executable}.");
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ if (!process.WaitForExit(timeout))
+ {
+ Kill(process);
+ throw new TimeoutException($"{Path.GetFileName(executable)} did not finish within {timeout.TotalSeconds:0} s.");
+ }
+ // Make sure the asynchronous readers have delivered the last lines.
+ process.WaitForExit();
+ lock (stdout)
+ lock (stderr)
+ return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString());
+ }
+
///
/// The position in seconds from one line of -progress output (out_time_us=2000000) or of the
/// palette pass' frame stats (frame_time=2.5); null for every other line.
@@ -149,12 +191,20 @@ private static string StripPrefix(string line)
return line;
}
+ /// How long a killed FFmpeg may take to exit (and let go of its output) before the caller moves on.
+ private static readonly TimeSpan ExitAfterKill = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Kills FFmpeg and waits until it has exited: Kill only asks Windows to end it, and a process that still runs
+ /// holds its output open, so the half-written file or folder of frames could not be deleted yet.
+ ///
private static void Kill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
+ process.WaitForExit(ExitAfterKill);
}
catch (InvalidOperationException) { }
catch (System.ComponentModel.Win32Exception) { }
diff --git a/src/Filee.Engines/Media/FrameSequenceWriter.cs b/src/Filee.Engines/Media/FrameSequenceWriter.cs
new file mode 100644
index 00000000..341c7b26
--- /dev/null
+++ b/src/Filee.Engines/Media/FrameSequenceWriter.cs
@@ -0,0 +1,102 @@
+// Video → every frame as a picture ("Save every frame", MediaOptions.SaveEveryFrame): numbered PNG or JPG files in
+// one folder, for focus stacking or picking the sharpest shot. The pictures are written into a hidden folder next to
+// the output and moved into place when FFmpeg is done, so a cancelled or failed run leaves nothing behind.
+
+using System.Globalization;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using Filee.Engines.Archives;
+
+namespace Filee.Engines.Media;
+
+/// Writes the frames of a video as numbered pictures into a folder.
+internal static class FrameSequenceWriter
+{
+ /// Name suffix of the output folder: "clip.mp4" → "clip_frames".
+ public const string FolderSuffix = "_frames";
+
+ ///
+ /// Saves the frames of as pictures (frame_000001.png, …)
+ /// into the folder the output rule names: "clip_frames", "clip_frames (2)" when that exists, or skipped, like any
+ /// other output.
+ ///
+ public static async Task> ConvertAsync(string ffmpeg, ConversionStep step, string input, MediaInfo info,
+ IProgress? progress, CancellationToken cancellationToken)
+ {
+ var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
+ var extension = FormatRegistry.Get(step.To).PrimaryExtension;
+ var interval = Math.Max(1, step.Preset.Media.FrameInterval);
+ var frameCount = EstimatedFrames(info, interval);
+ if (frameCount > MediaOptions.MaxFrames)
+ {
+ throw new ConversionRefusedException("error.too_many_frames",
+ string.Create(CultureInfo.InvariantCulture, $"{frameCount:N0} > {MediaOptions.MaxFrames:N0}"));
+ }
+
+ var output = step.Output.Allocate("", FolderSuffix);
+ if (output is null)
+ return [];
+
+ if (frameCount is { } count && FreeSpace(output) is { } free && EstimatedBytes(video, extension, count) is var needed && needed > free)
+ throw new ConversionRefusedException("error.not_enough_space", $"{Gigabytes(needed)} > {Gigabytes(free)}");
+
+ // Same folder as the output (same drive, so the move is instant); the frames go into a visible folder inside
+ // it, which becomes the output.
+ var staging = Path.Combine(Path.GetDirectoryName(output)!, $".{Path.GetFileName(output)}.filee-{Guid.NewGuid().ToString("N")[..8]}");
+ Directory.CreateDirectory(staging).Attributes |= FileAttributes.Hidden;
+ try
+ {
+ var frames = Directory.CreateDirectory(Path.Combine(staging, "frames")).FullName;
+ // FFmpeg reads % in the whole path as part of the number pattern: a literal one is written %%.
+ var pattern = Path.Combine(frames.Replace("%", "%%", StringComparison.Ordinal), $"frame_%06d.{extension}");
+ var arguments = MediaEncoding.FrameSequence(input, pattern, extension, video, step.Preset.Image.Quality, interval);
+
+ await FfmpegConverter.RunPassAsync(ffmpeg, new FfmpegPass(arguments, 1), info.Duration, progress, 0, cancellationToken);
+ if (!Directory.EnumerateFiles(frames).Any())
+ throw new InvalidOperationException("FFmpeg did not write any frames.");
+
+ ArchiveFiles.MoveFolder(frames, output);
+ progress?.Report(1);
+ return [output];
+ }
+ finally
+ {
+ // FFmpeg has exited by now (FfmpegRunner waits for it after killing it), so nothing holds these files.
+ ArchiveFiles.TryDeleteFolder(staging);
+ }
+ }
+
+ /// Pictures the video gives: duration × frame rate ÷ interval; null when the file doesn't say.
+ internal static long? EstimatedFrames(MediaInfo info, int interval) =>
+ info.Duration is { TotalSeconds: > 0 } duration && info.Video is { FrameRate: > 0 } video
+ ? (long)Math.Ceiling(duration.TotalSeconds * video.FrameRate / Math.Max(1, interval))
+ : null;
+
+ ///
+ /// A generous guess of the pictures' size on disk: PNG about half of raw RGB (twice that for 10-bit video, which
+ /// becomes 16-bit PNG), JPG about a quarter byte per pixel at the usual qualities.
+ ///
+ internal static long EstimatedBytes(VideoStreamInfo video, string extension, long frames)
+ {
+ var pixels = (double)Math.Max(1, video.Width * video.SampleAspect) * Math.Max(1, video.Height);
+ var perPicture = extension == "png" ? pixels * 1.5 * (video.IsHighBitDepth ? 2 : 1) : pixels * 0.25;
+ return (long)Math.Min(perPicture * frames, long.MaxValue);
+ }
+
+ /// Free bytes on the drive of ; null where that can't be read (some network shares).
+ private static long? FreeSpace(string path)
+ {
+ try
+ {
+ return Path.GetPathRoot(Path.GetFullPath(path)) is { Length: > 0 } root ? new DriveInfo(root).AvailableFreeSpace : null;
+ }
+ catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static string Gigabytes(long bytes) =>
+ string.Create(CultureInfo.InvariantCulture, $"{bytes / 1e9:0.0} GB");
+}
diff --git a/src/Filee.Engines/Media/HardwareProbe.cs b/src/Filee.Engines/Media/HardwareProbe.cs
new file mode 100644
index 00000000..eaaff759
--- /dev/null
+++ b/src/Filee.Engines/Media/HardwareProbe.cs
@@ -0,0 +1,68 @@
+// Finds out which hardware video encoder works on this computer. FFmpeg lists every encoder it was built with, also
+// NVENC without an NVIDIA card or Quick Sync without an Intel GPU, so each candidate gets a tiny test encode first. The
+// result is kept per FFmpeg executable, encoder and bit depth for as long as Filee runs.
+
+using System.Collections.Concurrent;
+using Filee.Engines.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+namespace Filee.Engines.Media;
+
+/// Picks the first hardware encoder whose test encode succeeds.
+internal static class HardwareProbe
+{
+ ///
+ /// A test encode takes 0.1–2 s (Quick Sync loads its runtime on the first one); a driver that hangs is given up on
+ /// after this long and the software encoder is used.
+ ///
+ public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
+
+ private static readonly ConcurrentDictionary>> Results = new();
+
+ ///
+ /// The hardware encoder to write ("h264" or "hevc") with, or null when none works here
+ /// (then x264 / x265 encode as usual).
+ ///
+ /// The video is encoded in 10 bits (HEVC Main 10): an encoder must handle that too.
+ /// The encoders lists; null when the list could not be read.
+ public static async Task SelectAsync(string ffmpeg, string codec, bool tenBit, IReadOnlySet? encoders,
+ ILogger logger, CancellationToken cancellationToken)
+ {
+ var vaapi = HardwareVideo.Current == HostPlatform.Linux && File.Exists(HardwareVideo.VaapiDevice);
+ foreach (var candidate in HardwareVideo.Candidates(codec, HardwareVideo.Current, encoders, vaapi))
+ {
+ // The probe itself is not cancelled with the conversion: other conversions may be waiting for its result.
+ if (await WorksAsync(ffmpeg, candidate, tenBit, logger).WaitAsync(cancellationToken))
+ return candidate;
+ }
+ return null;
+ }
+
+ /// The cached result of the test encode of (run on first use).
+ internal static Task WorksAsync(string ffmpeg, HardwareEncoder encoder, bool tenBit, ILogger logger) =>
+ Results.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}|{encoder.Name}|{encoder.RateControl}|{(tenBit ? 10 : 8)}",
+ _ => new Lazy>(() => ProbeAsync(ffmpeg, encoder, tenBit, logger))).Value;
+
+ private static async Task ProbeAsync(string ffmpeg, HardwareEncoder encoder, bool tenBit, ILogger logger)
+ {
+ var bits = tenBit ? 10 : 8;
+ try
+ {
+ var result = await ProcessRunner.RunAsync(ffmpeg, HardwareVideo.ProbeArguments(encoder, tenBit), Timeout, CancellationToken.None);
+ if (result.ExitCode == 0)
+ {
+ logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) works", encoder.Name, encoder.RateControl, bits);
+ return true;
+ }
+ logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) is not usable: {Reason}", encoder.Name,
+ encoder.RateControl, bits, FfmpegRunner.Summarize(result.StandardError));
+ return false;
+ }
+ catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
+ {
+ logger.LogInformation("Hardware encoder {Encoder} ({RateControl}, {Bits}-bit) is not usable: {Reason}", encoder.Name,
+ encoder.RateControl, bits, ex.Message);
+ return false;
+ }
+ }
+}
diff --git a/src/Filee.Engines/Media/HardwareVideo.cs b/src/Filee.Engines/Media/HardwareVideo.cs
new file mode 100644
index 00000000..8253460c
--- /dev/null
+++ b/src/Filee.Engines/Media/HardwareVideo.cs
@@ -0,0 +1,229 @@
+// Hardware video encoders (NVENC, Quick Sync, AMF, VideoToolbox, VA-API) for H.264 and HEVC: which ones to try on
+// which system, and their FFmpeg options. Pure (no processes), so every choice is unit tested without a GPU; whether
+// an encoder actually works on this computer is found out by HardwareProbe.
+
+using System.Globalization;
+using Filee.Core.Presets;
+
+namespace Filee.Engines.Media;
+
+/// The vendor interface a hardware encoder goes through; it decides the encoder's options.
+internal enum HardwareApi
+{
+ /// NVIDIA (h264_nvenc, hevc_nvenc).
+ Nvenc,
+
+ /// Intel Quick Sync Video (h264_qsv, hevc_qsv).
+ Qsv,
+
+ /// AMD Advanced Media Framework (h264_amf, hevc_amf).
+ Amf,
+
+ /// Apple VideoToolbox (h264_videotoolbox, hevc_videotoolbox).
+ VideoToolbox,
+
+ /// Linux VA-API (h264_vaapi, hevc_vaapi): frames are uploaded to the GPU first.
+ Vaapi,
+}
+
+/// How the preset's quality reaches a hardware encoder.
+internal enum HardwareRateControl
+{
+ /// A constant quality or quantizer, like x264's CRF.
+ Quality,
+
+ ///
+ /// An average bitrate from the picture size and frame rate: VideoToolbox on Intel Macs has no constant quality mode
+ /// (FFmpeg rejects -q:v there).
+ ///
+ Bitrate,
+}
+
+/// The operating systems hardware encoders are chosen for.
+internal enum HostPlatform
+{
+ Windows,
+ MacOS,
+ Linux,
+ Other,
+}
+
+/// One hardware encoder of FFmpeg, with the way it is told the quality.
+/// FFmpeg encoder name, e.g. hevc_nvenc.
+internal sealed record HardwareEncoder(string Name, HardwareApi Api, HardwareRateControl RateControl = HardwareRateControl.Quality)
+{
+ /// "h264" or "hevc": the part before the underscore.
+ public string Codec => Name[..Name.IndexOf('_', StringComparison.Ordinal)];
+}
+
+/// Hardware encoder candidates and command-line options.
+internal static class HardwareVideo
+{
+ ///
+ /// The first DRM render node: the integrated or only GPU on almost every Linux system. A second GPU has
+ /// renderD129, but NVIDIA's (the usual second one) is taken through NVENC, which is tried first.
+ ///
+ public const string VaapiDevice = "/dev/dri/renderD128";
+
+ /// The system Filee runs on.
+ public static HostPlatform Current { get; } =
+ OperatingSystem.IsWindows() ? HostPlatform.Windows
+ : OperatingSystem.IsMacOS() ? HostPlatform.MacOS
+ : OperatingSystem.IsLinux() ? HostPlatform.Linux
+ : HostPlatform.Other;
+
+ ///
+ /// The hardware encoders for ("h264" or "hevc") worth trying, best first. Windows:
+ /// NVIDIA, then Intel, then AMD (a laptop with an Intel chip and an NVIDIA card encodes on the card). macOS:
+ /// VideoToolbox with constant quality (Apple Silicon), then with a bitrate (Intel Macs). Linux: NVIDIA, then
+ /// VA-API when a render node exists. Only encoders the FFmpeg in use lists are tried (all of them when the list
+ /// could not be read); the probe decides which one really works.
+ ///
+ public static IReadOnlyList Candidates(string codec, HostPlatform platform, IReadOnlySet? encoders, bool vaapiDevice)
+ {
+ if (codec is not ("h264" or "hevc"))
+ return [];
+ List candidates = platform switch
+ {
+ HostPlatform.Windows => [new($"{codec}_nvenc", HardwareApi.Nvenc), new($"{codec}_qsv", HardwareApi.Qsv), new($"{codec}_amf", HardwareApi.Amf)],
+ HostPlatform.MacOS =>
+ [
+ new($"{codec}_videotoolbox", HardwareApi.VideoToolbox),
+ new($"{codec}_videotoolbox", HardwareApi.VideoToolbox, HardwareRateControl.Bitrate),
+ ],
+ HostPlatform.Linux => vaapiDevice
+ ? [new($"{codec}_nvenc", HardwareApi.Nvenc), new($"{codec}_vaapi", HardwareApi.Vaapi)]
+ : [new($"{codec}_nvenc", HardwareApi.Nvenc)],
+ _ => [],
+ };
+ return encoders is null ? candidates : [.. candidates.Where(c => encoders.Contains(c.Name))];
+ }
+
+ ///
+ /// True when the video is encoded in 10 bits: HEVC keeps 10-bit and HDR sources in Main 10 (as x265 does). H.264
+ /// is always 8-bit 4:2:0, the only format every player decodes (as with x264).
+ ///
+ public static bool TenBit(string codec, VideoStreamInfo video) => codec == "hevc" && (video.IsHighBitDepth || video.IsHdr);
+
+ ///
+ /// The preset's quality on the encoders' 0–51 quantizer scale (NVENC -cq, Quick Sync -global_quality,
+ /// AMF and VA-API -qp): one above x264's CRF 18/23/28 for H.264 and x265's 22/26/30 for HEVC. Hardware
+ /// encoders lack x264/x265's psychovisual tuning and spend bits less wisely, so the same number gives a larger
+ /// file; one step coarser keeps the size close at a slightly lower quality.
+ ///
+ public static int Quantizer(string codec, MediaQuality quality) => codec == "hevc"
+ ? Choose(quality, 23, 27, 31)
+ : Choose(quality, 19, 24, 29);
+
+ /// VideoToolbox's constant quality (-q:v, 1–100, higher is better) on Apple Silicon.
+ public static int VideoToolboxQuality(MediaQuality quality) => Choose(quality, 70, 60, 50);
+
+ ///
+ /// The average bitrate (kbit/s) for VideoToolbox on Intel Macs: bits per pixel per frame of the output picture,
+ /// e.g. 1080p at 30 fps gets about 9 / 5.6 / 3.1 Mbit/s as H.264 and two thirds of that as HEVC.
+ ///
+ public static int BitrateKbps(string codec, MediaQuality quality, VideoStreamInfo video, int maxHeight)
+ {
+ double height = video.DisplayHeight > 0 ? video.DisplayHeight : 1080;
+ if (maxHeight > 0 && height > Math.Max(16, maxHeight))
+ height = Math.Max(16, maxHeight);
+ var width = height * (video.DisplayAspect > 0 && double.IsFinite(video.DisplayAspect) ? video.DisplayAspect : 16 / 9.0);
+ var fps = video.FrameRate is > 0 and <= 240 ? video.FrameRate : 30;
+ var bitsPerPixel = Choose(quality, 0.14, 0.09, 0.05) * (codec == "hevc" ? 0.65 : 1);
+ return (int)Math.Clamp(Math.Round(width * height * fps * bitsPerPixel / 1000), 300, 100_000);
+ }
+
+ /// Global options before the input: VA-API needs its device.
+ public static string[] InputArguments(HardwareEncoder encoder) =>
+ encoder.Api == HardwareApi.Vaapi ? ["-vaapi_device", VaapiDevice] : [];
+
+ ///
+ /// Appended to the video filter chain: VA-API encodes frames in GPU memory, so the scaled frames are converted to
+ /// NV12 / P010 and uploaded. The others take frames from system memory (-pix_fmt).
+ ///
+ public static string FilterSuffix(HardwareEncoder encoder, bool tenBit) =>
+ encoder.Api == HardwareApi.Vaapi ? $",format={(tenBit ? "p010le" : "nv12")},hwupload" : "";
+
+ ///
+ /// The encoder options for one video: rate control from the preset's quality, NV12 (8-bit) or P010 with the Main 10
+ /// profile (10-bit HEVC), the source's HDR colour tags, and the hvc1 tag Apple players need for HEVC in
+ /// MP4/MOV.
+ ///
+ public static List CodecArguments(HardwareEncoder encoder, string target, MediaQuality quality, VideoStreamInfo video, int maxHeight)
+ {
+ var codec = encoder.Codec;
+ var tenBit = TenBit(codec, video);
+ var quantizer = Text(Quantizer(codec, quality));
+ List args = ["-c:v", encoder.Name];
+ switch (encoder.Api)
+ {
+ case HardwareApi.Nvenc:
+ // Constant quality inside VBR; -b:v 0 lifts NVENC's default 2 Mbit/s cap. P5 is the "slow, good quality"
+ // preset, still many times faster than x264's medium.
+ args.AddRange(["-preset", "p5", "-rc", "vbr", "-cq", quantizer, "-b:v", "0"]);
+ break;
+ case HardwareApi.Qsv:
+ // global_quality without a bitrate selects ICQ (intelligent constant quality), or CQP where the driver
+ // has no ICQ.
+ args.AddRange(["-preset", "medium", "-global_quality", quantizer]);
+ break;
+ case HardwareApi.Amf:
+ // Constant quantizer; I-frames two steps finer, as AMF's own presets do.
+ args.AddRange(["-quality", "balanced", "-rc", "cqp", "-qp_i", Text(Math.Max(0, Quantizer(codec, quality) - 2)), "-qp_p", quantizer]);
+ if (codec == "h264")
+ args.AddRange(["-qp_b", quantizer]);
+ break;
+ case HardwareApi.VideoToolbox when encoder.RateControl == HardwareRateControl.Bitrate:
+ args.AddRange(["-b:v", Text(BitrateKbps(codec, quality, video, maxHeight)) + "k"]);
+ break;
+ case HardwareApi.VideoToolbox:
+ args.AddRange(["-q:v", Text(VideoToolboxQuality(quality))]);
+ break;
+ case HardwareApi.Vaapi:
+ args.AddRange(["-rc_mode", "CQP", "-qp", quantizer]);
+ break;
+ }
+
+ if (codec == "h264")
+ args.AddRange(["-profile:v", "high"]);
+ else
+ args.AddRange(["-profile:v", tenBit ? "main10" : "main"]);
+ // VA-API's frames are already uploaded in the right format (FilterSuffix).
+ if (encoder.Api != HardwareApi.Vaapi)
+ args.AddRange(["-pix_fmt", tenBit ? "p010le" : "nv12"]);
+ if (tenBit)
+ args.AddRange(MediaEncoding.ColorArguments(video));
+ if (codec == "hevc" && target is "mp4" or "m4v" or "mov")
+ args.AddRange(["-tag:v", "hvc1"]);
+ return args;
+ }
+
+ /// The source of a probe: a tenth of a second of test pattern, three small frames.
+ private const string ProbeSource = "testsrc=size=256x144:rate=30:duration=0.1";
+
+ ///
+ /// A tiny test encode with exactly the options a real one uses (rate control, profile, pixel format), so an
+ /// encoder FFmpeg lists but the computer can't run (no GPU, old driver, no 10-bit support, no constant quality on
+ /// an Intel Mac) fails here instead of on the user's file.
+ ///
+ public static List ProbeArguments(HardwareEncoder encoder, bool tenBit)
+ {
+ var picture = new VideoStreamInfo(256, 144, 1, 0, 30, PixelFormat: tenBit ? "yuv420p10le" : "yuv420p");
+ return
+ [
+ .. MediaEncoding.Common, .. InputArguments(encoder), "-f", "lavfi", "-i", ProbeSource,
+ "-vf", "null" + FilterSuffix(encoder, tenBit),
+ .. CodecArguments(encoder, "mkv", MediaQuality.Balanced, picture, 0),
+ "-f", "null", "-",
+ ];
+ }
+
+ private static T Choose(MediaQuality quality, T high, T balanced, T small) => quality switch
+ {
+ MediaQuality.High => high,
+ MediaQuality.Small => small,
+ _ => balanced,
+ };
+
+ private static string Text(int value) => value.ToString(CultureInfo.InvariantCulture);
+}
diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs
index ff791f17..c4f949bd 100644
--- a/src/Filee.Engines/Media/MediaEncoding.cs
+++ b/src/Filee.Engines/Media/MediaEncoding.cs
@@ -141,9 +141,13 @@ private static readonly (int Num, int Den)[] BroadcastRates =
/// Scratch folder (the GIF palette goes there).
/// The source's cover for targets FFmpeg can't put a picture stream into ().
/// The encoders the FFmpeg in use has (ffmpeg -encoders); null assumes the pinned build's.
+ ///
+ /// The hardware encoder that passed its probe for of this file; null encodes with x264 /
+ /// x265 (also the retry after a hardware encode failed).
+ ///
/// The file lacks the stream the target needs (e.g. no audio track).
public static IReadOnlyList Plan(string input, string output, string target, MediaOptions options, MediaInfo info, string workDirectory,
- CoverExtras? cover = null, IReadOnlySet? encoders = null)
+ CoverExtras? cover = null, IReadOnlySet? encoders = null, HardwareEncoder? hardware = null)
{
if (target == "gif")
return AnimatedGif(input, output, options, info, workDirectory);
@@ -152,7 +156,7 @@ public static IReadOnlyList Plan(string input, string output, string
if (target == "png")
return [new FfmpegPass(PosterFrame(input, output, info), 1)];
if (VideoTargets.TryGetValue(target, out var video))
- return [new FfmpegPass(Video(input, output, target, video, options, info, encoders), 1)];
+ return [new FfmpegPass(Video(input, output, target, video, options, info, encoders, hardware), 1)];
if (AudioTargets.TryGetValue(target, out var audio))
return [new FfmpegPass(Audio(input, output, target, audio, options, info, cover), 1)];
if (SubtitleTargets.ContainsKey(target))
@@ -160,10 +164,64 @@ public static IReadOnlyList Plan(string input, string output, string
throw new NotSupportedException($"FFmpeg cannot write '{target}'.");
}
+ ///
+ /// Maps preset image quality (1..100, 100 best) to FFmpeg's mjpeg -q:v parameter (1..31, 1 best, 2..3 visually lossless).
+ ///
+ public static int QualityToQv(int quality)
+ {
+ var clamped = Math.Clamp(quality, 1, 100);
+ return Math.Clamp((int)Math.Round(2.0 + (100 - clamped) * 29.0 / 99.0), 1, 31);
+ }
+
+ ///
+ /// Every frame (or every th) of the first video stream as numbered pictures in
+ /// (an image2 pattern such as frame_%06d.png), for focus stacking and
+ /// picking a shot.
+ ///
+ ///
+ /// Each decoded frame is written once. The image2 muxer would otherwise pace frames to a constant rate and
+ /// duplicate (or drop) frames of a variable frame rate video (phones), so frames pass through with their own
+ /// timestamps; with an interval the select filter leaves gaps, which vfr keeps as they are.
+ ///
+ public static List FrameSequence(
+ string input,
+ string outputPattern,
+ string imageExt,
+ VideoStreamInfo? video = null,
+ int quality = 90,
+ int frameInterval = 1)
+ {
+ var select = frameInterval > 1 ? $"select='not(mod(n\\,{frameInterval}))'" : "null";
+ List args =
+ [
+ .. Common, "-i", input, "-map", "0:V:0", "-vf", (video is null ? "" : SquarePixels(video)) + select,
+ "-fps_mode", frameInterval > 1 ? "vfr" : "passthrough",
+ ];
+
+ if (imageExt is "jpg" or "jpeg")
+ args.AddRange(["-c:v", "mjpeg", "-q:v", QualityToQv(quality).ToString(CultureInfo.InvariantCulture)]);
+ else
+ args.AddRange(["-c:v", "png"]);
+
+ args.AddRange(["-f", "image2", "-progress", "pipe:1", outputPattern]);
+ return args;
+ }
+
// ───────────────────────── Video ─────────────────────────
+ ///
+ /// The codec a hardware encoder would have to write for this file ("h264" or "hevc"), or null when the preset does
+ /// not ask for hardware encoding, the target is written with another codec (VP9, AV1, MPEG-2, …), or the video is
+ /// copied anyway.
+ ///
+ internal static string? HardwareCodec(string target, MediaOptions options, MediaInfo info) =>
+ options.HardwareEncoding && info.Video is { } video && VideoTargets.ContainsKey(target)
+ && MediaCodecs.TakesHardwareEncoding(target, options.VideoCodec) && !CanCopyVideo(target, video, options)
+ ? MediaCodecs.Effective(target, options.VideoCodec) == Core.Presets.VideoCodec.Hevc ? "hevc" : "h264"
+ : null;
+
private static List Video(string input, string output, string target, MediaTarget spec, MediaOptions options, MediaInfo info,
- IReadOnlySet? encoders)
+ IReadOnlySet? encoders, HardwareEncoder? hardware)
{
var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
// An input without sound is fine: the output simply has no audio track.
@@ -180,6 +238,13 @@ private static List Video(string input, string output, string target, Me
if (video.Codec == "hevc" && target is "mp4" or "m4v" or "mov")
args.AddRange(["-tag:v", "hvc1"]);
}
+ else if (hardware is not null && HardwareCodec(target, options, info) == hardware.Codec)
+ {
+ // VA-API's device is a global option, so it goes before the input.
+ args.InsertRange(Common.Length, HardwareVideo.InputArguments(hardware));
+ args.AddRange(["-vf", ScaleFilter(options.MaxHeight) + HardwareVideo.FilterSuffix(hardware, HardwareVideo.TenBit(hardware.Codec, video))]);
+ args.AddRange(HardwareVideo.CodecArguments(hardware, target, options.Quality, video, options.MaxHeight));
+ }
else
{
var pal = IsPal(video.FrameRate);
@@ -367,8 +432,14 @@ .. HighBitDepthArguments(video), "-x265-params", "log-level=error",
internal static string[] HighBitDepthArguments(VideoStreamInfo video)
{
var tenBit = video.IsHighBitDepth || video.IsHdr;
- List args = ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p"];
- if (tenBit && video.Color is { } color)
+ return ["-pix_fmt", tenBit ? "yuv420p10le" : "yuv420p", .. tenBit ? ColorArguments(video) : []];
+ }
+
+ /// The source's colour tags (primaries, transfer, matrix), for 10-bit and HDR encodes.
+ internal static List ColorArguments(VideoStreamInfo video)
+ {
+ List args = [];
+ if (video.Color is { } color)
{
if (color.Primaries is { } primaries)
args.AddRange(["-color_primaries", primaries]);
@@ -377,7 +448,7 @@ internal static string[] HighBitDepthArguments(VideoStreamInfo video)
if (color.Matrix is { } matrix)
args.AddRange(["-colorspace", matrix]);
}
- return [.. args];
+ return args;
}
/// -r for a rate, nothing when the source rate is unknown (FFmpeg then keeps its own guess).
diff --git a/src/Filee.Engines/Pdf/PdfCompressor.cs b/src/Filee.Engines/Pdf/PdfCompressor.cs
new file mode 100644
index 00000000..3499bf9c
--- /dev/null
+++ b/src/Filee.Engines/Pdf/PdfCompressor.cs
@@ -0,0 +1,538 @@
+// Makes a PDF smaller while it stays a PDF (PDFsharp + Magick.NET). Pictures drawn at more than the level's resolution
+// are scaled down to it, photos are saved as JPEG at the level's quality and pictures with few colours (charts,
+// screenshots, logos) losslessly; every other stream is deflated at the highest level; page thumbnails, other
+// applications' private data and, when the preset drops metadata, the XMP metadata of pages and pictures are left out
+// (PDFsharp writes the document's own XMP from its document information on every save). Text, fonts and vector
+// drawings stay as they are. A picture is changed only when the result is smaller.
+
+using System.IO.Compression;
+using Filee.Core.Presets;
+using ImageMagick;
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.Advanced;
+using PdfSharp.Pdf.Content;
+using PdfSharp.Pdf.Content.Objects;
+using PdfSharp.Pdf.IO;
+
+namespace Filee.Engines.Pdf;
+
+/// Shrinks the pictures and streams of a PDF document in place (before it is saved).
+internal static class PdfCompressor
+{
+ ///
+ /// Pictures are scaled down only when they are drawn at more than this many times the level's resolution, like
+ /// Ghostscript does: a picture just above it would lose sharpness for little saving.
+ ///
+ private const double DownsampleThreshold = 1.5;
+
+ /// A picture drawn tiny (or with a transformation read wrong) never gets smaller than this, in pixels.
+ private const int MinimumSide = 8;
+
+ /// A JPEG that keeps its size is encoded again only when that saves at least this share of it.
+ private const double MinimumJpegSaving = 0.1;
+
+ /// Colours up to which a picture is a graphic (kept lossless) rather than a photo (JPEG).
+ private const int GraphicColors = 256;
+
+ /// The same for gray pictures, which never have more than 256 shades: a gray photo has many of them.
+ private const int GraphicGrays = 32;
+
+ /// Resolution pictures are scaled down to (dpi) and JPEG quality, per level.
+ public static (int Dpi, int Quality) Settings(PdfCompression level) => level switch
+ {
+ PdfCompression.Screen => (72, 55),
+ PdfCompression.Print => (300, 85),
+ _ => (150, 65),
+ };
+
+ /// Shrinks ; save it afterwards.
+ /// False drops the XMP metadata of pages, pictures and forms.
+ public static void Compress(PdfDocument document, PdfCompression level, bool keepMetadata, CancellationToken cancellationToken)
+ {
+ var (dpi, quality) = Settings(level);
+ var done = new HashSet(ReferenceEqualityComparer.Instance);
+ foreach (var (image, use) in PdfPictureUses.Find(document))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (done.Add(image))
+ ShrinkPicture(document, image, use, dpi, quality, done);
+ }
+
+ MergeDuplicatePictures(document);
+
+ foreach (var page in document.Pages)
+ {
+ page.Elements.Remove("/Thumb");
+ page.Elements.Remove("/PieceInfo");
+ }
+ document.Internals.Catalog.Elements.Remove("/PieceInfo");
+ // PDFsharp writes the document's XMP afresh from its document information when it saves (as for every PDF it
+ // writes); the old packet would stay in the file unreferenced, so it goes whatever the preset says.
+ document.Internals.Catalog.Elements.Remove("/Metadata");
+
+ foreach (var item in document.Internals.GetAllObjects())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (item is not PdfDictionary dictionary)
+ continue;
+ if (!keepMetadata && dictionary.Elements.ContainsKey("/Metadata"))
+ dictionary.Elements.Remove("/Metadata");
+ if (dictionary.Stream is not null)
+ Deflate(dictionary);
+ }
+
+ document.Options.CompressContentStreams = true;
+ document.Options.FlateEncodeMode = PdfFlateEncodeMode.BestCompression;
+ document.Options.Layout = PdfWriterLayout.Compact;
+ }
+
+ /// Scales a picture down and saves it as JPEG or Flate, when that's smaller. Soft masks follow it.
+ private static void ShrinkPicture(PdfDocument document, PdfDictionary image, PictureUse use, int dpi, int quality, HashSet done)
+ {
+ if (Decode(image) is not { } picture)
+ return;
+ using var pixels = picture.Image;
+ var width = (int)pixels.Width;
+ var height = (int)pixels.Height;
+
+ // Pixels needed to show the picture at the target resolution where it is drawn largest.
+ var scale = Math.Max(use.WidthInches * dpi / width, use.HeightInches * dpi / height);
+ var downsample = scale * DownsampleThreshold < 1;
+ var newWidth = downsample ? Math.Clamp((int)Math.Round(width * scale), Math.Min(MinimumSide, width), width) : width;
+ var newHeight = downsample ? Math.Clamp((int)Math.Round(height * scale), Math.Min(MinimumSide, height), height) : height;
+ downsample = newWidth < width || newHeight < height;
+ if (downsample)
+ pixels.Resize(new MagickGeometry((uint)newWidth, (uint)newHeight) { IgnoreAspectRatio = true });
+
+ var original = image.Stream.Value.Length;
+ // A JPEG that only gets a lower quality is worth it only for a clear saving: it loses detail once more.
+ var limit = !downsample && picture.WasJpeg ? (int)(original * (1 - MinimumJpegSaving)) : original;
+ var graphic = !picture.WasJpeg && pixels.TotalColors <= (picture.Components == 1 ? GraphicGrays : GraphicColors);
+ var encoded = picture.Components == 4 || graphic
+ ? EncodeFlate(pixels, picture.Components)
+ : EncodeJpeg(pixels, picture.Components, quality);
+ if (encoded is null || encoded.Value.Data.Length >= limit)
+ return;
+
+ image.Stream.Value = encoded.Value.Data;
+ image.Elements.SetName("/Filter", encoded.Value.Filter);
+ if (encoded.Value.DecodeParms is { } parms)
+ image.Elements["/DecodeParms"] = Parms(document, parms);
+ else
+ image.Elements.Remove("/DecodeParms");
+ image.Elements.SetInteger("/Width", newWidth);
+ image.Elements.SetInteger("/Height", newHeight);
+ image.Elements.SetInteger("/BitsPerComponent", 8);
+
+ // The soft mask (transparency) may have its own size: scaled by as much as the picture, and kept lossless.
+ if (downsample && Resolve(image.Elements["/SMask"]) is PdfDictionary mask && done.Add(mask))
+ ShrinkMask(document, mask, (double)newWidth / width, (double)newHeight / height);
+ }
+
+ private static void ShrinkMask(PdfDocument document, PdfDictionary mask, double scaleX, double scaleY)
+ {
+ if (Decode(mask) is not { Components: 1, WasJpeg: false } picture)
+ return;
+ using var pixels = picture.Image;
+ var width = (int)Math.Max(1, Math.Round(pixels.Width * scaleX));
+ var height = (int)Math.Max(1, Math.Round(pixels.Height * scaleY));
+ pixels.Resize(new MagickGeometry((uint)width, (uint)height) { IgnoreAspectRatio = true });
+ if (EncodeFlate(pixels, 1) is not { } encoded || encoded.Data.Length >= mask.Stream.Value.Length)
+ return;
+ mask.Stream.Value = encoded.Data;
+ mask.Elements.SetName("/Filter", encoded.Filter);
+ mask.Elements["/DecodeParms"] = Parms(document, encoded.DecodeParms!.Value);
+ mask.Elements.SetInteger("/Width", width);
+ mask.Elements.SetInteger("/Height", height);
+ mask.Elements.SetInteger("/BitsPerComponent", 8);
+ }
+
+ ///
+ /// Pictures stored more than once with the same data and settings (a logo embedded again on every page) are kept
+ /// once: every reference to a copy points to the first. Soft masks first, so the pictures using them compare equal.
+ ///
+ private static void MergeDuplicatePictures(PdfDocument document)
+ {
+ var images = document.Internals.GetAllObjects().OfType()
+ .Where(d => d.Stream is not null && d.Elements.GetName("/Subtype") == "/Image" && d.Reference is not null)
+ .ToList();
+ foreach (var pass in new[] { images.Where(Plain).ToList(), images.Where(i => !Plain(i)).ToList() })
+ {
+ var first = new Dictionary(StringComparer.Ordinal);
+ var copies = new Dictionary(ReferenceEqualityComparer.Instance);
+ foreach (var image in pass)
+ {
+ var key = string.Join(" ", image.Elements.Keys.Where(k => k != "/Length").Order(StringComparer.Ordinal)
+ .Select(k => k + " " + image.Elements[k])) + " " + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(image.Stream.Value));
+ if (!first.TryAdd(key, image))
+ copies[image] = first[key].Reference!;
+ }
+ if (copies.Count == 0)
+ continue;
+ foreach (var item in document.Internals.GetAllObjects())
+ {
+ if (item is PdfDictionary or PdfArray)
+ Redirect(item, copies, depth: 0);
+ }
+ }
+
+ static bool Plain(PdfDictionary image) => !image.Elements.ContainsKey("/SMask") && !image.Elements.ContainsKey("/Mask");
+ }
+
+ /// Points references to the copies in to the kept picture, in direct objects too.
+ private static void Redirect(PdfItem container, Dictionary copies, int depth)
+ {
+ if (depth > 32)
+ return;
+ if (container is PdfDictionary dictionary)
+ {
+ foreach (var key in dictionary.Elements.Keys.ToList())
+ {
+ var value = dictionary.Elements[key];
+ if (value is PdfReference { Value: PdfDictionary target } && copies.TryGetValue(target, out var kept))
+ dictionary.Elements[key] = kept;
+ else if (value is PdfDictionary { Reference: null } or PdfArray { Reference: null })
+ Redirect(value, copies, depth + 1);
+ }
+ }
+ else if (container is PdfArray array)
+ {
+ for (var i = 0; i < array.Elements.Count; i++)
+ {
+ var value = array.Elements[i];
+ if (value is PdfReference { Value: PdfDictionary target } && copies.TryGetValue(target, out var kept))
+ array.Elements[i] = kept;
+ else if (value is PdfDictionary { Reference: null } or PdfArray { Reference: null })
+ Redirect(value, copies, depth + 1);
+ }
+ }
+ }
+
+ private static PdfDictionary Parms(PdfDocument document, (int Colors, int Columns) parms)
+ {
+ var dictionary = new PdfDictionary(document);
+ dictionary.Elements.SetInteger("/Predictor", 15);
+ dictionary.Elements.SetInteger("/Colors", parms.Colors);
+ dictionary.Elements.SetInteger("/BitsPerComponent", 8);
+ dictionary.Elements.SetInteger("/Columns", parms.Columns);
+ return dictionary;
+ }
+
+ private sealed record DecodedPicture(MagickImage Image, int Components, bool WasJpeg);
+
+ ///
+ /// The pixels of a picture Filee can encode again without changing how it looks: 8 bits per component, gray, RGB or
+ /// CMYK (CMYK not from JPEG: Adobe's inverted CMYK JPEGs are easy to get wrong), stored as JPEG, Flate or
+ /// uncompressed. Null for everything else (JBIG2, CCITT, JPEG 2000, 1-bit, indexed, spot colours, colour-key
+ /// masks), which stays as it is.
+ ///
+ private static DecodedPicture? Decode(PdfDictionary image)
+ {
+ var elements = image.Elements;
+ if (elements.GetName("/Subtype") != "/Image" || elements.GetBoolean("/ImageMask") || elements.GetInteger("/BitsPerComponent") != 8
+ || Resolve(elements["/Mask"]) is PdfArray || elements.ContainsKey("/F"))
+ return null;
+ var width = elements.GetInteger("/Width");
+ var height = elements.GetInteger("/Height");
+ var components = Components(Resolve(elements["/ColorSpace"]));
+ if (width <= 0 || height <= 0 || components == 0 || (long)width * height > 100_000_000)
+ return null;
+
+ var filters = Filters(image);
+ var data = image.Stream.Value;
+ try
+ {
+ if (filters is ["/DCTDecode"])
+ {
+ // ColorTransform overrides how the JPEG's colours are read: rare, so left alone.
+ if (components == 4 || Parms(image) is { } dct && dct.Elements.ContainsKey("/ColorTransform"))
+ return null;
+ var jpeg = new MagickImage(data, MagickFormat.Jpeg);
+ var gray = jpeg.ColorSpace == ColorSpace.Gray || jpeg.ChannelCount == 1;
+ if (jpeg.Width != width || jpeg.Height != height || gray != (components == 1) || jpeg.ColorSpace == ColorSpace.CMYK)
+ {
+ jpeg.Dispose();
+ return null;
+ }
+ return new DecodedPicture(jpeg, components, WasJpeg: true);
+ }
+
+ if (filters is not ([] or ["/FlateDecode"] or ["/RunLengthDecode"]))
+ return null;
+ var raw = filters switch
+ {
+ [] => data,
+ ["/RunLengthDecode"] => UndoRunLength(data),
+ _ => Inflate(data),
+ };
+ if (filters is ["/FlateDecode"] && Parms(image) is { } flate && flate.Elements.GetInteger("/Predictor") > 1)
+ {
+ // PNG prediction of whole pixels only; TIFF prediction (2) is rare in pictures.
+ var parms = flate.Elements;
+ var colors = parms.ContainsKey("/Colors") ? parms.GetInteger("/Colors") : 1;
+ var columns = parms.ContainsKey("/Columns") ? parms.GetInteger("/Columns") : 1;
+ var bits = parms.ContainsKey("/BitsPerComponent") ? parms.GetInteger("/BitsPerComponent") : 8;
+ if (parms.GetInteger("/Predictor") < 10 || colors != components || columns != width || bits != 8
+ || UndoPngPredictor(raw, width * components, height, components) is not { } unpredicted)
+ return null;
+ raw = unpredicted;
+ }
+ var size = width * height * components;
+ if (raw.Length < size)
+ return null;
+ var settings = new MagickReadSettings
+ {
+ Width = (uint)width,
+ Height = (uint)height,
+ Depth = 8,
+ Format = components switch { 1 => MagickFormat.Gray, 3 => MagickFormat.Rgb, _ => MagickFormat.Cmyk },
+ };
+ return new DecodedPicture(new MagickImage(raw.AsSpan(0, size), settings), components, WasJpeg: false);
+ }
+ catch (Exception ex) when (ex is MagickException or InvalidDataException)
+ {
+ return null; // damaged or unusual data: the picture stays as it is
+ }
+ }
+
+ private static (byte[] Data, string Filter, (int Colors, int Columns)? DecodeParms)? EncodeJpeg(MagickImage pixels, int components, int quality)
+ {
+ pixels.Strip();
+ pixels.Quality = (uint)quality;
+ pixels.ColorType = components == 1 ? ColorType.Grayscale : ColorType.TrueColor;
+ var data = pixels.ToByteArray(MagickFormat.Jpeg);
+ // Exactly what the image dictionary says, or the picture would come out wrong: ImageMagick could write a
+ // gray-looking colour picture with one component.
+ return JpegFrame(data) is var (w, h, n) && w == pixels.Width && h == pixels.Height && n == components
+ ? (data, "/DCTDecode", null)
+ : null;
+ }
+
+ private static (byte[] Data, string Filter, (int Colors, int Columns)? DecodeParms)? EncodeFlate(MagickImage pixels, int components)
+ {
+ pixels.Depth = 8;
+ var raw = pixels.ToByteArray(components switch { 1 => MagickFormat.Gray, 3 => MagickFormat.Rgb, _ => MagickFormat.Cmyk });
+ var stride = (int)pixels.Width * components;
+ if (raw.Length != stride * (int)pixels.Height)
+ return null;
+ return (Deflate(PngPredict(raw, stride, (int)pixels.Height, components)), "/FlateDecode", (components, (int)pixels.Width));
+ }
+
+ /// Deflates an uncompressed stream, or deflates a Flate stream again at the highest level, when smaller.
+ private static void Deflate(PdfDictionary dictionary)
+ {
+ var type = dictionary.Elements.GetName("/Type");
+ // XMP stays readable for tools that search files for it; object and cross-reference streams are PDFsharp's.
+ if (type is "/Metadata" or "/ObjStm" or "/XRef" || dictionary.Elements.ContainsKey("/F"))
+ return;
+ var filters = Filters(dictionary);
+ var data = dictionary.Stream.Value;
+ if (data.Length < 64)
+ return;
+ try
+ {
+ if (filters.Length == 0)
+ {
+ var packed = Deflate(data);
+ if (packed.Length >= data.Length)
+ return;
+ dictionary.Stream.Value = packed;
+ dictionary.Elements.SetName("/Filter", "/FlateDecode");
+ }
+ else if (filters is ["/FlateDecode"])
+ {
+ // The data stays the same (predictor included), only packed tighter.
+ var packed = Deflate(Inflate(data));
+ if (packed.Length < data.Length)
+ dictionary.Stream.Value = packed;
+ }
+ else if (filters is ["/RunLengthDecode"])
+ {
+ var packed = Deflate(UndoRunLength(data));
+ if (packed.Length >= data.Length)
+ return;
+ dictionary.Stream.Value = packed;
+ dictionary.Elements.SetName("/Filter", "/FlateDecode");
+ }
+ }
+ catch (InvalidDataException)
+ {
+ // damaged stream: left as it is
+ }
+ }
+
+ private static string[] Filters(PdfDictionary dictionary) => Resolve(dictionary.Elements["/Filter"]) switch
+ {
+ PdfName name => [name.Value],
+ PdfArray array => [.. array.Elements.Select(e => Resolve(e) is PdfName n ? n.Value : "?")],
+ _ => [],
+ };
+
+ private static PdfDictionary? Parms(PdfDictionary dictionary) => Resolve(dictionary.Elements["/DecodeParms"]) switch
+ {
+ PdfDictionary parms => parms,
+ PdfArray { Elements.Count: 1 } array => Resolve(array.Elements[0]) as PdfDictionary,
+ _ => null,
+ };
+
+ /// Components of a colour space Filee re-encodes (gray, RGB, CMYK and their calibrated / ICC forms), else 0.
+ private static int Components(PdfItem? colorSpace) => colorSpace switch
+ {
+ PdfName { Value: "/DeviceGray" } => 1,
+ PdfName { Value: "/DeviceRGB" } => 3,
+ PdfName { Value: "/DeviceCMYK" } => 4,
+ PdfArray { Elements.Count: > 0 } array => (Resolve(array.Elements[0]) as PdfName)?.Value switch
+ {
+ "/CalGray" => 1,
+ "/CalRGB" => 3,
+ "/ICCBased" when array.Elements.Count > 1 && Resolve(array.Elements[1]) is PdfDictionary icc
+ => icc.Elements.GetInteger("/N") is var n and (1 or 3 or 4) ? n : 0,
+ _ => 0,
+ },
+ _ => 0,
+ };
+
+ /// The object a reference points to; a name stored as an object of its own (ImageMagick does) as a name.
+ internal static PdfItem? Resolve(PdfItem? item) => item is PdfReference reference
+ ? reference.Value is PdfNameObject name ? new PdfName(name.Value) : reference.Value
+ : item;
+
+ private static byte[] Inflate(byte[] data)
+ {
+ using var input = new ZLibStream(new MemoryStream(data), CompressionMode.Decompress);
+ using var output = new MemoryStream();
+ input.CopyTo(output);
+ return output.ToArray();
+ }
+
+ private static byte[] Deflate(byte[] data)
+ {
+ using var output = new MemoryStream();
+ using (var zlib = new ZLibStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
+ zlib.Write(data);
+ return output.ToArray();
+ }
+
+ ///
+ /// RunLengthDecode (ImageMagick writes pictures with it): a length byte n < 128 copies the next n + 1 bytes, n > 128
+ /// repeats the next byte 257 - n times, 128 ends the data.
+ ///
+ private static byte[] UndoRunLength(byte[] data)
+ {
+ using var output = new MemoryStream(data.Length * 2);
+ var i = 0;
+ while (i < data.Length)
+ {
+ var length = data[i++];
+ if (length == 128)
+ break;
+ if (length < 128)
+ {
+ var count = Math.Min(length + 1, data.Length - i);
+ output.Write(data, i, count);
+ i += count;
+ }
+ else if (i < data.Length)
+ {
+ var value = data[i++];
+ for (var n = 0; n < 257 - length; n++)
+ output.WriteByte(value);
+ }
+ }
+ return output.ToArray();
+ }
+
+ /// Reverses PNG prediction (/Predictor 10-15): each row starts with its filter type. Null when damaged.
+ private static byte[]? UndoPngPredictor(byte[] data, int stride, int rows, int bpp)
+ {
+ if (data.Length < (long)(stride + 1) * rows)
+ return null;
+ var result = new byte[stride * rows];
+ var none = new byte[stride];
+ for (var r = 0; r < rows; r++)
+ {
+ var type = data[r * (stride + 1)];
+ var source = data.AsSpan(r * (stride + 1) + 1, stride);
+ var row = result.AsSpan(r * stride, stride);
+ ReadOnlySpan up = r == 0 ? none : result.AsSpan((r - 1) * stride, stride);
+ for (var x = 0; x < stride; x++)
+ {
+ int a = x >= bpp ? row[x - bpp] : 0, b = up[x], c = x >= bpp ? up[x - bpp] : 0;
+ int predicted;
+ switch (type)
+ {
+ case 0: predicted = 0; break;
+ case 1: predicted = a; break;
+ case 2: predicted = b; break;
+ case 3: predicted = (a + b) / 2; break;
+ case 4: predicted = Paeth(a, b, c); break;
+ default: return null;
+ }
+ row[x] = (byte)(source[x] + predicted);
+ }
+ }
+ return result;
+ }
+
+ /// PNG-predicts rows (/Predictor 15), each with the filter that leaves the smallest values.
+ private static byte[] PngPredict(byte[] raw, int stride, int rows, int bpp)
+ {
+ var output = new byte[(stride + 1) * rows];
+ var candidates = new byte[5][];
+ for (var t = 0; t < candidates.Length; t++)
+ candidates[t] = new byte[stride];
+ var none = new byte[stride];
+ for (var r = 0; r < rows; r++)
+ {
+ ReadOnlySpan row = raw.AsSpan(r * stride, stride);
+ ReadOnlySpan up = r == 0 ? none : raw.AsSpan((r - 1) * stride, stride);
+ var best = 0;
+ var bestSum = long.MaxValue;
+ for (var type = 0; type < candidates.Length; type++)
+ {
+ var filtered = candidates[type];
+ long sum = 0;
+ for (var x = 0; x < stride; x++)
+ {
+ int a = x >= bpp ? row[x - bpp] : 0, b = up[x], c = x >= bpp ? up[x - bpp] : 0;
+ var predicted = type switch { 0 => 0, 1 => a, 2 => b, 3 => (a + b) / 2, _ => Paeth(a, b, c) };
+ var value = (byte)(row[x] - predicted);
+ filtered[x] = value;
+ sum += value < 128 ? value : 256 - value;
+ }
+ if (sum < bestSum)
+ {
+ bestSum = sum;
+ best = type;
+ }
+ }
+ output[r * (stride + 1)] = (byte)best;
+ candidates[best].CopyTo(output.AsSpan(r * (stride + 1) + 1));
+ }
+ return output;
+ }
+
+ private static int Paeth(int a, int b, int c)
+ {
+ var p = a + b - c;
+ int pa = Math.Abs(p - a), pb = Math.Abs(p - b), pc = Math.Abs(p - c);
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
+ }
+
+ /// Width, height and components of a JPEG's frame header, or null.
+ private static (int Width, int Height, int Components)? JpegFrame(byte[] jpeg)
+ {
+ var i = 2;
+ while (i + 9 < jpeg.Length)
+ {
+ if (jpeg[i] != 0xFF)
+ return null;
+ var marker = jpeg[i + 1];
+ var length = (jpeg[i + 2] << 8) | jpeg[i + 3];
+ if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC)
+ return ((jpeg[i + 7] << 8) | jpeg[i + 8], (jpeg[i + 5] << 8) | jpeg[i + 6], jpeg[i + 9]);
+ i += 2 + length;
+ }
+ return null;
+ }
+}
diff --git a/src/Filee.Engines/Pdf/PdfPictureUses.cs b/src/Filee.Engines/Pdf/PdfPictureUses.cs
new file mode 100644
index 00000000..70e88327
--- /dev/null
+++ b/src/Filee.Engines/Pdf/PdfPictureUses.cs
@@ -0,0 +1,242 @@
+// Where the pictures of a PDF are drawn and how large, for PdfCompressor: the content streams of the pages, of the
+// forms they draw and of annotation appearances are read for "cm" (transformations) and "Do" (draw an XObject). A
+// picture's largest drawn size decides how far it may be scaled down. Pictures whose drawing can't be followed (a
+// content stream PDFsharp can't read) are taken as large as the page lets them be, so they are never scaled down too far.
+
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.Content;
+using PdfSharp.Pdf.Content.Objects;
+
+namespace Filee.Engines.Pdf;
+
+/// The largest size, in inches, a picture is drawn at (width and height of the picture, wherever it is turned).
+internal readonly record struct PictureUse(double WidthInches, double HeightInches);
+
+/// Finds the pictures (image XObjects) of a document and the size they are drawn at.
+internal sealed class PdfPictureUses
+{
+ private const double PointsPerInch = 72;
+
+ /// Forms drawing forms deeper than this are not followed (and nothing is drawn that deep in practice).
+ private const int MaxDepth = 12;
+
+ /// Forms followed per document at most, against forms drawing each other many times over.
+ private const int MaxForms = 20_000;
+
+ /// The part of a transformation that sizes things (no translation): x' = a·x + c·y, y' = b·x + d·y.
+ private readonly record struct Matrix(double A, double B, double C, double D)
+ {
+ public static readonly Matrix Identity = new(1, 0, 0, 1);
+
+ /// This transformation applied first, then (PDF's "this × outer").
+ public Matrix Then(Matrix outer) =>
+ new(A * outer.A + B * outer.C, A * outer.B + B * outer.D, C * outer.A + D * outer.C, C * outer.B + D * outer.D);
+ }
+
+ private readonly Dictionary _drawn = new(ReferenceEqualityComparer.Instance);
+ private readonly HashSet _found = new(ReferenceEqualityComparer.Instance);
+ private readonly HashSet _unsure = new(ReferenceEqualityComparer.Instance);
+ private int _forms;
+
+ /// Every picture in the resources of the pages, their forms and annotation appearances, with its size.
+ public static IReadOnlyList<(PdfDictionary Image, PictureUse Use)> Find(PdfDocument document)
+ {
+ var uses = new PdfPictureUses();
+ double pageWidth = 0, pageHeight = 0;
+ foreach (var page in document.Pages)
+ {
+ pageWidth = Math.Max(pageWidth, page.Width.Point);
+ pageHeight = Math.Max(pageHeight, page.Height.Point);
+ var resources = page.Elements.GetDictionary("/Resources");
+ uses.Collect(resources);
+ CSequence? content = null;
+ try
+ {
+ content = ContentReader.ReadContent(page);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ uses.Unsure(resources); // can't be read: its pictures are taken as large as the page
+ }
+ if (content is not null)
+ uses.Walk(content, resources, Matrix.Identity, 0);
+ uses.WalkAnnotations(page);
+ }
+
+ // Not drawn anywhere Filee could follow: as large as the largest page holds it (either way round).
+ var pageWidthInches = Math.Max(pageWidth, 1) / PointsPerInch;
+ var pageHeightInches = Math.Max(pageHeight, 1) / PointsPerInch;
+ var result = new List<(PdfDictionary, PictureUse)>();
+ foreach (var image in uses._found)
+ {
+ var width = Math.Max(1, image.Elements.GetInteger("/Width"));
+ var height = Math.Max(1, image.Elements.GetInteger("/Height"));
+ var dpi = Math.Min(Math.Max(width / pageWidthInches, height / pageHeightInches),
+ Math.Max(width / pageHeightInches, height / pageWidthInches));
+ var drawn = uses._drawn.TryGetValue(image, out var size) ? size : (0, 0);
+ if (!uses._drawn.ContainsKey(image) || uses._unsure.Contains(image))
+ drawn = (Math.Max(drawn.Width, width / dpi), Math.Max(drawn.Height, height / dpi));
+ result.Add((image, new PictureUse(drawn.Width, drawn.Height)));
+ }
+ return result;
+ }
+
+ /// Remembers the pictures of a resource dictionary.
+ private void Collect(PdfDictionary? resources)
+ {
+ if (resources?.Elements.GetDictionary("/XObject") is not { } xobjects)
+ return;
+ foreach (var key in xobjects.Elements.Keys)
+ {
+ if (PdfCompressor.Resolve(xobjects.Elements[key]) is PdfDictionary { Stream: not null } xobject
+ && xobject.Elements.GetName("/Subtype") == "/Image")
+ _found.Add(xobject);
+ }
+ }
+
+ private void Unsure(PdfDictionary? resources)
+ {
+ if (resources?.Elements.GetDictionary("/XObject") is not { } xobjects)
+ return;
+ foreach (var key in xobjects.Elements.Keys)
+ {
+ if (PdfCompressor.Resolve(xobjects.Elements[key]) is PdfDictionary xobject)
+ _unsure.Add(xobject);
+ }
+ }
+
+ /// Follows q / Q / cm and records what Do draws.
+ private void Walk(CSequence content, PdfDictionary? resources, Matrix start, int depth)
+ {
+ var saved = new Stack();
+ var current = start;
+ foreach (var item in content)
+ {
+ if (item is not COperator op)
+ continue;
+ switch (op.OpCode.OpCodeName)
+ {
+ case OpCodeName.q:
+ saved.Push(current);
+ break;
+ case OpCodeName.Q:
+ if (saved.Count > 0)
+ current = saved.Pop();
+ break;
+ case OpCodeName.cm when op.Operands.Count == 6:
+ current = new Matrix(Number(op.Operands[0]), Number(op.Operands[1]), Number(op.Operands[2]), Number(op.Operands[3])).Then(current);
+ break;
+ case OpCodeName.Do when op.Operands.Count == 1 && op.Operands[0] is CName name:
+ Draw(name.Name, resources, current, depth);
+ break;
+ }
+ }
+ }
+
+ private void Draw(string name, PdfDictionary? resources, Matrix ctm, int depth)
+ {
+ if (resources?.Elements.GetDictionary("/XObject") is not { } xobjects
+ || PdfCompressor.Resolve(xobjects.Elements[name]) is not PdfDictionary { Stream: not null } xobject)
+ return;
+ switch (xobject.Elements.GetName("/Subtype"))
+ {
+ case "/Image":
+ // The picture fills the unit square: its sides become the transformed unit vectors.
+ var width = Math.Sqrt(ctm.A * ctm.A + ctm.B * ctm.B) / PointsPerInch;
+ var height = Math.Sqrt(ctm.C * ctm.C + ctm.D * ctm.D) / PointsPerInch;
+ _found.Add(xobject);
+ _drawn[xobject] = _drawn.TryGetValue(xobject, out var known)
+ ? (Math.Max(known.Width, width), Math.Max(known.Height, height))
+ : (width, height);
+ break;
+ case "/Form":
+ WalkForm(xobject, resources, ctm, depth + 1);
+ break;
+ }
+ }
+
+ private void WalkForm(PdfDictionary form, PdfDictionary? inherited, Matrix parent, int depth)
+ {
+ if (depth > MaxDepth || ++_forms > MaxForms)
+ {
+ Unsure(form.Elements.GetDictionary("/Resources") ?? inherited);
+ return;
+ }
+ // Old PDFs leave out a form's resources: it uses the page's then.
+ var resources = form.Elements.GetDictionary("/Resources") ?? inherited;
+ Collect(resources);
+ CSequence content;
+ try
+ {
+ content = ContentReader.ReadContent(form.Stream.UnfilteredValue);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ Unsure(resources);
+ return;
+ }
+ Walk(content, resources, FormMatrix(form).Then(parent), depth);
+ }
+
+ ///
+ /// Annotation appearances (stamps, signatures, form fields): the form's bounding box, transformed by its matrix,
+ /// is fitted into the annotation's rectangle.
+ ///
+ private void WalkAnnotations(PdfPage page)
+ {
+ if (page.Elements.GetArray("/Annots") is not { } annotations)
+ return;
+ foreach (var item in annotations.Elements)
+ {
+ if (PdfCompressor.Resolve(item) is not PdfDictionary annotation
+ || annotation.Elements.GetDictionary("/AP") is not { } appearances
+ || annotation.Elements.GetArray("/Rect") is not { Elements.Count: 4 } rect)
+ continue;
+ var rectWidth = Math.Abs(rect.Elements.GetReal(2) - rect.Elements.GetReal(0));
+ var rectHeight = Math.Abs(rect.Elements.GetReal(3) - rect.Elements.GetReal(1));
+ foreach (var key in new[] { "/N", "/R", "/D" })
+ {
+ switch (PdfCompressor.Resolve(appearances.Elements[key]))
+ {
+ case PdfDictionary { Stream: not null } form:
+ WalkAppearance(form, page, rectWidth, rectHeight);
+ break;
+ case PdfDictionary states:
+ foreach (var state in states.Elements.Keys)
+ {
+ if (PdfCompressor.Resolve(states.Elements[state]) is PdfDictionary { Stream: not null } stateForm)
+ WalkAppearance(stateForm, page, rectWidth, rectHeight);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ private void WalkAppearance(PdfDictionary form, PdfPage page, double rectWidth, double rectHeight)
+ {
+ var matrix = FormMatrix(form);
+ double boxWidth = 0, boxHeight = 0;
+ if (form.Elements.GetArray("/BBox") is { Elements.Count: 4 } box)
+ {
+ var w = Math.Abs(box.Elements.GetReal(2) - box.Elements.GetReal(0));
+ var h = Math.Abs(box.Elements.GetReal(3) - box.Elements.GetReal(1));
+ boxWidth = Math.Abs(matrix.A) * w + Math.Abs(matrix.C) * h;
+ boxHeight = Math.Abs(matrix.B) * w + Math.Abs(matrix.D) * h;
+ }
+ var fit = boxWidth > 0 && boxHeight > 0 ? new Matrix(rectWidth / boxWidth, 0, 0, rectHeight / boxHeight) : Matrix.Identity;
+ WalkForm(form, page.Elements.GetDictionary("/Resources"), fit, 1);
+ }
+
+ private static Matrix FormMatrix(PdfDictionary form) =>
+ form.Elements.GetArray("/Matrix") is { Elements.Count: 6 } m
+ ? new Matrix(m.Elements.GetReal(0), m.Elements.GetReal(1), m.Elements.GetReal(2), m.Elements.GetReal(3))
+ : Matrix.Identity;
+
+ private static double Number(CObject item) => item switch
+ {
+ CInteger integer => integer.Value,
+ CReal real => real.Value,
+ _ => 0,
+ };
+}
diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs
index f3dba224..4f64ac75 100644
--- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs
+++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs
@@ -1,9 +1,11 @@
-// Builds PDFs from images, splits / extracts PDF pages, and merges PDFs (PDFsharp, MIT).
+// Builds PDFs from images, splits / extracts PDF pages, merges PDFs and makes PDFs smaller (PDFsharp, MIT; the
+// compression itself is in PdfCompressor, with Ghostscript as an optional second try).
using Filee.Core.Conversion;
using Filee.Core.Presets;
using Filee.Engines.Infrastructure;
using Filee.Engines.Magick;
+using Filee.Engines.Vector;
using ImageMagick;
using PdfSharp.Drawing;
using PdfSharp.Pdf;
@@ -11,7 +13,7 @@
namespace Filee.Engines.Pdf;
-/// Image → PDF, PDF → PDF (split / page range) and PDF merging.
+/// Image → PDF, PDF → PDF (split / page range / compression) and PDF merging.
public sealed class PdfSharpConverter : IConverter, IPdfMerger, IImageTransformer
{
private const double PointsPerInch = 72.0;
@@ -33,9 +35,9 @@ public sealed class PdfSharpConverter : IConverter, IPdfMerger, IImageTransforme
public EngineStatus GetStatus() => EngineStatus.Available(version: EngineVersions.Library("PDFsharp", typeof(PdfDocument)));
public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) =>
- Task.Run(() => step.From == "pdf"
- ? SplitOrExtract(step, progress, cancellationToken)
- : ImageToPdf(step, progress, cancellationToken), cancellationToken);
+ step.From != "pdf" ? Task.Run(() => ImageToPdf(step, progress, cancellationToken), cancellationToken)
+ : step.Preset.Pdf.Compression == PdfCompression.None ? Task.Run(() => SplitOrExtract(step, progress, cancellationToken), cancellationToken)
+ : Task.Run(() => CompressAsync(step, progress, cancellationToken), cancellationToken);
public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, CancellationToken cancellationToken) =>
Task.Run(() =>
@@ -44,7 +46,7 @@ public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, Canc
foreach (var input in inputPaths)
{
cancellationToken.ThrowIfCancellationRequested();
- using var source = PdfReader.Open(input, PdfDocumentOpenMode.Import);
+ using var source = Open(input, PdfDocumentOpenMode.Import);
foreach (var page in source.Pages)
output.AddPage(page);
}
@@ -106,7 +108,7 @@ private static IReadOnlyList ImageToPdf(ConversionStep step, IProgressPDF → PDF: split into single pages, or extract the configured page range.
private static IReadOnlyList SplitOrExtract(ConversionStep step, IProgress? progress, CancellationToken ct)
{
- using var source = PdfReader.Open(step.InputPath, PdfDocumentOpenMode.Import);
+ using var source = Open(step.InputPath, PdfDocumentOpenMode.Import);
var pages = PageRange.Parse(step.Preset.Pdf.PageRange, source.PageCount);
var written = new List();
@@ -137,6 +139,150 @@ private static IReadOnlyList SplitOrExtract(ConversionStep step, IProgre
return [output];
}
+ ///
+ /// PDF → a smaller PDF (), also with a page range or one file per page: the whole
+ /// file keeps everything (bookmarks, form fields, links, metadata) and is compressed as it is; selected pages are
+ /// copied into new PDFs first, as without compression. Each result is the smallest of the built-in compression and,
+ /// when the preset asks for it and it is installed, Ghostscript's; when neither is smaller than the pages as they
+ /// were, those are written unchanged and the file gets a notice.
+ ///
+ private static async Task> CompressAsync(ConversionStep step, IProgress? progress, CancellationToken ct)
+ {
+ var options = step.Preset.Pdf;
+ var scratch = Path.Combine(step.WorkDirectory, "compress-" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(scratch);
+
+ // The PDFs to compress: the file itself, or its selected pages copied into new PDFs.
+ var parts = new List<(string Plain, string? Suffix)>();
+ using (var source = Open(step.InputPath, PdfDocumentOpenMode.Import))
+ {
+ var pages = PageRange.Parse(options.PageRange, source.PageCount);
+ if (options.SplitPages)
+ {
+ foreach (var index in pages)
+ {
+ using var single = new PdfDocument();
+ single.AddPage(source.Pages[index]);
+ var path = Path.Combine(scratch, $"page-{index + 1}.pdf");
+ single.Save(path);
+ parts.Add((path, $"_p{index + 1}"));
+ }
+ }
+ else if (pages.SequenceEqual(Enumerable.Range(0, source.PageCount)))
+ {
+ parts.Add((step.InputPath, null));
+ }
+ else
+ {
+ using var extract = new PdfDocument();
+ foreach (var index in pages)
+ extract.AddPage(source.Pages[index]);
+ var path = Path.Combine(scratch, "pages.pdf");
+ extract.Save(path);
+ parts.Add((path, null));
+ }
+ }
+
+ var gs = options.CompressWithGhostscript ? GhostscriptConverter.Locate() : null;
+ var written = new List();
+ var anySmaller = false;
+ for (var i = 0; i < parts.Count; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ var output = step.Output.Allocate("pdf", parts[i].Suffix);
+ if (output is null)
+ continue;
+ anySmaller |= await CompressFileAsync(parts[i].Plain, output, step.Preset, gs, scratch, ct);
+ written.Add(output);
+ progress?.Report((i + 1.0) / parts.Count);
+ }
+ if (written.Count > 0 && !anySmaller)
+ step.Notice?.Invoke("notice.pdf_not_smaller");
+ return written;
+ }
+
+ /// Writes the smallest of and its compressed versions to .
+ /// True when a compressed version was smaller.
+ private static async Task CompressFileAsync(string plain, string output, Preset preset, string? gs, string scratch, CancellationToken ct)
+ {
+ var level = preset.Pdf.Compression;
+ var candidates = new List();
+ Exception? failure = null;
+ var builtIn = Path.Combine(scratch, Guid.NewGuid().ToString("N")[..8] + "-filee.pdf");
+ try
+ {
+ using var document = OpenForChanges(plain);
+ PdfCompressor.Compress(document, level, preset.Image.KeepMetadata, ct);
+ document.Save(builtIn);
+ candidates.Add(builtIn);
+ }
+ catch (Exception ex) when (gs is not null && ex is not OperationCanceledException)
+ {
+ failure = ex; // Ghostscript may still manage
+ }
+
+ if (gs is not null)
+ {
+ var rewritten = Path.Combine(scratch, Guid.NewGuid().ToString("N")[..8] + "-gs.pdf");
+ try
+ {
+ await GhostscriptConverter.CompressPdfAsync(gs, plain, rewritten, level, scratch, ct);
+ candidates.Add(rewritten);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException && candidates.Count > 0)
+ {
+ // The built-in result stands.
+ }
+ catch (Exception) when (failure is not null)
+ {
+ throw failure;
+ }
+ }
+
+ var original = new FileInfo(plain).Length;
+ var best = candidates.Select(path => new FileInfo(path)).MinBy(file => file.Length);
+ if (best is not null && best.Length < original)
+ {
+ File.Copy(best.FullName, output, overwrite: true);
+ return true;
+ }
+ File.Copy(plain, output, overwrite: true);
+ return false;
+ }
+
+ ///
+ /// Opens a PDF to change it. One locked only against changes (it opens without a password) has its pages copied
+ /// into a new PDF instead, as when pages are split or extracted: bookmarks and form fields stay behind then.
+ ///
+ private static PdfDocument OpenForChanges(string path)
+ {
+ try
+ {
+ return PdfReader.Open(path, PdfDocumentOpenMode.Modify);
+ }
+ catch (PdfReaderException ex) when (ex.Message.Contains("owner password", StringComparison.OrdinalIgnoreCase))
+ {
+ using var source = Open(path, PdfDocumentOpenMode.Import);
+ var copy = new PdfDocument();
+ foreach (var page in source.Pages)
+ copy.AddPage(page);
+ return copy;
+ }
+ }
+
+ /// Opens a PDF; a password-protected one fails with the message the other PDF conversions give.
+ internal static PdfDocument Open(string path, PdfDocumentOpenMode mode)
+ {
+ try
+ {
+ return PdfReader.Open(path, mode);
+ }
+ catch (PdfReaderException ex) when (ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase))
+ {
+ throw PdfDocumentReader.PasswordProtected();
+ }
+ }
+
///
/// Encodes a frame for embedding. JPEG sources stay JPEG (no generational loss beyond one re-encode at high quality);
/// everything else is embedded losslessly as PNG.
diff --git a/src/Filee.Engines/Vector/GhostscriptConverter.cs b/src/Filee.Engines/Vector/GhostscriptConverter.cs
index 8a5da4e1..06f06f5a 100644
--- a/src/Filee.Engines/Vector/GhostscriptConverter.cs
+++ b/src/Filee.Engines/Vector/GhostscriptConverter.cs
@@ -1,9 +1,11 @@
// PostScript and EPS with Ghostscript (AGPL-3.0, a separate program downloaded on demand into engines/ghostscript).
// EPS/PS → PDF with pdfwrite; PDF → EPS/PS with eps2write/ps2write; EPS/PS → PNG with pngalpha, which keeps a
// transparent background (through PDF the page would be drawn on white paper). Everything else (EPS → JPG,
-// SVG → EPS, …) is planned through PDF or PNG by the route planner.
+// SVG → EPS, …) is planned through PDF or PNG by the route planner. "Compress PDF" may also run a PDF through pdfwrite
+// (PdfSharpConverter calls CompressPdfAsync; there is no PDF → PDF edge here, so routes don't change).
using Filee.Core.Conversion;
+using Filee.Core.Presets;
using Filee.Engines.Infrastructure;
using Filee.Engines.Magick;
using ImageMagick;
@@ -92,6 +94,26 @@ internal static async Task ConvertToPdfAsync(string gs, string input, string out
File.Move(target, output, overwrite: true);
}
+ ///
+ /// Rewrites a PDF smaller with pdfwrite's settings for the level (/screen, /ebook, /printer: pictures at 72, 150 and
+ /// 300 dpi, Filee's levels too). Used by for "Compress PDF" when the preset asks
+ /// for Ghostscript; the caller keeps the result only when it is smaller.
+ ///
+ internal static async Task CompressPdfAsync(string gs, string input, string output, PdfCompression level, string workDirectory,
+ CancellationToken cancellationToken)
+ {
+ var settings = level switch
+ {
+ PdfCompression.Screen => "/screen",
+ PdfCompression.Print => "/printer",
+ _ => "/ebook",
+ };
+ var target = Path.Combine(NewScratch(workDirectory), "out.pdf");
+ List args = ["-sDEVICE=pdfwrite", "-dPDFSETTINGS=" + settings, "-dCompatibilityLevel=1.6", "-dAutoRotatePages=/None"];
+ await RunAsync(gs, args, target, input, cancellationToken, progress: null);
+ File.Move(target, output, overwrite: true);
+ }
+
private static async Task> ToPdfAsync(string gs, ConversionStep step, CancellationToken ct, IProgress? progress)
{
var output = step.Output.Allocate("pdf");
diff --git a/src/Filee.Engines/Vector/SvgRenderer.cs b/src/Filee.Engines/Vector/SvgRenderer.cs
index e43de355..3ee25d8d 100644
--- a/src/Filee.Engines/Vector/SvgRenderer.cs
+++ b/src/Filee.Engines/Vector/SvgRenderer.cs
@@ -56,10 +56,23 @@ public static Drawing Load(string path)
svg.Settings.EnableJavaScript = false;
svg.Settings.EnableExternalJavaScript = false;
SKPicture? picture;
+ var uri = new Uri(Path.GetFullPath(path));
try
{
- using var stream = new MemoryStream(bytes, writable: false);
- picture = svg.Load(stream, null, new Uri(Path.GetFullPath(path)));
+ picture = Parse(svg, bytes, uri);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException && SvgXmlRepair.FixAmpersands(bytes) is { } repaired)
+ {
+ // "A & B" or in the text (common in generated SVGs): read it again with those escaped.
+ try
+ {
+ picture = Parse(svg, repaired, uri);
+ }
+ catch (Exception retry) when (retry is not OutOfMemoryException)
+ {
+ svg.Dispose();
+ throw new InvalidDataException("The SVG file could not be read: " + ex.Message, ex);
+ }
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
@@ -75,6 +88,12 @@ public static Drawing Load(string path)
return new Drawing(svg, picture, bounds);
}
+ private static SKPicture? Parse(SKSvg svg, byte[] bytes, Uri baseUri)
+ {
+ using var stream = new MemoryStream(bytes, writable: false);
+ return svg.Load(stream, null, baseUri);
+ }
+
/// The file's SVG text as bytes: SVGZ (or a gzipped .svg) is decompressed.
public static byte[] ReadSvgBytes(string path)
{
diff --git a/src/Filee.Engines/Vector/SvgXmlRepair.cs b/src/Filee.Engines/Vector/SvgXmlRepair.cs
new file mode 100644
index 00000000..5abdc7f1
--- /dev/null
+++ b/src/Filee.Engines/Vector/SvgXmlRepair.cs
@@ -0,0 +1,94 @@
+// Repairs the one mistake that makes many hand-written and AI-generated SVGs unreadable: an "&" that isn't an entity
+// ("A & B" in a , a URL query in an href) and HTML entities XML doesn't know ( , ©). Browsers forgive
+// some of it; the XML parser Svg.Skia uses stops at the first one (issue #120).
+
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Filee.Engines.Vector;
+
+internal static partial class SvgXmlRepair
+{
+ /// The entities every XML document has; anything else must be declared in the DTD.
+ private static readonly HashSet XmlEntities = ["amp", "lt", "gt", "quot", "apos"];
+
+ ///
+ /// Returns with every "&" that doesn't start a character reference or a known entity
+ /// escaped, and HTML named entities turned into character references. Entities declared in the document's DTD
+ /// (Illustrator's &ns_svg;), the DOCTYPE, comments and CDATA sections are left alone. Null when nothing needed repair, or
+ /// when the bytes aren't UTF-8 (another declared encoding is read as it is).
+ ///
+ public static byte[]? FixAmpersands(byte[] svg)
+ {
+ if (svg.AsSpan().IndexOf((byte)'&') < 0)
+ return null;
+ string text;
+ try
+ {
+ text = new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(svg);
+ }
+ catch (DecoderFallbackException)
+ {
+ return null;
+ }
+
+ var declared = DeclaredEntity().Matches(text).Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal);
+ var result = new StringBuilder(text.Length + 64);
+ var changed = false;
+ var position = 0;
+ foreach (Match skip in Untouched().Matches(text))
+ {
+ changed |= Repair(text.AsSpan(position, skip.Index - position), declared, result);
+ result.Append(skip.Value);
+ position = skip.Index + skip.Length;
+ }
+ changed |= Repair(text.AsSpan(position), declared, result);
+ if (!changed)
+ return null;
+ return Encoding.UTF8.GetBytes(result.ToString()); // a byte order mark was decoded as U+FEFF and stays
+ }
+
+ /// Appends to repaired; true if anything changed.
+ private static bool Repair(ReadOnlySpan part, HashSet declared, StringBuilder result)
+ {
+ var changed = false;
+ var text = part.ToString();
+ var last = 0;
+ foreach (Match amp in Ampersand().Matches(text))
+ {
+ result.Append(text, last, amp.Index - last);
+ last = amp.Index + amp.Length;
+ var name = amp.Groups["name"];
+ if (amp.Groups["number"].Success || name.Success && (XmlEntities.Contains(name.Value) || declared.Contains(name.Value)))
+ {
+ result.Append(amp.Value);
+ continue;
+ }
+ changed = true;
+ if (name.Success && WebUtility.HtmlDecode(amp.Value) is { } decoded && decoded != amp.Value)
+ {
+ // An HTML entity: the same character as a reference XML reads.
+ foreach (var rune in decoded.EnumerateRunes())
+ result.Append("").Append(rune.Value).Append(';');
+ }
+ else
+ {
+ // A lone "&", or an entity nobody defines: the "&" itself is text.
+ result.Append("&").Append(amp.Value, 1, amp.Value.Length - 1);
+ }
+ }
+ result.Append(text, last, text.Length - last);
+ return changed;
+ }
+
+ // "&", then a character reference, a name with its ";", or neither (a lone "&").
+ [GeneratedRegex(@"&(?:(?#[0-9]+;|#[xX][0-9A-Fa-f]+;)|(?[A-Za-z_][A-Za-z0-9._-]*);)?")]
+ private static partial Regex Ampersand();
+
+ [GeneratedRegex(@"||]*(?:\[.*?\])?\s*>", RegexOptions.Singleline)]
+ private static partial Regex Untouched();
+
+ [GeneratedRegex(@"Elevation checks and the de-elevated restart. Every member is safe to call on any OS.
+public static class Elevation
+{
+ /// Reads the integrity levels of this process and of Explorer. off Windows.
+ public static ElevationState Probe()
+ {
+ if (!OperatingSystem.IsWindows())
+ return ElevationState.Unknown;
+ try
+ {
+ var process = LevelOfProcess(NativeMethods.GetCurrentProcess(), out var elevated);
+ return new ElevationState(process, ShellLevel(), elevated);
+ }
+ catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException)
+ {
+ return ElevationState.Unknown;
+ }
+ }
+
+ ///
+ /// Starts through Explorer, so it gets Explorer's rights instead of this process's.
+ /// Returns false when there is no Explorer desktop to ask (another shell, Explorer restarting) or it refused;
+ /// then says why, in English.
+ ///
+ public static bool RelaunchThroughShell(string executable, string arguments, out string? error)
+ {
+ if (OperatingSystem.IsWindows())
+ return RelaunchOnWindows(executable, arguments, out error);
+ error = "Not supported on this operating system.";
+ return false;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static bool RelaunchOnWindows(string executable, string arguments, out string? error)
+ {
+ // Explorer's COM objects want a single-threaded apartment; a thread of its own keeps the caller's apartment
+ // out of it and bounds the wait if Explorer doesn't answer.
+ string? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ ShellExecuteFromDesktop(executable, arguments);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // Anything Explorer or the COM binder throws: report it, never take Filee down from this thread.
+ failure = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ })
+ { IsBackground = true, Name = "Filee shell relaunch" };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ if (!thread.Join(TimeSpan.FromSeconds(15)))
+ {
+ error = "Explorer did not answer.";
+ return false;
+ }
+ error = failure;
+ return failure is null;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void ShellExecuteFromDesktop(string executable, string arguments)
+ {
+ var shellWindowsType = Type.GetTypeFromCLSID(new Guid("9BA05972-F6A8-11CF-A442-00A0C90A8F39"), throwOnError: true)!;
+ object? shellWindows = null, desktop = null, browser = null, view = null, background = null;
+ try
+ {
+ shellWindows = Activator.CreateInstance(shellWindowsType)
+ ?? throw new InvalidOperationException("ShellWindows is not available.");
+ object location = CSIDL_DESKTOP;
+ object? empty = null; // VT_EMPTY
+ desktop = ((IShellWindows)shellWindows).FindWindowSW(ref location, ref empty, SWC_DESKTOP, out _, SWFO_NEEDDISPATCH)
+ ?? throw new InvalidOperationException("Explorer's desktop window was not found.");
+ var topLevelBrowser = SID_STopLevelBrowser;
+ var shellBrowserId = typeof(IShellBrowser).GUID;
+ browser = ((IServiceProvider)desktop).QueryService(ref topLevelBrowser, ref shellBrowserId);
+ view = ((IShellBrowser)browser).QueryActiveShellView();
+ var dispatchId = IID_IDispatch;
+ background = ((IShellView)view).GetItemObject(SVGIO_BACKGROUND, ref dispatchId);
+ // IShellFolderViewDual.Application is Explorer's IShellDispatch2: its ShellExecute runs in Explorer.
+ dynamic folderView = background;
+ dynamic application = folderView.Application;
+ try
+ {
+ application.ShellExecute(executable, arguments, Path.GetDirectoryName(executable) ?? "", "", SW_SHOWNORMAL);
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject((object)application);
+ }
+ }
+ finally
+ {
+ // Released here, on their apartment's thread, which ends right after. Two of them may be one object.
+ foreach (var item in new[] { background, view, browser, desktop, shellWindows }
+ .OfType