From 402be6689f615225ca5892123e6c3c0a1731a348 Mon Sep 17 00:00:00 2001
From: Will Knowles
Date: Thu, 27 Aug 2026 19:37:17 +0100
Subject: [PATCH 01/81] chore: prepare 2.0 packaging, Explorer integration and
ffprobe
Bumps to 2.0.0-alpha.1 and brings the build config in line with the other
Diamond apps.
- Add installer.nsh registering a 'Convert with Diamond File Converter'
Explorer entry for both files and directories, plus fileAssociations for
all 21 supported extensions. File Converter was the only one of the three
apps with neither, so it had no shell integration at all.
- Bundle ffprobe-static alongside ffmpeg. ffmpeg-static ships only ffmpeg.exe,
so no metadata was previously available anywhere in the app.
- Drop fluent-ffmpeg. Pipelines need filter_complex and explicit -map, which
it obscures, and -progress pipe:1 reports more reliably than its percent
field did.
- Rename funding.yml to FUNDING.yml. GitHub requires the uppercase name, so
the sponsor button was not rendering.
- Add an npm test script.
---
.github/{funding.yml => FUNDING.yml} | 0
installer.nsh | 17 +++++++++
package.json | 56 ++++++++++++++++++++++++++--
3 files changed, 69 insertions(+), 4 deletions(-)
rename .github/{funding.yml => FUNDING.yml} (100%)
create mode 100644 installer.nsh
diff --git a/.github/funding.yml b/.github/FUNDING.yml
similarity index 100%
rename from .github/funding.yml
rename to .github/FUNDING.yml
diff --git a/installer.nsh b/installer.nsh
new file mode 100644
index 0000000..022eeb5
--- /dev/null
+++ b/installer.nsh
@@ -0,0 +1,17 @@
+!macro customInstall
+ ; Add "Convert with Diamond File Converter" to the context menu with icon
+ WriteRegStr HKCR "*\shell\DiamondFileConverter" "" "Convert with Diamond File Converter"
+ WriteRegStr HKCR "*\shell\DiamondFileConverter" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
+ WriteRegStr HKCR "*\shell\DiamondFileConverter\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"'
+
+ ; Add the same entry for directories, so a whole folder can be queued
+ WriteRegStr HKCR "Directory\shell\DiamondFileConverter" "" "Convert with Diamond File Converter"
+ WriteRegStr HKCR "Directory\shell\DiamondFileConverter" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
+ WriteRegStr HKCR "Directory\shell\DiamondFileConverter\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" "%1"'
+!macroend
+
+!macro customUninstall
+ ; Clean up the registry keys on uninstall
+ DeleteRegKey HKCR "*\shell\DiamondFileConverter"
+ DeleteRegKey HKCR "Directory\shell\DiamondFileConverter"
+!macroend
diff --git a/package.json b/package.json
index 5e80c70..21ba205 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "diamond-file-converter",
- "version": "1.0.0",
+ "version": "2.0.0-alpha.1",
"description": "An Electron-based Windows app for converting audio, video, and image files.",
"main": "src/main.js",
"funding": {
@@ -9,7 +9,8 @@
},
"scripts": {
"start": "electron .",
- "build": "electron-builder"
+ "build": "electron-builder",
+ "test": "node --test"
},
"repository": {
"type": "git",
@@ -23,6 +24,52 @@
{
"from": "node_modules/ffmpeg-static/ffmpeg.exe",
"to": "ffmpeg/ffmpeg.exe"
+ },
+ {
+ "from": "node_modules/ffprobe-static/bin/win32/x64/ffprobe.exe",
+ "to": "ffmpeg/ffprobe.exe"
+ }
+ ],
+ "fileAssociations": [
+ {
+ "ext": [
+ "mp3",
+ "wav",
+ "flac",
+ "ogg",
+ "aac",
+ "m4a",
+ "opus",
+ "wma"
+ ],
+ "name": "Audio File",
+ "role": "Editor"
+ },
+ {
+ "ext": [
+ "mp4",
+ "mkv",
+ "webm",
+ "avi",
+ "mov",
+ "wmv",
+ "flv"
+ ],
+ "name": "Video File",
+ "role": "Editor"
+ },
+ {
+ "ext": [
+ "jpg",
+ "jpeg",
+ "png",
+ "webp",
+ "gif",
+ "bmp",
+ "tiff"
+ ],
+ "name": "Image File",
+ "role": "Editor"
}
],
"publish": {
@@ -39,7 +86,8 @@
"nsis": {
"perMachine": true,
"createDesktopShortcut": true,
- "menuCategory": "Diamond Digital Development"
+ "menuCategory": "Diamond Digital Development",
+ "include": "installer.nsh"
}
},
"keywords": [],
@@ -54,7 +102,7 @@
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.2.0",
- "fluent-ffmpeg": "^2.1.3",
+ "ffprobe-static": "^3.1.0",
"material-icons": "^1.13.14"
}
}
From 4b496da8abbec0fefc0eacf32c893855ddac848d Mon Sep 17 00:00:00 2001
From: Will Knowles
Date: Thu, 27 Aug 2026 19:37:33 +0100
Subject: [PATCH 02/81] feat(core): add the 2.0 conversion architecture
The v1 engine was a single fluent-ffmpeg call, ffmpeg(input).save(output),
with no codec, bitrate, resolution, trim, quality or stream-copy control at
any layer. These modules are the spine the 2.0 features need. They import no
Electron, so they are unit-testable without launching the app.
- formats.js Capability graph replacing the within-group cross-product.
Cross-kind conversions are now representable: video to audio,
video to frames or a thumbnail, images to video. Subsumes the
gif-to-still special case that was hardcoded in main.js.
- job.js Job schema with defaults derived from target-format
capabilities, nested-merge patching for bulk edit, and
validation that rejects impossible combinations without
requiring metadata to be present.
- ffmpeg-args.js Pure buildArgs(job) -> string[]. Stream copy, crf/bitrate,
presets, resize with contain/cover/stretch, trim via input
seek plus duration, per-format image quality, a palettegen
chain for GIF, frame patterns, faststart.
- runner.js Concurrency pool, per-job ids, per-job cancel, pause/resume,
partial-output cleanup, structured progress parsed from
-progress pipe:1. Replaces the single activeConversion global.
- probe.js ffprobe wrapper with normalised output. Treats metadata as
enrichment, so a failed probe never blocks a job. Discriminates
cover art from real video streams.
- paths.js Output routing (alongside, fixed, mirrored source tree), name
templating and conflict policies. Output was previously
hardcoded to the input directory.
- scan.js Async recursive directory walk with depth and count limits and
extension filters, replacing a synchronous one-level readdir
that ran on the UI thread and discarded nested files.
- pipeline.js Node graph schema and filter_complex compiler with cycle,
port-type and reachability validation. Schema and compiler
only; the flowchart editor is deliberately not in this slice.
53 tests cover the argument builder, the pipeline compiler, path resolution
against a real filesystem, progress parsing and the directory scanner.
---
src/core/ffmpeg-args.js | 299 +++++++++++++++++++
src/core/formats.js | 296 +++++++++++++++++++
src/core/job.js | 222 ++++++++++++++
src/core/paths.js | 170 +++++++++++
src/core/pipeline.js | 410 ++++++++++++++++++++++++++
src/core/probe.js | 142 +++++++++
src/core/runner.js | 375 ++++++++++++++++++++++++
src/core/scan.js | 147 ++++++++++
test/core.test.js | 631 ++++++++++++++++++++++++++++++++++++++++
9 files changed, 2692 insertions(+)
create mode 100644 src/core/ffmpeg-args.js
create mode 100644 src/core/formats.js
create mode 100644 src/core/job.js
create mode 100644 src/core/paths.js
create mode 100644 src/core/pipeline.js
create mode 100644 src/core/probe.js
create mode 100644 src/core/runner.js
create mode 100644 src/core/scan.js
create mode 100644 test/core.test.js
diff --git a/src/core/ffmpeg-args.js b/src/core/ffmpeg-args.js
new file mode 100644
index 0000000..b4cca7d
--- /dev/null
+++ b/src/core/ffmpeg-args.js
@@ -0,0 +1,299 @@
+"use strict";
+
+// ffmpeg argv construction.
+//
+// v1 delegated everything to fluent-ffmpeg and a single `.save(outputPath)`
+// call, so the entire command was "read this, write that" and the output
+// format was inferred from the file extension. This module builds the argv
+// explicitly instead. It is a pure function of the job: no filesystem access,
+// no spawning, no Electron. That is what makes it testable.
+
+const path = require("path");
+const formats = require("./formats");
+const { STREAM_MODE } = require("./job");
+
+// Codecs that take a constant-quality value via -crf. Everything else that
+// supports a quality knob uses -q:v, which runs on an inverted scale.
+const CRF_CODECS = new Set(["libx264", "libx265", "libsvtav1", "libvpx-vp9", "libvpx"]);
+// Codecs whose -preset flag ffmpeg understands.
+const PRESET_CODECS = new Set(["libx264", "libx265", "libsvtav1"]);
+// Codecs that need an explicit 8-bit 4:2:0 pixel format for player compatibility.
+const NEEDS_YUV420P = new Set(["libx264", "libx265"]);
+
+const FRAME_PATTERN = "frame_%04d";
+const DEFAULT_ASSEMBLE_FPS = 25;
+const DEFAULT_STILL_DURATION = 5; // seconds, for one image looped into a video
+
+/**
+ * Build the complete ffmpeg argument list for a job.
+ *
+ * @param {object} job a job from core/job.js
+ * @param {object} opts
+ * @param {string} opts.outputPath resolved by core/paths.js. For frame
+ * extraction this is the output *directory*.
+ * @param {boolean} [opts.progress] emit machine-readable progress on stdout
+ * @param {boolean} [opts.overwrite] pass -y (default true; the conflict policy
+ * is resolved before we ever get here)
+ * @returns {string[]}
+ */
+function buildArgs(job, opts = {}) {
+ const { outputPath, progress = true, overwrite = true } = opts;
+ if (!outputPath) throw new Error("buildArgs requires an outputPath.");
+
+ const mode = job.mode ?? formats.MODE.TRANSCODE;
+ const target = formats.getFormat(job.output.ext);
+ if (!target) throw new Error(`Unknown target format: ${job.output.ext}`);
+
+ const args = ["-hide_banner", "-nostdin"];
+ args.push(overwrite ? "-y" : "-n");
+
+ // -- Input ---------------------------------------------------------------
+
+ const { start, end } = job.trim ?? {};
+
+ // Input-side seek. Fast and frame-accurate for re-encodes; with -c copy it
+ // lands on the nearest preceding keyframe, which is inherent to remuxing.
+ if (start != null && start > 0) {
+ args.push("-ss", formatSeconds(start));
+ }
+
+ if (mode === formats.MODE.ASSEMBLE) {
+ const fps = job.video?.fps ?? DEFAULT_ASSEMBLE_FPS;
+ if (job.inputIsSequence) {
+ args.push("-framerate", String(fps), "-i", job.inputPath);
+ } else {
+ // A single still looped into a clip of a fixed length.
+ args.push("-loop", "1", "-framerate", String(fps), "-i", job.inputPath);
+ args.push("-t", formatSeconds(durationOrDefault(job, DEFAULT_STILL_DURATION)));
+ }
+ } else {
+ args.push("-i", job.inputPath);
+ }
+
+ // Output-side duration. Paired with the input seek above, so it counts from
+ // the seek point rather than from the start of the source.
+ if (end != null && mode !== formats.MODE.ASSEMBLE) {
+ const from = start ?? 0;
+ if (end > from) args.push("-t", formatSeconds(end - from));
+ }
+
+ // -- Stream selection and filtering --------------------------------------
+
+ const videoMode = job.video?.mode ?? STREAM_MODE.ENCODE;
+ const audioMode = job.audio?.mode ?? STREAM_MODE.ENCODE;
+
+ const wantsPalette = target.ext === "gif" && videoMode !== STREAM_MODE.DROP;
+ const videoFilters = buildVideoFilters(job, mode);
+
+ if (wantsPalette) {
+ // A generated palette is the difference between a usable GIF and a
+ // 256-colour mess, and it is the one place the simple -vf chain cannot
+ // express what we need.
+ args.push("-filter_complex", buildPaletteChain(videoFilters));
+ } else if (videoFilters.length > 0 && videoMode !== STREAM_MODE.DROP) {
+ args.push("-vf", videoFilters.join(","));
+ }
+
+ // -- Video ---------------------------------------------------------------
+
+ if (videoMode === STREAM_MODE.DROP || mode === formats.MODE.EXTRACT) {
+ args.push("-vn");
+ } else if (videoMode === STREAM_MODE.COPY) {
+ args.push("-c:v", "copy");
+ } else {
+ const codec = job.video?.codec ?? target.defaultVideoCodec;
+ if (codec) args.push("-c:v", codec);
+
+ if (target.kind === formats.KIND.IMAGE) {
+ pushImageQuality(args, job, target);
+ } else {
+ pushVideoQuality(args, job, codec);
+ }
+
+ if (codec && PRESET_CODECS.has(codec) && job.video?.preset) {
+ args.push("-preset", job.video.preset);
+ }
+ if (codec && NEEDS_YUV420P.has(codec)) {
+ args.push("-pix_fmt", "yuv420p");
+ }
+ }
+
+ // -- Audio ---------------------------------------------------------------
+
+ const targetTakesAudio = target.kind !== formats.KIND.IMAGE;
+
+ if (!targetTakesAudio || audioMode === STREAM_MODE.DROP) {
+ args.push("-an");
+ } else if (audioMode === STREAM_MODE.COPY) {
+ args.push("-c:a", "copy");
+ } else {
+ const codec = job.audio?.codec ?? target.defaultAudioCodec;
+ if (codec) args.push("-c:a", codec);
+ if (job.audio?.bitrate) args.push("-b:a", normaliseBitrate(job.audio.bitrate));
+ if (job.audio?.sampleRate) args.push("-ar", String(job.audio.sampleRate));
+ if (job.audio?.channels) args.push("-ac", String(job.audio.channels));
+ }
+
+ // -- Mode-specific output shaping ----------------------------------------
+
+ let outputTarget = outputPath;
+
+ if (mode === formats.MODE.FRAMES) {
+ // Every frame, written into a directory. -fps_mode passthrough keeps
+ // the source cadence instead of resampling it.
+ args.push("-fps_mode", "passthrough");
+ outputTarget = path.join(outputPath, `${FRAME_PATTERN}.${target.ext}`);
+ } else if (mode === formats.MODE.THUMBNAIL) {
+ args.push("-frames:v", "1", "-update", "1");
+ }
+
+ if (target.ext === "mp4" || target.ext === "mov") {
+ // Move the index to the front so the file is streamable / seekable
+ // before it has fully downloaded.
+ args.push("-movflags", "+faststart");
+ }
+
+ // Extra raw filters carried on the job, and anything a pipeline appended.
+ if (Array.isArray(job.extraArgs) && job.extraArgs.length > 0) {
+ args.push(...job.extraArgs.map(String));
+ }
+
+ if (progress) {
+ args.push("-progress", "pipe:1", "-nostats");
+ }
+
+ args.push(outputTarget);
+ return args;
+}
+
+/** The video filter chain, in application order. */
+function buildVideoFilters(job, mode) {
+ const filters = [];
+
+ const scale = buildScaleFilter(job);
+ if (scale) filters.push(scale);
+
+ // fps is an input flag for assembly, so only apply it as a filter elsewhere.
+ if (job.video?.fps && mode !== formats.MODE.ASSEMBLE) {
+ filters.push(`fps=${job.video.fps}`);
+ }
+
+ if (Array.isArray(job.filters)) {
+ for (const f of job.filters) {
+ if (typeof f === "string" && f.trim()) filters.push(f.trim());
+ }
+ }
+
+ return filters;
+}
+
+function buildScaleFilter(job) {
+ const width = job.video?.width ?? null;
+ const height = job.video?.height ?? null;
+ const scale = job.image?.scale ?? null;
+
+ if (scale && !width && !height) {
+ // Proportional resize. -2 keeps the result divisible by two, which
+ // most codecs require.
+ return `scale=iw*${scale}:-2`;
+ }
+ if (!width && !height) return null;
+
+ // -2 rather than -1 on the free axis, for the same divisibility reason.
+ if (width && !height) return `scale=${width}:-2`;
+ if (!width && height) return `scale=-2:${height}`;
+
+ switch (job.video?.fitMode) {
+ case "stretch":
+ return `scale=${width}:${height}`;
+ case "cover":
+ return `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
+ case "contain":
+ default:
+ return `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
+ }
+}
+
+/**
+ * GIF output split into a palette pass and an application pass. With no
+ * preceding filters the chain still needs an explicit [0:v] label.
+ */
+function buildPaletteChain(videoFilters) {
+ const pre = videoFilters.length > 0 ? `${videoFilters.join(",")},` : "";
+ return `[0:v] ${pre}split[pal_a][pal_b];[pal_a]palettegen[pal];[pal_b][pal]paletteuse`;
+}
+
+function pushVideoQuality(args, job, codec) {
+ const crf = job.video?.crf;
+ const bitrate = job.video?.bitrate;
+
+ if (crf != null && codec && CRF_CODECS.has(codec)) {
+ args.push("-crf", String(crf));
+ // VP9 treats -crf as a cap unless the target bitrate is released.
+ if (codec === "libvpx-vp9" || codec === "libvpx") args.push("-b:v", "0");
+ return;
+ }
+ if (bitrate) {
+ args.push("-b:v", normaliseBitrate(bitrate));
+ return;
+ }
+ if (crf != null) {
+ // Codecs without -crf still take the inverted -q:v scale.
+ args.push("-q:v", String(crf));
+ }
+}
+
+function pushImageQuality(args, job, target) {
+ const quality = job.image?.quality;
+ if (quality == null || !target.quality) return;
+ const { flag, min, max } = target.quality;
+ args.push(flag, String(clamp(quality, min, max)));
+}
+
+/** Accepts 320, "320", "320k" or "320000" and returns an ffmpeg bitrate string. */
+function normaliseBitrate(value) {
+ if (typeof value === "string" && /[km]$/i.test(value.trim())) return value.trim().toLowerCase();
+ const n = Number(value);
+ if (!Number.isFinite(n)) return String(value);
+ // Anything this large was given in bits per second already.
+ if (n >= 10000) return `${Math.round(n / 1000)}k`;
+ return `${Math.round(n)}k`;
+}
+
+/** Seconds as a plain decimal; ffmpeg accepts this everywhere it accepts a time. */
+function formatSeconds(seconds) {
+ const n = Number(seconds);
+ if (!Number.isFinite(n)) return "0";
+ return String(Math.round(n * 1000) / 1000);
+}
+
+function durationOrDefault(job, fallback) {
+ const { start, end } = job.trim ?? {};
+ if (start != null && end != null && end > start) return end - start;
+ if (end != null) return end;
+ return fallback;
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, Number(value)));
+}
+
+/** ffprobe argv for a single file. Kept here so both binaries are described together. */
+function buildProbeArgs(inputPath) {
+ return [
+ "-v", "quiet",
+ "-print_format", "json",
+ "-show_format",
+ "-show_streams",
+ inputPath,
+ ];
+}
+
+module.exports = {
+ buildArgs,
+ buildProbeArgs,
+ buildVideoFilters,
+ buildScaleFilter,
+ normaliseBitrate,
+ FRAME_PATTERN,
+};
diff --git a/src/core/formats.js b/src/core/formats.js
new file mode 100644
index 0000000..d389e84
--- /dev/null
+++ b/src/core/formats.js
@@ -0,0 +1,296 @@
+"use strict";
+
+// Format capability graph.
+//
+// This replaces the within-group cross-product that v1 used (buildEntries in
+// constants.js), which made cross-kind conversions such as video -> audio
+// structurally impossible to express. Here every format carries its own
+// capability flags, and the legal conversions between two formats are derived
+// from their kinds rather than assumed.
+
+const KIND = {
+ AUDIO: "audio",
+ VIDEO: "video",
+ IMAGE: "image",
+};
+
+// How a conversion is carried out. The mode decides the overall shape of the
+// ffmpeg invocation, and whether the output is a single file or a directory.
+const MODE = {
+ TRANSCODE: "transcode", // same kind in, same kind out
+ EXTRACT: "extract", // video in, audio out (video streams dropped)
+ FRAMES: "frames", // moving picture in, image sequence out (dir output)
+ THUMBNAIL: "thumbnail", // moving picture in, one still out at a timestamp
+ ASSEMBLE: "assemble", // image(s) in, video out
+};
+
+// Modes whose output is a directory of files rather than a single file.
+const DIRECTORY_MODES = new Set([MODE.FRAMES]);
+
+const FORMATS = {
+ // -- Audio ---------------------------------------------------------------
+ mp3: {
+ ext: "mp3", label: "MP3", kind: KIND.AUDIO,
+ audioCodecs: ["libmp3lame"], defaultAudioCodec: "libmp3lame",
+ supportsStreamCopy: true, lossless: false,
+ },
+ wav: {
+ ext: "wav", label: "WAV", kind: KIND.AUDIO,
+ audioCodecs: ["pcm_s16le", "pcm_s24le", "pcm_f32le"], defaultAudioCodec: "pcm_s16le",
+ supportsStreamCopy: true, lossless: true,
+ },
+ flac: {
+ ext: "flac", label: "FLAC", kind: KIND.AUDIO,
+ audioCodecs: ["flac"], defaultAudioCodec: "flac",
+ supportsStreamCopy: true, lossless: true,
+ },
+ ogg: {
+ ext: "ogg", label: "OGG", kind: KIND.AUDIO,
+ audioCodecs: ["libvorbis", "libopus"], defaultAudioCodec: "libvorbis",
+ supportsStreamCopy: true, lossless: false,
+ },
+ aac: {
+ ext: "aac", label: "AAC", kind: KIND.AUDIO,
+ audioCodecs: ["aac"], defaultAudioCodec: "aac",
+ supportsStreamCopy: true, lossless: false,
+ },
+ m4a: {
+ ext: "m4a", label: "M4A", kind: KIND.AUDIO,
+ audioCodecs: ["aac", "alac"], defaultAudioCodec: "aac",
+ supportsStreamCopy: true, lossless: false,
+ },
+ opus: {
+ ext: "opus", label: "OPUS", kind: KIND.AUDIO,
+ audioCodecs: ["libopus"], defaultAudioCodec: "libopus",
+ supportsStreamCopy: true, lossless: false,
+ },
+ wma: {
+ ext: "wma", label: "WMA", kind: KIND.AUDIO,
+ audioCodecs: ["wmav2"], defaultAudioCodec: "wmav2",
+ supportsStreamCopy: true, lossless: false,
+ },
+
+ // -- Video ---------------------------------------------------------------
+ mp4: {
+ ext: "mp4", label: "MP4", kind: KIND.VIDEO,
+ videoCodecs: ["libx264", "libx265", "libsvtav1", "mpeg4"], defaultVideoCodec: "libx264",
+ audioCodecs: ["aac", "libmp3lame", "ac3"], defaultAudioCodec: "aac",
+ supportsStreamCopy: true, supportsAlpha: false,
+ },
+ mkv: {
+ ext: "mkv", label: "MKV", kind: KIND.VIDEO,
+ videoCodecs: ["libx264", "libx265", "libvpx-vp9", "libsvtav1"], defaultVideoCodec: "libx264",
+ audioCodecs: ["aac", "libmp3lame", "libopus", "flac", "ac3"], defaultAudioCodec: "aac",
+ supportsStreamCopy: true, supportsAlpha: false,
+ },
+ webm: {
+ ext: "webm", label: "WebM", kind: KIND.VIDEO,
+ videoCodecs: ["libvpx-vp9", "libvpx", "libsvtav1"], defaultVideoCodec: "libvpx-vp9",
+ audioCodecs: ["libopus", "libvorbis"], defaultAudioCodec: "libopus",
+ supportsStreamCopy: true, supportsAlpha: true,
+ },
+ avi: {
+ ext: "avi", label: "AVI", kind: KIND.VIDEO,
+ videoCodecs: ["mpeg4", "libx264", "mjpeg"], defaultVideoCodec: "mpeg4",
+ audioCodecs: ["libmp3lame", "ac3", "pcm_s16le"], defaultAudioCodec: "libmp3lame",
+ supportsStreamCopy: true, supportsAlpha: false,
+ },
+ mov: {
+ ext: "mov", label: "MOV", kind: KIND.VIDEO,
+ videoCodecs: ["libx264", "libx265", "prores_ks", "mjpeg"], defaultVideoCodec: "libx264",
+ audioCodecs: ["aac", "alac", "pcm_s16le"], defaultAudioCodec: "aac",
+ supportsStreamCopy: true, supportsAlpha: false,
+ },
+ wmv: {
+ ext: "wmv", label: "WMV", kind: KIND.VIDEO,
+ videoCodecs: ["wmv2", "msmpeg4v3"], defaultVideoCodec: "wmv2",
+ audioCodecs: ["wmav2"], defaultAudioCodec: "wmav2",
+ supportsStreamCopy: true, supportsAlpha: false,
+ },
+ flv: {
+ ext: "flv", label: "FLV", kind: KIND.VIDEO,
+ videoCodecs: ["libx264", "flv"], defaultVideoCodec: "libx264",
+ audioCodecs: ["libmp3lame", "aac"], defaultAudioCodec: "libmp3lame",
+ supportsStreamCopy: true, supportsAlpha: false,
+ },
+
+ // -- Image ---------------------------------------------------------------
+ jpg: {
+ ext: "jpg", label: "JPG", kind: KIND.IMAGE,
+ videoCodecs: ["mjpeg"], defaultVideoCodec: "mjpeg",
+ supportsStreamCopy: false, supportsAlpha: false, animated: false,
+ // ffmpeg -q:v for mjpeg runs 2 (best) to 31 (worst)
+ quality: { flag: "-q:v", min: 2, max: 31, default: 3, inverted: true },
+ },
+ png: {
+ ext: "png", label: "PNG", kind: KIND.IMAGE,
+ videoCodecs: ["png"], defaultVideoCodec: "png",
+ supportsStreamCopy: false, supportsAlpha: true, animated: false, lossless: true,
+ quality: { flag: "-compression_level", min: 0, max: 9, default: 6, inverted: false },
+ },
+ webp: {
+ ext: "webp", label: "WebP", kind: KIND.IMAGE,
+ videoCodecs: ["libwebp"], defaultVideoCodec: "libwebp",
+ supportsStreamCopy: false, supportsAlpha: true, animated: true,
+ quality: { flag: "-quality", min: 0, max: 100, default: 80, inverted: false },
+ },
+ gif: {
+ ext: "gif", label: "GIF", kind: KIND.IMAGE,
+ videoCodecs: ["gif"], defaultVideoCodec: "gif",
+ supportsStreamCopy: false, supportsAlpha: true, animated: true,
+ },
+ bmp: {
+ ext: "bmp", label: "BMP", kind: KIND.IMAGE,
+ videoCodecs: ["bmp"], defaultVideoCodec: "bmp",
+ supportsStreamCopy: false, supportsAlpha: false, animated: false, lossless: true,
+ },
+ tiff: {
+ ext: "tiff", label: "TIFF", kind: KIND.IMAGE,
+ videoCodecs: ["tiff"], defaultVideoCodec: "tiff",
+ supportsStreamCopy: false, supportsAlpha: true, animated: false, lossless: true,
+ },
+};
+
+// Alternate extensions mapped onto their canonical key. Carried over from v1.
+const EXT_ALIASES = {
+ jpeg: "jpg",
+ tif: "tiff",
+ jfif: "jpg",
+};
+
+const SUPPORTED_EXTENSIONS = Object.keys(FORMATS);
+
+// Image formats that hold exactly one frame. Used to decide whether a moving
+// source has to be exploded into a sequence rather than written as one file.
+const STATIC_IMAGE_EXTS = new Set(
+ Object.values(FORMATS)
+ .filter(f => f.kind === KIND.IMAGE && !f.animated)
+ .map(f => f.ext)
+);
+
+/** Resolve a raw extension (with or without a leading dot) to a canonical key. */
+function canonicalExt(raw) {
+ if (typeof raw !== "string") return null;
+ const cleaned = raw.replace(/^\./, "").toLowerCase();
+ const resolved = EXT_ALIASES[cleaned] ?? cleaned;
+ return FORMATS[resolved] ? resolved : null;
+}
+
+function getFormat(ext) {
+ const key = canonicalExt(ext);
+ return key ? FORMATS[key] : null;
+}
+
+function kindOf(ext) {
+ return getFormat(ext)?.kind ?? null;
+}
+
+function isSupported(ext) {
+ return canonicalExt(ext) !== null;
+}
+
+/**
+ * Every mode by which `fromExt` may legally become `toExt`, most useful first.
+ * An empty array means the conversion is not supported.
+ */
+function allowedModes(fromExt, toExt) {
+ const from = getFormat(fromExt);
+ const to = getFormat(toExt);
+ if (!from || !to) return [];
+
+ const modes = [];
+
+ if (from.kind === to.kind) {
+ // An animated source landing in a single-frame target cannot be one
+ // file, so it explodes into a sequence instead. This subsumes the
+ // gif -> static special case that v1 hardcoded in main.js.
+ if (from.kind === KIND.IMAGE && from.animated && !to.animated) {
+ modes.push(MODE.FRAMES, MODE.THUMBNAIL);
+ } else {
+ modes.push(MODE.TRANSCODE);
+ }
+ } else if (from.kind === KIND.VIDEO && to.kind === KIND.AUDIO) {
+ modes.push(MODE.EXTRACT);
+ } else if (from.kind === KIND.VIDEO && to.kind === KIND.IMAGE) {
+ // A still target gets one frame or all of them; an animated target
+ // (gif, animated webp) can hold the motion directly.
+ if (to.animated) modes.push(MODE.TRANSCODE, MODE.FRAMES, MODE.THUMBNAIL);
+ else modes.push(MODE.FRAMES, MODE.THUMBNAIL);
+ } else if (from.kind === KIND.IMAGE && to.kind === KIND.VIDEO) {
+ modes.push(MODE.ASSEMBLE);
+ }
+
+ return modes;
+}
+
+function defaultMode(fromExt, toExt) {
+ return allowedModes(fromExt, toExt)[0] ?? null;
+}
+
+function canConvert(fromExt, toExt) {
+ return allowedModes(fromExt, toExt).length > 0;
+}
+
+function producesDirectory(mode) {
+ return DIRECTORY_MODES.has(mode);
+}
+
+/** Every format `ext` can be converted into, as UI-ready descriptors. */
+function targetsFor(ext) {
+ const from = getFormat(ext);
+ if (!from) return [];
+
+ const targets = [];
+ for (const to of Object.values(FORMATS)) {
+ const modes = allowedModes(from.ext, to.ext);
+ if (modes.length === 0) continue;
+ targets.push({
+ ext: to.ext,
+ label: to.label,
+ kind: to.kind,
+ group: to.kind.charAt(0).toUpperCase() + to.kind.slice(1),
+ modes,
+ mode: modes[0],
+ sameKind: to.kind === from.kind,
+ });
+ }
+ return targets;
+}
+
+/**
+ * The v1 renderer's `{ [ext]: { type, targets } }` shape, restricted to
+ * same-kind targets so the existing format grid behaves exactly as it did.
+ * Cross-kind targets are already available through targetsFor(); surfacing
+ * them in the UI is TODO item 6.
+ */
+function buildLegacyConversionMap() {
+ const map = {};
+ for (const from of Object.values(FORMATS)) {
+ map[from.ext] = {
+ type: from.kind,
+ targets: targetsFor(from.ext)
+ .filter(t => t.sameKind)
+ .map(t => ({ ext: t.ext, label: t.label, group: t.group })),
+ };
+ }
+ return map;
+}
+
+module.exports = {
+ KIND,
+ MODE,
+ FORMATS,
+ EXT_ALIASES,
+ SUPPORTED_EXTENSIONS,
+ STATIC_IMAGE_EXTS,
+ canonicalExt,
+ getFormat,
+ kindOf,
+ isSupported,
+ allowedModes,
+ defaultMode,
+ canConvert,
+ producesDirectory,
+ targetsFor,
+ buildLegacyConversionMap,
+};
diff --git a/src/core/job.js b/src/core/job.js
new file mode 100644
index 0000000..f5c731e
--- /dev/null
+++ b/src/core/job.js
@@ -0,0 +1,222 @@
+"use strict";
+
+// The job model.
+//
+// v1 had no job concept at all: the convert IPC took two positional strings
+// (filePath, targetExt) and every encoding decision was left to ffmpeg's
+// extension sniffing. A job is the unit the 2.0 card grid binds to, the unit
+// the runner schedules, and the unit ffmpeg-args compiles.
+
+const formats = require("./formats");
+
+const STATUS = {
+ PENDING: "pending",
+ RUNNING: "running",
+ DONE: "done",
+ ERROR: "error",
+ CANCELLED: "cancelled",
+ SKIPPED: "skipped",
+};
+
+const STREAM_MODE = {
+ COPY: "copy", // remux without re-encoding
+ ENCODE: "encode",
+ DROP: "drop",
+};
+
+const CONFLICT = {
+ ASK: "ask",
+ OVERWRITE: "overwrite",
+ UNIQUE: "unique",
+ SKIP: "skip",
+};
+
+const OUTPUT_ROUTING = {
+ ALONGSIDE: "alongside", // next to the input, v1's only behaviour
+ FIXED: "fixed", // one chosen directory
+ MIRROR: "mirror", // rebuild the source tree under a chosen root
+};
+
+let idCounter = 0;
+
+function generateId() {
+ idCounter += 1;
+ return `job_${Date.now().toString(36)}_${idCounter.toString(36)}`;
+}
+
+/**
+ * Build a fully-populated job from a sparse spec. Anything not supplied is
+ * filled from the target format's own capability defaults, so a job created
+ * with nothing but an input path and a target extension is already runnable.
+ */
+function createJob(spec = {}) {
+ const inputPath = spec.inputPath ?? null;
+ const sourceExt = spec.sourceExt ?? (inputPath ? formats.canonicalExt(extnameOf(inputPath)) : null);
+ const targetExt = formats.canonicalExt(spec.targetExt ?? spec.output?.ext ?? "") ?? null;
+
+ const targetFormat = targetExt ? formats.getFormat(targetExt) : null;
+ const mode = spec.mode ?? (sourceExt && targetExt ? formats.defaultMode(sourceExt, targetExt) : null);
+
+ return {
+ id: spec.id ?? generateId(),
+ inputPath,
+ sourceExt,
+ inputMeta: spec.inputMeta ?? null,
+ mode,
+
+ output: {
+ routing: spec.output?.routing ?? OUTPUT_ROUTING.ALONGSIDE,
+ dir: spec.output?.dir ?? null,
+ mirrorRoot: spec.output?.mirrorRoot ?? null,
+ nameTemplate: spec.output?.nameTemplate ?? "{name}",
+ ext: targetExt,
+ onConflict: spec.output?.onConflict ?? CONFLICT.UNIQUE,
+ },
+
+ video: {
+ mode: spec.video?.mode ?? defaultVideoMode(targetFormat, mode),
+ codec: spec.video?.codec ?? targetFormat?.defaultVideoCodec ?? null,
+ crf: spec.video?.crf ?? null,
+ bitrate: spec.video?.bitrate ?? null,
+ preset: spec.video?.preset ?? null,
+ width: spec.video?.width ?? null,
+ height: spec.video?.height ?? null,
+ fitMode: spec.video?.fitMode ?? "contain",
+ fps: spec.video?.fps ?? null,
+ },
+
+ audio: {
+ mode: spec.audio?.mode ?? defaultAudioMode(targetFormat, mode),
+ codec: spec.audio?.codec ?? targetFormat?.defaultAudioCodec ?? null,
+ bitrate: spec.audio?.bitrate ?? null,
+ sampleRate: spec.audio?.sampleRate ?? null,
+ channels: spec.audio?.channels ?? null,
+ },
+
+ image: {
+ quality: spec.image?.quality ?? targetFormat?.quality?.default ?? null,
+ scale: spec.image?.scale ?? null,
+ },
+
+ // Null means "from the start" / "to the end". Seconds, as floats.
+ trim: {
+ start: spec.trim?.start ?? null,
+ end: spec.trim?.end ?? null,
+ },
+
+ // Extra raw filters, and the pipeline this job was built from (if any).
+ filters: spec.filters ?? [],
+ pipelineId: spec.pipelineId ?? null,
+
+ status: spec.status ?? STATUS.PENDING,
+ progress: spec.progress ?? 0,
+ error: spec.error ?? null,
+ outputPath: spec.outputPath ?? null,
+ };
+}
+
+function defaultVideoMode(targetFormat, mode) {
+ if (!targetFormat) return STREAM_MODE.ENCODE;
+ if (mode === formats.MODE.EXTRACT) return STREAM_MODE.DROP;
+ if (targetFormat.kind === formats.KIND.AUDIO) return STREAM_MODE.DROP;
+ return STREAM_MODE.ENCODE;
+}
+
+function defaultAudioMode(targetFormat, mode) {
+ if (!targetFormat) return STREAM_MODE.ENCODE;
+ if (targetFormat.kind === formats.KIND.IMAGE) return STREAM_MODE.DROP;
+ if (mode === formats.MODE.ASSEMBLE) return STREAM_MODE.DROP;
+ return STREAM_MODE.ENCODE;
+}
+
+function extnameOf(filePath) {
+ const base = String(filePath).split(/[\\/]/).pop() ?? "";
+ const dot = base.lastIndexOf(".");
+ return dot > 0 ? base.slice(dot + 1) : "";
+}
+
+/**
+ * Apply a partial patch to a job, merging nested sections rather than
+ * replacing them. This is what bulk-editing a multi-card selection uses.
+ */
+function patchJob(job, patch = {}) {
+ const next = { ...job };
+ for (const [key, value] of Object.entries(patch)) {
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof job[key] === "object" && job[key] !== null) {
+ next[key] = { ...job[key], ...value };
+ } else {
+ next[key] = value;
+ }
+ }
+ return next;
+}
+
+/**
+ * Structural validation. Returns { valid, errors[] }. Metadata is deliberately
+ * not required: a job is valid before its probe has returned, so cards can
+ * render immediately and enrich later.
+ */
+function validateJob(job) {
+ const errors = [];
+
+ if (!job.inputPath) errors.push("Job has no input path.");
+ if (!job.sourceExt) errors.push("Input file type is not recognised.");
+ if (!job.output?.ext) errors.push("Job has no target format.");
+
+ if (job.sourceExt && job.output?.ext) {
+ if (!formats.canConvert(job.sourceExt, job.output.ext)) {
+ errors.push(`Cannot convert ${job.sourceExt} to ${job.output.ext}.`);
+ } else if (job.mode && !formats.allowedModes(job.sourceExt, job.output.ext).includes(job.mode)) {
+ errors.push(`Mode "${job.mode}" is not valid for ${job.sourceExt} to ${job.output.ext}.`);
+ }
+ }
+
+ const target = job.output?.ext ? formats.getFormat(job.output.ext) : null;
+
+ if (job.video?.mode === STREAM_MODE.COPY && target && !target.supportsStreamCopy) {
+ errors.push(`${target.label} does not support copying the video stream.`);
+ }
+ if (job.audio?.mode === STREAM_MODE.COPY && target && !target.supportsStreamCopy) {
+ errors.push(`${target.label} does not support copying the audio stream.`);
+ }
+ if (job.video?.mode === STREAM_MODE.DROP && job.audio?.mode === STREAM_MODE.DROP) {
+ errors.push("Both video and audio are set to be dropped, so there is nothing to write.");
+ }
+
+ if (job.video?.codec && target?.videoCodecs && !target.videoCodecs.includes(job.video.codec)) {
+ errors.push(`${target.label} does not support the video codec "${job.video.codec}".`);
+ }
+ if (job.audio?.mode === STREAM_MODE.ENCODE && job.audio?.codec && target?.audioCodecs && !target.audioCodecs.includes(job.audio.codec)) {
+ errors.push(`${target.label} does not support the audio codec "${job.audio.codec}".`);
+ }
+
+ const { start, end } = job.trim ?? {};
+ if (start !== null && start !== undefined && start < 0) errors.push("Trim start cannot be negative.");
+ if (start != null && end != null && end <= start) errors.push("Trim end must be after trim start.");
+
+ if (job.output?.routing === OUTPUT_ROUTING.FIXED && !job.output.dir) {
+ errors.push("A fixed output directory was selected but no directory was given.");
+ }
+ if (job.output?.routing === OUTPUT_ROUTING.MIRROR && !job.output.mirrorRoot) {
+ errors.push("Mirrored output was selected but no source root was given.");
+ }
+
+ return { valid: errors.length === 0, errors };
+}
+
+/** True when this job writes a directory of files rather than a single file. */
+function producesDirectory(job) {
+ return formats.producesDirectory(job.mode);
+}
+
+module.exports = {
+ STATUS,
+ STREAM_MODE,
+ CONFLICT,
+ OUTPUT_ROUTING,
+ generateId,
+ createJob,
+ patchJob,
+ validateJob,
+ producesDirectory,
+};
diff --git a/src/core/paths.js b/src/core/paths.js
new file mode 100644
index 0000000..45eb3ed
--- /dev/null
+++ b/src/core/paths.js
@@ -0,0 +1,170 @@
+"use strict";
+
+// Output path resolution.
+//
+// v1 hardcoded the output directory to the input's own directory in both
+// branches of the convert handler, so there was no destination concept at all.
+// This module owns routing, name templating and conflict resolution.
+
+const fs = require("fs");
+const path = require("path");
+
+const { OUTPUT_ROUTING, CONFLICT } = require("./job");
+const formats = require("./formats");
+
+/**
+ * The next free filename: "file (1).ext", "file (2).ext", ...
+ * Lifted unchanged from main.js so existing behaviour is preserved exactly.
+ */
+function getUniquePath(filePath) {
+ if (!fs.existsSync(filePath)) return filePath;
+ const ext = path.extname(filePath);
+ const stem = path.basename(filePath, ext);
+ const dir = path.dirname(filePath);
+ let i = 1;
+ let candidate;
+ do {
+ candidate = path.join(dir, `${stem} (${i})${ext}`);
+ i++;
+ } while (fs.existsSync(candidate));
+ return candidate;
+}
+
+/** The next free directory name: "dir (1)", "dir (2)", ... */
+function getUniqueDirPath(dirPath) {
+ if (!fs.existsSync(dirPath)) return dirPath;
+ let i = 1;
+ let candidate;
+ do {
+ candidate = `${dirPath} (${i})`;
+ i++;
+ } while (fs.existsSync(candidate));
+ return candidate;
+}
+
+/**
+ * Expand a name template. Supported tokens:
+ * {name} input basename without extension
+ * {ext} target extension, no dot
+ * {src} source extension, no dot
+ * {index} 1-based position in the batch
+ * {date} YYYY-MM-DD
+ * {time} HHMMSS
+ * {preset} preset name, or "" when the job has none
+ * Unknown tokens are left alone rather than silently blanked, so a typo is
+ * visible in the output filename instead of vanishing.
+ */
+function applyNameTemplate(template, context) {
+ const now = context.now instanceof Date ? context.now : new Date();
+ const pad = (n) => String(n).padStart(2, "0");
+
+ const values = {
+ name: context.name ?? "",
+ ext: context.ext ?? "",
+ src: context.src ?? "",
+ index: context.index != null ? String(context.index) : "",
+ date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
+ time: `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`,
+ preset: context.preset ?? "",
+ };
+
+ return String(template ?? "{name}").replace(/\{(\w+)\}/g, (match, key) =>
+ Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match
+ );
+}
+
+/** Strip characters Windows will not accept in a filename. */
+function sanitiseName(name) {
+ return String(name)
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
+ .replace(/[. ]+$/, "")
+ .trim() || "output";
+}
+
+/** The directory a job's output belongs in, before conflict resolution. */
+function resolveOutputDir(job) {
+ const inputDir = path.dirname(job.inputPath);
+ const routing = job.output?.routing ?? OUTPUT_ROUTING.ALONGSIDE;
+
+ switch (routing) {
+ case OUTPUT_ROUTING.FIXED:
+ return job.output.dir ?? inputDir;
+
+ case OUTPUT_ROUTING.MIRROR: {
+ // Rebuild the input's position under the source root inside the
+ // destination, so a nested folder ingest keeps its shape.
+ const root = job.output.mirrorRoot;
+ const dest = job.output.dir ?? inputDir;
+ if (!root) return dest;
+ const relative = path.relative(root, inputDir);
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return dest;
+ return path.join(dest, relative);
+ }
+
+ case OUTPUT_ROUTING.ALONGSIDE:
+ default:
+ return inputDir;
+ }
+}
+
+/**
+ * Resolve the final output path for a job.
+ *
+ * Returns { outputPath, isDirectory, action } where action is "write",
+ * "skip" or "ask". "ask" means the caller has to prompt, which is the one
+ * decision this module deliberately does not make on its own.
+ */
+function resolveOutputPath(job, context = {}) {
+ const targetExt = job.output.ext;
+ const dir = resolveOutputDir(job);
+ const inputExt = path.extname(job.inputPath);
+ const stem = path.basename(job.inputPath, inputExt);
+
+ const name = sanitiseName(applyNameTemplate(job.output.nameTemplate, {
+ name: stem,
+ ext: targetExt,
+ src: job.sourceExt ?? inputExt.replace(/^\./, ""),
+ index: context.index,
+ preset: context.preset,
+ now: context.now,
+ }));
+
+ const isDirectory = formats.producesDirectory(job.mode);
+ const candidate = isDirectory ? path.join(dir, name) : path.join(dir, `${name}.${targetExt}`);
+
+ const exists = fs.existsSync(candidate);
+ if (!exists) return { outputPath: candidate, isDirectory, action: "write" };
+
+ switch (job.output.onConflict) {
+ case CONFLICT.OVERWRITE:
+ return { outputPath: candidate, isDirectory, action: "write" };
+ case CONFLICT.SKIP:
+ return { outputPath: candidate, isDirectory, action: "skip" };
+ case CONFLICT.ASK:
+ return { outputPath: candidate, isDirectory, action: "ask" };
+ case CONFLICT.UNIQUE:
+ default:
+ return {
+ outputPath: isDirectory ? getUniqueDirPath(candidate) : getUniquePath(candidate),
+ isDirectory,
+ action: "write",
+ };
+ }
+}
+
+/** Create the directory an output will be written into. */
+function ensureParentDir(outputPath, isDirectory) {
+ const dir = isDirectory ? outputPath : path.dirname(outputPath);
+ fs.mkdirSync(dir, { recursive: true });
+ return dir;
+}
+
+module.exports = {
+ getUniquePath,
+ getUniqueDirPath,
+ applyNameTemplate,
+ sanitiseName,
+ resolveOutputDir,
+ resolveOutputPath,
+ ensureParentDir,
+};
diff --git a/src/core/pipeline.js b/src/core/pipeline.js
new file mode 100644
index 0000000..970a389
--- /dev/null
+++ b/src/core/pipeline.js
@@ -0,0 +1,410 @@
+"use strict";
+
+// Pipeline schema and compiler.
+//
+// This slice deliberately ships no flowchart editor. What it ships is the
+// thing the editor will draw: a validated node graph, and a compiler that
+// turns that graph into an ffmpeg filter_complex plus stream maps. Defining
+// the data model first is what lets the editor later be a pure view layer
+// over an already-tested representation.
+
+const PORT = {
+ VIDEO: "video",
+ AUDIO: "audio",
+ ANY: "any",
+};
+
+/**
+ * Node type registry. `inputs`/`outputs` describe ports; `emit` renders the
+ * ffmpeg filter body for that node, or returns null for nodes that carry no
+ * filter of their own (input, output, encode).
+ */
+const NODE_TYPES = {
+ input: {
+ label: "Input",
+ inputs: [],
+ outputs: [{ name: "video", type: PORT.VIDEO }, { name: "audio", type: PORT.AUDIO }],
+ emit: () => null,
+ },
+
+ trim: {
+ label: "Trim",
+ inputs: [{ name: "in", type: PORT.ANY }],
+ outputs: [{ name: "out", type: PORT.ANY }],
+ required: [],
+ emit: (params, portType) => {
+ const parts = [];
+ if (params.start != null) parts.push(`start=${num(params.start)}`);
+ if (params.end != null) parts.push(`end=${num(params.end)}`);
+ if (parts.length === 0) return null;
+ const filter = portType === PORT.AUDIO ? "atrim" : "trim";
+ const reset = portType === PORT.AUDIO ? "asetpts=PTS-STARTPTS" : "setpts=PTS-STARTPTS";
+ return `${filter}=${parts.join(":")},${reset}`;
+ },
+ },
+
+ scale: {
+ label: "Scale",
+ inputs: [{ name: "in", type: PORT.VIDEO }],
+ outputs: [{ name: "out", type: PORT.VIDEO }],
+ required: [],
+ emit: (params) => {
+ const w = params.width ?? -2;
+ const h = params.height ?? -2;
+ if (params.width == null && params.height == null) return null;
+ switch (params.fitMode) {
+ case "stretch": return `scale=${w}:${h}`;
+ case "cover": return `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
+ default: return `scale=${w}:${h}:force_original_aspect_ratio=decrease`;
+ }
+ },
+ },
+
+ crop: {
+ label: "Crop",
+ inputs: [{ name: "in", type: PORT.VIDEO }],
+ outputs: [{ name: "out", type: PORT.VIDEO }],
+ required: ["width", "height"],
+ emit: (params) =>
+ `crop=${num(params.width)}:${num(params.height)}:${num(params.x ?? 0)}:${num(params.y ?? 0)}`,
+ },
+
+ fps: {
+ label: "Frame rate",
+ inputs: [{ name: "in", type: PORT.VIDEO }],
+ outputs: [{ name: "out", type: PORT.VIDEO }],
+ required: ["fps"],
+ emit: (params) => `fps=${num(params.fps)}`,
+ },
+
+ volume: {
+ label: "Volume",
+ inputs: [{ name: "in", type: PORT.AUDIO }],
+ outputs: [{ name: "out", type: PORT.AUDIO }],
+ required: ["volume"],
+ emit: (params) => `volume=${num(params.volume)}`,
+ },
+
+ concat: {
+ label: "Concatenate",
+ inputs: [{ name: "a", type: PORT.ANY }, { name: "b", type: PORT.ANY }],
+ outputs: [{ name: "out", type: PORT.ANY }],
+ variadic: true,
+ emit: (_params, portType, arity) => {
+ const n = arity ?? 2;
+ return portType === PORT.AUDIO
+ ? `concat=n=${n}:v=0:a=1`
+ : `concat=n=${n}:v=1:a=0`;
+ },
+ },
+
+ overlay: {
+ label: "Overlay",
+ inputs: [{ name: "base", type: PORT.VIDEO }, { name: "overlay", type: PORT.VIDEO }],
+ outputs: [{ name: "out", type: PORT.VIDEO }],
+ required: [],
+ emit: (params) => `overlay=${num(params.x ?? 0)}:${num(params.y ?? 0)}`,
+ },
+
+ encode: {
+ label: "Encode",
+ inputs: [{ name: "video", type: PORT.VIDEO }, { name: "audio", type: PORT.AUDIO }],
+ outputs: [{ name: "video", type: PORT.VIDEO }, { name: "audio", type: PORT.AUDIO }],
+ // Encoding settings are applied to the job, not the filter graph.
+ emit: () => null,
+ },
+
+ output: {
+ label: "Output",
+ inputs: [{ name: "video", type: PORT.VIDEO }, { name: "audio", type: PORT.AUDIO }],
+ outputs: [],
+ emit: () => null,
+ },
+};
+
+function createPipeline(spec = {}) {
+ return {
+ id: spec.id ?? `pl_${Date.now().toString(36)}`,
+ name: spec.name ?? "Untitled pipeline",
+ version: 1,
+ nodes: spec.nodes ?? [],
+ edges: spec.edges ?? [],
+ };
+}
+
+/**
+ * Structural validation of a graph.
+ * @returns {{valid: boolean, errors: string[], warnings: string[], order: string[]|null}}
+ */
+function validate(pipeline) {
+ const errors = [];
+ const warnings = [];
+
+ const nodes = Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
+ const edges = Array.isArray(pipeline?.edges) ? pipeline.edges : [];
+
+ if (nodes.length === 0) {
+ return { valid: false, errors: ["Pipeline has no nodes."], warnings, order: null };
+ }
+
+ const byId = new Map();
+ for (const node of nodes) {
+ if (!node.id) { errors.push("A node is missing an id."); continue; }
+ if (byId.has(node.id)) errors.push(`Duplicate node id "${node.id}".`);
+ if (!NODE_TYPES[node.type]) errors.push(`Node "${node.id}" has unknown type "${node.type}".`);
+ byId.set(node.id, node);
+ }
+
+ const inputs = nodes.filter(n => n.type === "input");
+ const outputs = nodes.filter(n => n.type === "output");
+ if (inputs.length === 0) errors.push("Pipeline has no input node.");
+ if (outputs.length === 0) errors.push("Pipeline has no output node.");
+ if (outputs.length > 1) errors.push("Pipeline has more than one output node.");
+
+ // -- Edge integrity and port types ---------------------------------------
+ for (const edge of edges) {
+ const from = byId.get(edge.from);
+ const to = byId.get(edge.to);
+ if (!from) { errors.push(`Edge references unknown source node "${edge.from}".`); continue; }
+ if (!to) { errors.push(`Edge references unknown target node "${edge.to}".`); continue; }
+
+ const fromSpec = NODE_TYPES[from.type];
+ const toSpec = NODE_TYPES[to.type];
+ if (!fromSpec || !toSpec) continue;
+
+ const outPort = fromSpec.outputs.find(p => p.name === edge.fromPort);
+ const inPort = toSpec.inputs.find(p => p.name === edge.toPort);
+
+ if (!outPort) errors.push(`Node "${from.id}" has no output port "${edge.fromPort}".`);
+ if (!inPort && !toSpec.variadic) errors.push(`Node "${to.id}" has no input port "${edge.toPort}".`);
+
+ if (outPort && inPort) {
+ const compatible =
+ outPort.type === inPort.type ||
+ outPort.type === PORT.ANY ||
+ inPort.type === PORT.ANY;
+ if (!compatible) {
+ errors.push(`Cannot connect ${outPort.type} output of "${from.id}" to ${inPort.type} input of "${to.id}".`);
+ }
+ }
+ }
+
+ // -- Required params -----------------------------------------------------
+ for (const node of nodes) {
+ const spec = NODE_TYPES[node.type];
+ if (!spec?.required) continue;
+ for (const key of spec.required) {
+ if (node.params?.[key] == null) {
+ errors.push(`Node "${node.id}" (${spec.label}) is missing required setting "${key}".`);
+ }
+ }
+ }
+
+ // -- Cycles --------------------------------------------------------------
+ const order = topoSort(nodes, edges);
+ if (order === null) {
+ errors.push("Pipeline contains a cycle, so it cannot be run.");
+ }
+
+ // -- Reachability --------------------------------------------------------
+ if (order && outputs.length === 1) {
+ const reaching = collectAncestors(outputs[0].id, edges);
+ for (const node of nodes) {
+ if (node.id === outputs[0].id) continue;
+ if (!reaching.has(node.id)) {
+ warnings.push(`Node "${node.id}" (${NODE_TYPES[node.type]?.label ?? node.type}) is not connected to the output and will be ignored.`);
+ }
+ }
+ }
+
+ return { valid: errors.length === 0, errors, warnings, order };
+}
+
+/** Kahn's algorithm. Returns node ids in dependency order, or null on a cycle. */
+function topoSort(nodes, edges) {
+ const indegree = new Map(nodes.map(n => [n.id, 0]));
+ const adjacency = new Map(nodes.map(n => [n.id, []]));
+
+ for (const edge of edges) {
+ if (!indegree.has(edge.from) || !indegree.has(edge.to)) continue;
+ adjacency.get(edge.from).push(edge.to);
+ indegree.set(edge.to, indegree.get(edge.to) + 1);
+ }
+
+ const queue = [...indegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
+ const order = [];
+
+ while (queue.length > 0) {
+ const id = queue.shift();
+ order.push(id);
+ for (const next of adjacency.get(id) ?? []) {
+ indegree.set(next, indegree.get(next) - 1);
+ if (indegree.get(next) === 0) queue.push(next);
+ }
+ }
+
+ return order.length === nodes.length ? order : null;
+}
+
+function collectAncestors(nodeId, edges) {
+ const seen = new Set([nodeId]);
+ const stack = [nodeId];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ for (const edge of edges) {
+ if (edge.to === current && !seen.has(edge.from)) {
+ seen.add(edge.from);
+ stack.push(edge.from);
+ }
+ }
+ }
+ return seen;
+}
+
+/**
+ * Compile a validated pipeline into ffmpeg pieces.
+ *
+ * @returns {{filterComplex: string|null, maps: string[], extraArgs: string[], encode: object|null}}
+ * `maps` are ready to be paired with -map. `encode` carries any
+ * settings from an encode node for the job to absorb.
+ */
+function compile(pipeline) {
+ const result = validate(pipeline);
+ if (!result.valid) {
+ const err = new Error(`Pipeline is not valid: ${result.errors[0]}`);
+ err.errors = result.errors;
+ throw err;
+ }
+
+ const byId = new Map(pipeline.nodes.map(n => [n.id, n]));
+ const chains = [];
+ // nodeId -> { portName -> { label, type } }
+ const outputLabels = new Map();
+
+ let labelCounter = 0;
+ const nextLabel = (hint) => `${hint}${labelCounter++}`;
+
+ const incomingFor = (nodeId, portName) =>
+ pipeline.edges.filter(e => e.to === nodeId && (portName == null || e.toPort === portName));
+
+ for (const nodeId of result.order) {
+ const node = byId.get(nodeId);
+ const spec = NODE_TYPES[node.type];
+
+ if (node.type === "input") {
+ // The single source file is always ffmpeg input 0.
+ outputLabels.set(nodeId, {
+ video: { label: "0:v", type: PORT.VIDEO },
+ audio: { label: "0:a", type: PORT.AUDIO },
+ });
+ continue;
+ }
+
+ if (node.type === "output") continue;
+
+ if (node.type === "encode") {
+ // Passthrough in the graph; its params are read separately below.
+ const ports = {};
+ for (const port of spec.inputs) {
+ const edge = incomingFor(nodeId, port.name)[0];
+ if (!edge) continue;
+ const upstream = outputLabels.get(edge.from)?.[edge.fromPort];
+ if (upstream) ports[port.name] = upstream;
+ }
+ outputLabels.set(nodeId, ports);
+ continue;
+ }
+
+ // Gather this node's inputs in declared port order.
+ const incoming = spec.variadic
+ ? incomingFor(nodeId)
+ : spec.inputs.map(p => incomingFor(nodeId, p.name)[0]).filter(Boolean);
+
+ if (incoming.length === 0) {
+ // Nothing feeds it, so it contributes nothing.
+ outputLabels.set(nodeId, {});
+ continue;
+ }
+
+ const sources = incoming
+ .map(edge => outputLabels.get(edge.from)?.[edge.fromPort])
+ .filter(Boolean);
+
+ if (sources.length === 0) {
+ outputLabels.set(nodeId, {});
+ continue;
+ }
+
+ // An ANY-typed node takes its concrete type from what it is fed.
+ const portType = sources[0].type;
+ const body = spec.emit(node.params ?? {}, portType, sources.length);
+
+ if (!body) {
+ // A no-op node (a trim with no bounds set) forwards its input
+ // rather than inserting an empty link into the chain.
+ outputLabels.set(nodeId, { [spec.outputs[0]?.name ?? "out"]: sources[0] });
+ continue;
+ }
+
+ const outLabel = nextLabel(portType === PORT.AUDIO ? "a" : "v");
+ chains.push(`${sources.map(s => `[${s.label}]`).join("")}${body}[${outLabel}]`);
+
+ const outPortName = spec.outputs[0]?.name ?? "out";
+ outputLabels.set(nodeId, { [outPortName]: { label: outLabel, type: portType } });
+ }
+
+ // -- Resolve what the output node is actually fed -------------------------
+ const outputNode = pipeline.nodes.find(n => n.type === "output");
+ const maps = [];
+ for (const port of NODE_TYPES.output.inputs) {
+ const edge = incomingFor(outputNode.id, port.name)[0];
+ if (!edge) continue;
+ const upstream = outputLabels.get(edge.from)?.[edge.fromPort];
+ if (upstream) maps.push(`[${upstream.label}]`);
+ }
+
+ const encodeNode = pipeline.nodes.find(n => n.type === "encode");
+
+ return {
+ filterComplex: chains.length > 0 ? chains.join(";") : null,
+ maps,
+ extraArgs: [],
+ encode: encodeNode?.params ?? null,
+ warnings: result.warnings,
+ };
+}
+
+/**
+ * Fold a compiled pipeline into a job, so the runner needs to know nothing
+ * about pipelines at all.
+ */
+function applyToJob(job, pipeline) {
+ const compiled = compile(pipeline);
+ const extraArgs = [];
+
+ if (compiled.filterComplex) extraArgs.push("-filter_complex", compiled.filterComplex);
+ for (const map of compiled.maps) extraArgs.push("-map", map);
+
+ return {
+ ...job,
+ pipelineId: pipeline.id,
+ extraArgs: [...(job.extraArgs ?? []), ...extraArgs],
+ video: { ...job.video, ...(compiled.encode?.video ?? {}) },
+ audio: { ...job.audio, ...(compiled.encode?.audio ?? {}) },
+ };
+}
+
+function num(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? String(n) : "0";
+}
+
+module.exports = {
+ PORT,
+ NODE_TYPES,
+ createPipeline,
+ validate,
+ compile,
+ applyToJob,
+ topoSort,
+};
diff --git a/src/core/probe.js b/src/core/probe.js
new file mode 100644
index 0000000..14f93e4
--- /dev/null
+++ b/src/core/probe.js
@@ -0,0 +1,142 @@
+"use strict";
+
+// ffprobe wrapper.
+//
+// v1 never set an ffprobe path and never shipped the binary, so no metadata
+// was available anywhere in the app. Probing here is treated as *enrichment*,
+// not a precondition: a failed probe returns a result with `ok: false` and the
+// job stays runnable. Cards render filename and size immediately and fill in
+// duration, resolution and codecs when this resolves.
+
+const { execFile } = require("child_process");
+const { buildProbeArgs } = require("./ffmpeg-args");
+
+const PROBE_TIMEOUT_MS = 30000;
+const MAX_BUFFER = 8 * 1024 * 1024;
+
+let ffprobePath = null;
+
+function setFfprobePath(binaryPath) {
+ ffprobePath = binaryPath;
+}
+
+function getFfprobePath() {
+ return ffprobePath;
+}
+
+/**
+ * Probe a single file.
+ * Always resolves; never rejects. Check `.ok` before trusting the fields.
+ */
+function probe(inputPath) {
+ return new Promise((resolve) => {
+ if (!ffprobePath) {
+ resolve({ ok: false, error: "ffprobe path has not been configured.", inputPath });
+ return;
+ }
+
+ execFile(
+ ffprobePath,
+ buildProbeArgs(inputPath),
+ { timeout: PROBE_TIMEOUT_MS, maxBuffer: MAX_BUFFER, windowsHide: true },
+ (err, stdout) => {
+ if (err) {
+ resolve({ ok: false, error: err.message, inputPath });
+ return;
+ }
+ try {
+ resolve(normalise(JSON.parse(stdout), inputPath));
+ } catch (parseErr) {
+ resolve({ ok: false, error: `Could not parse ffprobe output: ${parseErr.message}`, inputPath });
+ }
+ }
+ );
+ });
+}
+
+/** Flatten ffprobe's raw JSON into the shape the UI actually binds to. */
+function normalise(raw, inputPath) {
+ const streams = Array.isArray(raw.streams) ? raw.streams : [];
+ const format = raw.format ?? {};
+
+ const videoStreams = streams.filter(s => s.codec_type === "video");
+ const audioStreams = streams.filter(s => s.codec_type === "audio");
+ const subtitleStreams = streams.filter(s => s.codec_type === "subtitle");
+
+ // Cover art and other attached stills report as video streams with a
+ // single frame. Treating those as "this file has video" would put a
+ // resolution badge on an MP3, so they are separated out.
+ const motionStreams = videoStreams.filter(s => !isAttachedPicture(s));
+ const primaryVideo = motionStreams[0] ?? null;
+ const primaryAudio = audioStreams[0] ?? null;
+
+ const duration = toNumber(format.duration)
+ ?? toNumber(primaryVideo?.duration)
+ ?? toNumber(primaryAudio?.duration)
+ ?? null;
+
+ return {
+ ok: true,
+ inputPath,
+ duration,
+ size: toNumber(format.size),
+ bitrate: toNumber(format.bit_rate),
+ formatName: format.format_name ?? null,
+ hasVideo: motionStreams.length > 0,
+ hasAudio: audioStreams.length > 0,
+ hasSubtitles: subtitleStreams.length > 0,
+
+ video: primaryVideo ? {
+ codec: primaryVideo.codec_name ?? null,
+ width: toNumber(primaryVideo.width),
+ height: toNumber(primaryVideo.height),
+ fps: parseFrameRate(primaryVideo.avg_frame_rate) ?? parseFrameRate(primaryVideo.r_frame_rate),
+ pixelFormat: primaryVideo.pix_fmt ?? null,
+ bitrate: toNumber(primaryVideo.bit_rate),
+ } : null,
+
+ audio: primaryAudio ? {
+ codec: primaryAudio.codec_name ?? null,
+ sampleRate: toNumber(primaryAudio.sample_rate),
+ channels: toNumber(primaryAudio.channels),
+ channelLayout: primaryAudio.channel_layout ?? null,
+ bitrate: toNumber(primaryAudio.bit_rate),
+ } : null,
+
+ streamCounts: {
+ video: motionStreams.length,
+ audio: audioStreams.length,
+ subtitle: subtitleStreams.length,
+ },
+ };
+}
+
+function isAttachedPicture(stream) {
+ if (stream.disposition?.attached_pic === 1) return true;
+ // Still-image codecs carried inside a media container are cover art.
+ return ["mjpeg", "png", "bmp", "gif"].includes(stream.codec_name) && !stream.avg_frame_rate?.startsWith("0/");
+}
+
+/** ffprobe reports frame rates as "30000/1001" rationals. */
+function parseFrameRate(value) {
+ if (typeof value !== "string" || !value.includes("/")) return null;
+ const [num, den] = value.split("/").map(Number);
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null;
+ const fps = num / den;
+ if (fps <= 0) return null;
+ return Math.round(fps * 1000) / 1000;
+}
+
+function toNumber(value) {
+ if (value == null) return null;
+ const n = Number(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+module.exports = {
+ setFfprobePath,
+ getFfprobePath,
+ probe,
+ normalise,
+ parseFrameRate,
+};
diff --git a/src/core/runner.js b/src/core/runner.js
new file mode 100644
index 0000000..62fab5a
--- /dev/null
+++ b/src/core/runner.js
@@ -0,0 +1,375 @@
+"use strict";
+
+// The job runner.
+//
+// v1 ran a strictly sequential `for await` loop in the renderer against a
+// single `activeConversion` global in main, so there was exactly one ffmpeg
+// process, no per-job identity, and cancel was all-or-nothing. This runner
+// owns a concurrency pool, gives every job an id, and can cancel one job
+// without touching the others.
+
+const { EventEmitter } = require("events");
+const { spawn } = require("child_process");
+const fs = require("fs");
+const os = require("os");
+
+const { STATUS } = require("./job");
+const { buildArgs } = require("./ffmpeg-args");
+const paths = require("./paths");
+
+function defaultConcurrency() {
+ const cpus = os.cpus()?.length ?? 2;
+ return Math.max(1, Math.min(4, Math.floor(cpus / 2)));
+}
+
+class JobRunner extends EventEmitter {
+ /**
+ * @param {object} options
+ * @param {string} options.ffmpegPath
+ * @param {number} [options.concurrency]
+ * @param {(job, candidatePath) => Promise<{action: string, outputPath?: string}>} [options.conflictResolver]
+ * Called when a job's conflict policy is "ask". Must resolve to
+ * { action: "write" | "skip" | "cancel", outputPath }.
+ */
+ constructor(options = {}) {
+ super();
+ this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
+ this.concurrency = options.concurrency ?? defaultConcurrency();
+ this.conflictResolver = options.conflictResolver ?? null;
+
+ this.queue = []; // jobs waiting to start
+ this.active = new Map(); // jobId -> { job, child, outputPath, isDirectory }
+ this.results = new Map(); // jobId -> result
+ this.running = false;
+ this.paused = false;
+ this._cancelledAll = false;
+ }
+
+ setConcurrency(n) {
+ this.concurrency = Math.max(1, Number(n) || 1);
+ if (this.running && !this.paused) this._pump();
+ }
+
+ enqueue(job) {
+ this.queue.push(job);
+ if (this.running && !this.paused) this._pump();
+ return job.id;
+ }
+
+ enqueueAll(jobs) {
+ for (const job of jobs) this.queue.push(job);
+ if (this.running && !this.paused) this._pump();
+ return jobs.map(j => j.id);
+ }
+
+ start() {
+ if (this.running) return;
+ this.running = true;
+ this.paused = false;
+ this._cancelledAll = false;
+ this._pump();
+ }
+
+ /** Stop starting new jobs. Jobs already running are left to finish. */
+ pause() {
+ this.paused = true;
+ this.emit("paused");
+ }
+
+ resume() {
+ if (!this.paused) return;
+ this.paused = false;
+ this.emit("resumed");
+ this._pump();
+ }
+
+ /** Cancel one job, whether it is running or still queued. */
+ cancel(jobId) {
+ const entry = this.active.get(jobId);
+ if (entry) {
+ entry.cancelled = true;
+ try { entry.child.kill(); } catch (_) { /* already gone */ }
+ return true;
+ }
+
+ const index = this.queue.findIndex(j => j.id === jobId);
+ if (index !== -1) {
+ const [job] = this.queue.splice(index, 1);
+ this._finish(job, { status: STATUS.CANCELLED });
+ return true;
+ }
+ return false;
+ }
+
+ cancelAll() {
+ this._cancelledAll = true;
+
+ const queued = this.queue.splice(0, this.queue.length);
+ for (const job of queued) this._finish(job, { status: STATUS.CANCELLED });
+
+ for (const jobId of [...this.active.keys()]) this.cancel(jobId);
+
+ if (this.active.size === 0) this._checkIdle();
+ }
+
+ get pending() { return this.queue.length; }
+ get activeCount() { return this.active.size; }
+
+ _pump() {
+ if (!this.running || this.paused) return;
+ while (this.active.size < this.concurrency && this.queue.length > 0) {
+ const job = this.queue.shift();
+ this._run(job).catch((err) => {
+ this._finish(job, { status: STATUS.ERROR, error: err.message });
+ });
+ }
+ this._checkIdle();
+ }
+
+ async _run(job) {
+ if (this._cancelledAll) {
+ this._finish(job, { status: STATUS.CANCELLED });
+ return;
+ }
+
+ // -- Decide where this is going ---------------------------------------
+ let resolved;
+ try {
+ resolved = paths.resolveOutputPath(job);
+ } catch (err) {
+ this._finish(job, { status: STATUS.ERROR, error: err.message });
+ return;
+ }
+
+ if (resolved.action === "skip") {
+ this._finish(job, { status: STATUS.SKIPPED, outputPath: resolved.outputPath });
+ return;
+ }
+
+ if (resolved.action === "ask") {
+ if (!this.conflictResolver) {
+ // Without a resolver, falling back to a unique name is safer
+ // than overwriting something the user did not agree to lose.
+ resolved = { ...resolved, outputPath: resolved.isDirectory
+ ? paths.getUniqueDirPath(resolved.outputPath)
+ : paths.getUniquePath(resolved.outputPath) };
+ } else {
+ const answer = await this.conflictResolver(job, resolved.outputPath);
+ if (!answer || answer.action === "cancel") {
+ this._finish(job, { status: STATUS.CANCELLED });
+ return;
+ }
+ if (answer.action === "skip") {
+ this._finish(job, { status: STATUS.SKIPPED, outputPath: resolved.outputPath });
+ return;
+ }
+ if (answer.outputPath) resolved = { ...resolved, outputPath: answer.outputPath };
+ }
+ }
+
+ const { outputPath, isDirectory } = resolved;
+
+ try {
+ paths.ensureParentDir(outputPath, isDirectory);
+ } catch (err) {
+ this._finish(job, { status: STATUS.ERROR, error: `Could not create the output folder: ${err.message}` });
+ return;
+ }
+
+ // -- Spawn -------------------------------------------------------------
+ let args;
+ try {
+ args = buildArgs(job, { outputPath });
+ } catch (err) {
+ this._finish(job, { status: STATUS.ERROR, error: err.message });
+ return;
+ }
+
+ const child = spawn(this.ffmpegPath, args, { windowsHide: true });
+ const entry = { job, child, outputPath, isDirectory, cancelled: false, stderr: "" };
+ this.active.set(job.id, entry);
+
+ job.status = STATUS.RUNNING;
+ this.emit("status", { jobId: job.id, status: STATUS.RUNNING });
+
+ const duration = durationOf(job);
+ let carry = "";
+
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ carry += chunk;
+ const lines = carry.split("\n");
+ carry = lines.pop() ?? "";
+ const update = parseProgressLines(lines, duration);
+ if (update) this.emit("progress", { jobId: job.id, ...update });
+ });
+
+ // ffmpeg writes all diagnostics to stderr; keep only the tail so a
+ // failure has a usable message without buffering an entire log.
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk) => {
+ entry.stderr = (entry.stderr + chunk).slice(-4000);
+ });
+
+ child.on("error", (err) => {
+ this.active.delete(job.id);
+ this._cleanupPartial(outputPath, isDirectory);
+ this._finish(job, { status: STATUS.ERROR, error: `Could not start ffmpeg: ${err.message}` });
+ this._pump();
+ });
+
+ child.on("close", (code) => {
+ this.active.delete(job.id);
+
+ if (entry.cancelled || this._cancelledAll) {
+ this._cleanupPartial(outputPath, isDirectory);
+ this._finish(job, { status: STATUS.CANCELLED });
+ } else if (code === 0) {
+ this.emit("progress", { jobId: job.id, percent: 100 });
+ this._finish(job, {
+ status: STATUS.DONE,
+ outputPath,
+ isDirectory,
+ fileCount: isDirectory ? countFiles(outputPath) : 1,
+ });
+ } else {
+ this._cleanupPartial(outputPath, isDirectory);
+ this._finish(job, {
+ status: STATUS.ERROR,
+ error: lastMeaningfulLine(entry.stderr) || `ffmpeg exited with code ${code}.`,
+ });
+ }
+
+ this._pump();
+ });
+ }
+
+ _finish(job, result) {
+ job.status = result.status;
+ if (result.outputPath) job.outputPath = result.outputPath;
+ if (result.error) job.error = result.error;
+
+ const payload = { jobId: job.id, ...result };
+ this.results.set(job.id, payload);
+ this.emit("status", payload);
+ }
+
+ _cleanupPartial(outputPath, isDirectory) {
+ try {
+ if (!fs.existsSync(outputPath)) return;
+ if (isDirectory) fs.rmSync(outputPath, { recursive: true, force: true });
+ else fs.unlinkSync(outputPath);
+ } catch (_) {
+ // A partial file we cannot remove is not worth failing the job over.
+ }
+ }
+
+ _checkIdle() {
+ if (this.running && this.active.size === 0 && this.queue.length === 0) {
+ this.running = false;
+ this.emit("idle", { results: [...this.results.values()] });
+ }
+ }
+}
+
+/**
+ * Parse a block of `-progress pipe:1` key=value lines.
+ * Returns null when the block carried nothing worth emitting.
+ */
+function parseProgressLines(lines, durationSeconds) {
+ let outTimeUs = null;
+ let speed = null;
+ let fps = null;
+ let ended = false;
+
+ for (const line of lines) {
+ const eq = line.indexOf("=");
+ if (eq === -1) continue;
+ const key = line.slice(0, eq).trim();
+ const value = line.slice(eq + 1).trim();
+
+ switch (key) {
+ case "out_time_us":
+ case "out_time_ms": {
+ // Despite the name, ffmpeg reports out_time_ms in microseconds.
+ const n = Number(value);
+ if (Number.isFinite(n) && n >= 0) outTimeUs = n;
+ break;
+ }
+ case "speed": {
+ const n = parseFloat(value);
+ if (Number.isFinite(n)) speed = n;
+ break;
+ }
+ case "fps": {
+ const n = parseFloat(value);
+ if (Number.isFinite(n)) fps = n;
+ break;
+ }
+ case "progress":
+ if (value === "end") ended = true;
+ break;
+ }
+ }
+
+ if (outTimeUs === null && !ended) return null;
+
+ const outSeconds = outTimeUs != null ? outTimeUs / 1e6 : null;
+
+ // Without a known duration (still images, some streams) there is nothing
+ // to divide by, so percent stays null and the UI shows indeterminate
+ // rather than the frozen 0% v1 displayed.
+ let percent = null;
+ if (ended) {
+ percent = 100;
+ } else if (durationSeconds && durationSeconds > 0 && outSeconds != null) {
+ percent = Math.min(99.9, (outSeconds / durationSeconds) * 100);
+ }
+
+ let eta = null;
+ if (percent != null && percent < 100 && durationSeconds && speed && speed > 0 && outSeconds != null) {
+ eta = Math.max(0, (durationSeconds - outSeconds) / speed);
+ }
+
+ return {
+ percent: percent != null ? Math.round(percent * 10) / 10 : null,
+ outSeconds,
+ speed,
+ fps,
+ eta,
+ };
+}
+
+function durationOf(job) {
+ const meta = job.inputMeta;
+ const { start, end } = job.trim ?? {};
+
+ if (start != null && end != null && end > start) return end - start;
+
+ const full = meta?.duration ?? null;
+ if (!full) return end ?? null;
+ if (start != null) return Math.max(0, full - start);
+ if (end != null) return Math.min(full, end);
+ return full;
+}
+
+function countFiles(dirPath) {
+ try {
+ return fs.readdirSync(dirPath).length;
+ } catch (_) {
+ return 0;
+ }
+}
+
+/** The last non-empty stderr line, which is where ffmpeg puts the real reason. */
+function lastMeaningfulLine(stderr) {
+ if (!stderr) return null;
+ const lines = stderr.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
+ return lines.length > 0 ? lines[lines.length - 1] : null;
+}
+
+module.exports = {
+ JobRunner,
+ parseProgressLines,
+ defaultConcurrency,
+};
diff --git a/src/core/scan.js b/src/core/scan.js
new file mode 100644
index 0000000..12817fe
--- /dev/null
+++ b/src/core/scan.js
@@ -0,0 +1,147 @@
+"use strict";
+
+// Directory ingest.
+//
+// v1's only folder handling was a synchronous fs.readdirSync one level deep,
+// inside the drop handler, on the UI thread, with nested subfolders enumerated
+// and then silently discarded by the format lookup. This walks properly:
+// async, recursive, depth-limited, and filtered by the format graph.
+
+const fs = require("fs/promises");
+const path = require("path");
+const formats = require("./formats");
+
+const DEFAULT_MAX_DEPTH = 16;
+const DEFAULT_MAX_FILES = 20000;
+
+/**
+ * Walk a set of input paths and return the supported media files beneath them.
+ *
+ * @param {string[]} inputPaths files and/or directories
+ * @param {object} [options]
+ * @param {number} [options.maxDepth] how far to recurse (0 = the given dir only)
+ * @param {number} [options.maxFiles] stop after this many matches
+ * @param {string[]} [options.include] extensions to keep (default: all supported)
+ * @param {string[]} [options.exclude] extensions to drop
+ * @param {boolean} [options.followSymlinks]
+ * @param {boolean} [options.recursive] default true
+ * @returns {Promise<{files: string[], skipped: number, truncated: boolean, errors: object[]}>}
+ */
+async function scanPaths(inputPaths, options = {}) {
+ const {
+ maxDepth = DEFAULT_MAX_DEPTH,
+ maxFiles = DEFAULT_MAX_FILES,
+ include = null,
+ exclude = null,
+ followSymlinks = false,
+ recursive = true,
+ } = options;
+
+ const includeSet = include ? new Set(include.map(e => formats.canonicalExt(e)).filter(Boolean)) : null;
+ const excludeSet = exclude ? new Set(exclude.map(e => formats.canonicalExt(e)).filter(Boolean)) : null;
+
+ const files = [];
+ const errors = [];
+ const seen = new Set();
+ let skipped = 0;
+ let truncated = false;
+
+ const accept = (filePath) => {
+ const ext = formats.canonicalExt(path.extname(filePath));
+ if (!ext) { skipped++; return; }
+ if (includeSet && !includeSet.has(ext)) { skipped++; return; }
+ if (excludeSet && excludeSet.has(ext)) { skipped++; return; }
+
+ const key = path.resolve(filePath).toLowerCase();
+ if (seen.has(key)) return;
+ seen.add(key);
+
+ if (files.length >= maxFiles) { truncated = true; return; }
+ files.push(filePath);
+ };
+
+ async function walk(dir, depth) {
+ if (truncated) return;
+
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch (err) {
+ errors.push({ path: dir, error: err.message });
+ return;
+ }
+
+ // Files before directories, so a shallow ingest surfaces usable results
+ // before it disappears into deep subtrees.
+ const dirs = [];
+ for (const entry of entries) {
+ if (truncated) return;
+ const full = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ dirs.push(full);
+ } else if (entry.isFile()) {
+ accept(full);
+ } else if (entry.isSymbolicLink() && followSymlinks) {
+ try {
+ const stat = await fs.stat(full);
+ if (stat.isDirectory()) dirs.push(full);
+ else if (stat.isFile()) accept(full);
+ } catch (err) {
+ errors.push({ path: full, error: err.message });
+ }
+ }
+ }
+
+ if (!recursive || depth >= maxDepth) return;
+ for (const sub of dirs) {
+ if (truncated) return;
+ await walk(sub, depth + 1);
+ }
+ }
+
+ for (const inputPath of inputPaths) {
+ if (truncated) break;
+ let stat;
+ try {
+ stat = await fs.stat(inputPath);
+ } catch (err) {
+ errors.push({ path: inputPath, error: err.message });
+ continue;
+ }
+ if (stat.isDirectory()) await walk(inputPath, 0);
+ else if (stat.isFile()) accept(inputPath);
+ }
+
+ return { files, skipped, truncated, errors };
+}
+
+/**
+ * The common root of a set of paths, used as the mirror root when a folder
+ * ingest should rebuild its structure in the destination.
+ */
+function commonRoot(paths) {
+ if (!paths || paths.length === 0) return null;
+ if (paths.length === 1) return path.dirname(paths[0]);
+
+ const split = paths.map(p => path.resolve(path.dirname(p)).split(/[\\/]/));
+ const first = split[0];
+ const shared = [];
+
+ for (let i = 0; i < first.length; i++) {
+ const segment = first[i];
+ if (split.every(parts => parts[i]?.toLowerCase() === segment.toLowerCase())) shared.push(segment);
+ else break;
+ }
+
+ if (shared.length === 0) return null;
+ // A drive-letter-only result ("E:") needs its separator back to be a path.
+ return shared.length === 1 ? `${shared[0]}${path.sep}` : shared.join(path.sep);
+}
+
+module.exports = {
+ scanPaths,
+ commonRoot,
+ DEFAULT_MAX_DEPTH,
+ DEFAULT_MAX_FILES,
+};
diff --git a/test/core.test.js b/test/core.test.js
new file mode 100644
index 0000000..2d81624
--- /dev/null
+++ b/test/core.test.js
@@ -0,0 +1,631 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const os = require("os");
+const fs = require("fs");
+const path = require("path");
+
+const formats = require("../src/core/formats");
+const { createJob, patchJob, validateJob, STREAM_MODE, CONFLICT, OUTPUT_ROUTING } = require("../src/core/job");
+const { buildArgs, normaliseBitrate, buildScaleFilter } = require("../src/core/ffmpeg-args");
+const paths = require("../src/core/paths");
+const pipeline = require("../src/core/pipeline");
+const { parseProgressLines } = require("../src/core/runner");
+const { scanPaths, commonRoot } = require("../src/core/scan");
+const probe = require("../src/core/probe");
+
+// Find the flag/value pair for a flag, so assertions do not depend on argv order.
+function valueOf(args, flag) {
+ const i = args.indexOf(flag);
+ return i === -1 ? undefined : args[i + 1];
+}
+
+// ---------------------------------------------------------------------------
+// formats
+// ---------------------------------------------------------------------------
+
+test("formats: canonicalises aliases and rejects unknowns", () => {
+ assert.equal(formats.canonicalExt(".JPEG"), "jpg");
+ assert.equal(formats.canonicalExt("tif"), "tiff");
+ assert.equal(formats.canonicalExt("mp4"), "mp4");
+ assert.equal(formats.canonicalExt("exe"), null);
+ assert.equal(formats.canonicalExt(null), null);
+});
+
+test("formats: expresses the cross-kind conversions v1 could not", () => {
+ // The whole point of replacing the within-group cross-product.
+ assert.deepEqual(formats.allowedModes("mp4", "mp3"), [formats.MODE.EXTRACT]);
+ assert.ok(formats.allowedModes("mp4", "png").includes(formats.MODE.FRAMES));
+ assert.deepEqual(formats.allowedModes("png", "mp4"), [formats.MODE.ASSEMBLE]);
+});
+
+test("formats: animated source into a still target explodes into frames", () => {
+ // This subsumes v1's hardcoded isGifToStaticImage special case.
+ assert.equal(formats.defaultMode("gif", "png"), formats.MODE.FRAMES);
+ assert.ok(formats.producesDirectory(formats.MODE.FRAMES));
+ // ...but gif -> animated webp stays a single file.
+ assert.equal(formats.defaultMode("gif", "webp"), formats.MODE.TRANSCODE);
+ assert.equal(formats.producesDirectory(formats.MODE.TRANSCODE), false);
+});
+
+test("formats: audio cannot become video or images", () => {
+ assert.equal(formats.canConvert("mp3", "mp4"), false);
+ assert.equal(formats.canConvert("mp3", "png"), false);
+ assert.equal(formats.canConvert("mp3", "flac"), true);
+});
+
+test("formats: legacy map stays within-group so the v1 grid is unchanged", () => {
+ const map = formats.buildLegacyConversionMap();
+ assert.equal(Object.keys(map).length, 21);
+ assert.equal(map.mp4.type, "video");
+ assert.equal(map.mp4.targets.length, 7);
+ assert.ok(map.mp4.targets.every(t => t.group === "Video"));
+ assert.equal(map.mp3.targets.length, 8);
+ assert.equal(map.jpg.targets.length, 6);
+});
+
+// ---------------------------------------------------------------------------
+// job
+// ---------------------------------------------------------------------------
+
+test("job: fills defaults from the target format", () => {
+ const job = createJob({ inputPath: "C:\\in\\clip.mov", targetExt: "mp4" });
+ assert.equal(job.sourceExt, "mov");
+ assert.equal(job.output.ext, "mp4");
+ assert.equal(job.mode, formats.MODE.TRANSCODE);
+ assert.equal(job.video.codec, "libx264");
+ assert.equal(job.audio.codec, "aac");
+ assert.equal(job.video.mode, STREAM_MODE.ENCODE);
+});
+
+test("job: audio target drops video automatically", () => {
+ const job = createJob({ inputPath: "C:\\in\\clip.mp4", targetExt: "mp3" });
+ assert.equal(job.mode, formats.MODE.EXTRACT);
+ assert.equal(job.video.mode, STREAM_MODE.DROP);
+ assert.equal(job.audio.mode, STREAM_MODE.ENCODE);
+});
+
+test("job: image target drops audio automatically", () => {
+ const job = createJob({ inputPath: "C:\\in\\clip.mp4", targetExt: "png" });
+ assert.equal(job.audio.mode, STREAM_MODE.DROP);
+});
+
+test("job: patch merges nested sections rather than replacing them", () => {
+ const job = createJob({ inputPath: "a.mp4", targetExt: "mp4" });
+ const patched = patchJob(job, { video: { crf: 20 } });
+ assert.equal(patched.video.crf, 20);
+ assert.equal(patched.video.codec, "libx264", "sibling fields must survive the patch");
+});
+
+test("job: validation catches impossible combinations", () => {
+ assert.ok(validateJob(createJob({ inputPath: "a.mp4", targetExt: "mp4" })).valid);
+
+ const noTarget = validateJob(createJob({ inputPath: "a.mp4" }));
+ assert.equal(noTarget.valid, false);
+
+ const bothDropped = createJob({ inputPath: "a.mp4", targetExt: "mp4" });
+ bothDropped.video.mode = STREAM_MODE.DROP;
+ bothDropped.audio.mode = STREAM_MODE.DROP;
+ assert.equal(validateJob(bothDropped).valid, false);
+
+ const badCopy = createJob({ inputPath: "a.mp4", targetExt: "png" });
+ badCopy.video.mode = STREAM_MODE.COPY;
+ assert.equal(validateJob(badCopy).valid, false, "PNG cannot stream-copy");
+
+ const badCodec = createJob({ inputPath: "a.mp4", targetExt: "webm" });
+ badCodec.video.codec = "libx264";
+ assert.equal(validateJob(badCodec).valid, false, "WebM cannot carry H.264");
+
+ const badTrim = createJob({ inputPath: "a.mp4", targetExt: "mp4", trim: { start: 10, end: 5 } });
+ assert.equal(validateJob(badTrim).valid, false);
+});
+
+test("job: validation does not require metadata", () => {
+ // Cards must be valid before the probe returns.
+ const job = createJob({ inputPath: "a.mp4", targetExt: "mp4" });
+ assert.equal(job.inputMeta, null);
+ assert.ok(validateJob(job).valid);
+});
+
+// ---------------------------------------------------------------------------
+// ffmpeg-args
+// ---------------------------------------------------------------------------
+
+test("args: stream copy remuxes without re-encoding", () => {
+ const job = createJob({ inputPath: "in.mkv", targetExt: "mp4" });
+ job.video.mode = STREAM_MODE.COPY;
+ job.audio.mode = STREAM_MODE.COPY;
+ const args = buildArgs(job, { outputPath: "out.mp4" });
+
+ assert.equal(valueOf(args, "-c:v"), "copy");
+ assert.equal(valueOf(args, "-c:a"), "copy");
+ assert.ok(!args.includes("-crf"));
+ assert.equal(args[args.length - 1], "out.mp4");
+});
+
+test("args: re-encode carries crf, preset and a compatible pixel format", () => {
+ const job = createJob({ inputPath: "in.mov", targetExt: "mp4" });
+ job.video.crf = 21;
+ job.video.preset = "slow";
+ job.audio.bitrate = 192;
+ const args = buildArgs(job, { outputPath: "out.mp4" });
+
+ assert.equal(valueOf(args, "-c:v"), "libx264");
+ assert.equal(valueOf(args, "-crf"), "21");
+ assert.equal(valueOf(args, "-preset"), "slow");
+ assert.equal(valueOf(args, "-pix_fmt"), "yuv420p");
+ assert.equal(valueOf(args, "-b:a"), "192k");
+ assert.equal(valueOf(args, "-movflags"), "+faststart");
+});
+
+test("args: VP9 releases the bitrate cap so -crf is constant quality", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "webm" });
+ job.video.crf = 30;
+ const args = buildArgs(job, { outputPath: "out.webm" });
+ assert.equal(valueOf(args, "-crf"), "30");
+ assert.equal(valueOf(args, "-b:v"), "0");
+});
+
+test("args: trim seeks on the input and bounds with a duration", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "mp4", trim: { start: 5, end: 12.5 } });
+ const args = buildArgs(job, { outputPath: "out.mp4" });
+
+ assert.equal(valueOf(args, "-ss"), "5");
+ assert.equal(valueOf(args, "-t"), "7.5", "duration counts from the seek point");
+ assert.ok(args.indexOf("-ss") < args.indexOf("-i"), "-ss must precede -i for a fast seek");
+ assert.ok(args.indexOf("-t") > args.indexOf("-i"), "-t must follow -i");
+});
+
+test("args: audio extraction drops video entirely", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "mp3" });
+ const args = buildArgs(job, { outputPath: "out.mp3" });
+ assert.ok(args.includes("-vn"));
+ assert.equal(valueOf(args, "-c:a"), "libmp3lame");
+});
+
+test("args: resize honours the fit mode", () => {
+ const contain = createJob({ inputPath: "in.mp4", targetExt: "mp4" });
+ contain.video.width = 1280; contain.video.height = 720;
+ assert.equal(valueOf(buildArgs(contain, { outputPath: "o.mp4" }), "-vf"),
+ "scale=1280:720:force_original_aspect_ratio=decrease");
+
+ const cover = patchJob(contain, { video: { fitMode: "cover" } });
+ assert.equal(valueOf(buildArgs(cover, { outputPath: "o.mp4" }), "-vf"),
+ "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720");
+
+ const stretch = patchJob(contain, { video: { fitMode: "stretch" } });
+ assert.equal(valueOf(buildArgs(stretch, { outputPath: "o.mp4" }), "-vf"), "scale=1280:720");
+});
+
+test("args: a single axis keeps the result divisible by two", () => {
+ assert.equal(buildScaleFilter({ video: { width: 640 } }), "scale=640:-2");
+ assert.equal(buildScaleFilter({ video: { height: 480 } }), "scale=-2:480");
+ assert.equal(buildScaleFilter({ video: {} }), null);
+});
+
+test("args: image quality uses the target format's own flag and scale", () => {
+ const jpg = createJob({ inputPath: "in.png", targetExt: "jpg" });
+ assert.equal(valueOf(buildArgs(jpg, { outputPath: "o.jpg" }), "-q:v"), "3");
+
+ const png = createJob({ inputPath: "in.jpg", targetExt: "png" });
+ assert.equal(valueOf(buildArgs(png, { outputPath: "o.png" }), "-compression_level"), "6");
+
+ const webp = createJob({ inputPath: "in.png", targetExt: "webp" });
+ assert.equal(valueOf(buildArgs(webp, { outputPath: "o.webp" }), "-quality"), "80");
+
+ // Out-of-range values are clamped to the format's own bounds.
+ const clamped = patchJob(jpg, { image: { quality: 99 } });
+ assert.equal(valueOf(buildArgs(clamped, { outputPath: "o.jpg" }), "-q:v"), "31");
+});
+
+test("args: frame extraction writes a numbered pattern inside the output dir", () => {
+ const job = createJob({ inputPath: "in.gif", targetExt: "png" });
+ assert.equal(job.mode, formats.MODE.FRAMES);
+ const args = buildArgs(job, { outputPath: path.join("C:", "out", "clip") });
+
+ assert.ok(args[args.length - 1].endsWith(`frame_%04d.png`));
+ assert.equal(valueOf(args, "-fps_mode"), "passthrough");
+});
+
+test("args: GIF output builds a palette so it is not a 256-colour mess", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "gif" });
+ job.video.fps = 15;
+ const args = buildArgs(job, { outputPath: "out.gif" });
+ const fc = valueOf(args, "-filter_complex");
+
+ assert.ok(fc.includes("palettegen"));
+ assert.ok(fc.includes("paletteuse"));
+ assert.ok(fc.includes("fps=15"), "preceding filters must survive into the palette chain");
+ assert.ok(!args.includes("-vf"), "the palette chain replaces -vf, it does not coexist with it");
+});
+
+test("args: assembling stills into video loops a single image", () => {
+ const job = createJob({ inputPath: "still.png", targetExt: "mp4" });
+ assert.equal(job.mode, formats.MODE.ASSEMBLE);
+ const args = buildArgs(job, { outputPath: "out.mp4" });
+ assert.ok(args.includes("-loop"));
+ assert.equal(valueOf(args, "-framerate"), "25");
+});
+
+test("args: progress is machine-readable by default", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "mp4" });
+ const args = buildArgs(job, { outputPath: "o.mp4" });
+ assert.equal(valueOf(args, "-progress"), "pipe:1");
+ assert.ok(args.includes("-nostats"));
+
+ const quiet = buildArgs(job, { outputPath: "o.mp4", progress: false });
+ assert.ok(!quiet.includes("-progress"));
+});
+
+test("args: bitrate accepts the forms a UI will actually produce", () => {
+ assert.equal(normaliseBitrate(192), "192k");
+ assert.equal(normaliseBitrate("192k"), "192k");
+ assert.equal(normaliseBitrate(192000), "192k");
+ assert.equal(normaliseBitrate("320K"), "320k");
+});
+
+test("args: an unresolvable output path is a hard error, not a silent skip", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "mp4" });
+ assert.throws(() => buildArgs(job, {}), /outputPath/);
+});
+
+// ---------------------------------------------------------------------------
+// paths
+// ---------------------------------------------------------------------------
+
+test("paths: name templating expands the documented tokens", () => {
+ const now = new Date(2026, 7, 27, 14, 5, 9);
+ const out = paths.applyNameTemplate("{name}_{src}-to-{ext}_{index}_{date}_{time}", {
+ name: "holiday", src: "mov", ext: "mp4", index: 3, now,
+ });
+ assert.equal(out, "holiday_mov-to-mp4_3_2026-08-27_140509");
+});
+
+test("paths: unknown tokens are left visible rather than silently blanked", () => {
+ assert.equal(paths.applyNameTemplate("{name}-{nope}", { name: "a" }), "a-{nope}");
+});
+
+test("paths: names are made safe for Windows", () => {
+ assert.equal(paths.sanitiseName('ac:d"e/f\\g|h?i*j'), "a_b_c_d_e_f_g_h_i_j");
+ assert.equal(paths.sanitiseName("trailing dots..."), "trailing dots");
+ assert.equal(paths.sanitiseName(" "), "output");
+});
+
+test("paths: mirror routing rebuilds the source tree under the destination", () => {
+ const job = createJob({
+ inputPath: path.join("C:", "src", "a", "b", "clip.mov"),
+ targetExt: "mp4",
+ output: {
+ routing: OUTPUT_ROUTING.MIRROR,
+ mirrorRoot: path.join("C:", "src"),
+ dir: path.join("D:", "out"),
+ },
+ });
+ assert.equal(paths.resolveOutputDir(job), path.join("D:", "out", "a", "b"));
+});
+
+test("paths: alongside routing reproduces v1 behaviour exactly", () => {
+ const job = createJob({ inputPath: path.join("C:", "src", "clip.mov"), targetExt: "mp4" });
+ assert.equal(paths.resolveOutputDir(job), path.join("C:", "src"));
+});
+
+test("paths: conflict policies resolve against the real filesystem", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dfc-paths-"));
+ try {
+ const input = path.join(dir, "clip.mov");
+ fs.writeFileSync(input, "");
+ fs.writeFileSync(path.join(dir, "clip.mp4"), ""); // the collision
+
+ const base = { inputPath: input, targetExt: "mp4" };
+
+ const unique = paths.resolveOutputPath(createJob({ ...base, output: { onConflict: CONFLICT.UNIQUE } }));
+ assert.equal(unique.action, "write");
+ assert.equal(path.basename(unique.outputPath), "clip (1).mp4");
+
+ const overwrite = paths.resolveOutputPath(createJob({ ...base, output: { onConflict: CONFLICT.OVERWRITE } }));
+ assert.equal(overwrite.action, "write");
+ assert.equal(path.basename(overwrite.outputPath), "clip.mp4");
+
+ assert.equal(paths.resolveOutputPath(createJob({ ...base, output: { onConflict: CONFLICT.SKIP } })).action, "skip");
+ assert.equal(paths.resolveOutputPath(createJob({ ...base, output: { onConflict: CONFLICT.ASK } })).action, "ask");
+
+ // No collision at all, so the policy never comes into play.
+ const clean = paths.resolveOutputPath(createJob({ ...base, targetExt: "mkv" }));
+ assert.equal(clean.action, "write");
+ assert.equal(path.basename(clean.outputPath), "clip.mkv");
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("paths: frame extraction resolves to a directory, not a file", () => {
+ const job = createJob({ inputPath: path.join("C:", "src", "loop.gif"), targetExt: "png" });
+ const resolved = paths.resolveOutputPath(job);
+ assert.equal(resolved.isDirectory, true);
+ assert.equal(path.basename(resolved.outputPath), "loop");
+});
+
+// ---------------------------------------------------------------------------
+// pipeline
+// ---------------------------------------------------------------------------
+
+function linearPipeline() {
+ return pipeline.createPipeline({
+ name: "scale then output",
+ nodes: [
+ { id: "in", type: "input" },
+ { id: "sc", type: "scale", params: { width: 640, height: 360, fitMode: "contain" } },
+ { id: "out", type: "output" },
+ ],
+ edges: [
+ { from: "in", fromPort: "video", to: "sc", toPort: "in" },
+ { from: "sc", fromPort: "out", to: "out", toPort: "video" },
+ { from: "in", fromPort: "audio", to: "out", toPort: "audio" },
+ ],
+ });
+}
+
+test("pipeline: a linear graph compiles to filter_complex and maps", () => {
+ const compiled = pipeline.compile(linearPipeline());
+ assert.match(compiled.filterComplex, /^\[0:v\]scale=640:360:force_original_aspect_ratio=decrease\[v\d\]$/);
+ assert.equal(compiled.maps.length, 2);
+ assert.ok(compiled.maps.some(m => m === "[0:a]"), "audio passes straight through");
+});
+
+test("pipeline: cycles are rejected", () => {
+ const cyclic = pipeline.createPipeline({
+ nodes: [
+ { id: "in", type: "input" },
+ { id: "a", type: "fps", params: { fps: 30 } },
+ { id: "b", type: "fps", params: { fps: 60 } },
+ { id: "out", type: "output" },
+ ],
+ edges: [
+ { from: "in", fromPort: "video", to: "a", toPort: "in" },
+ { from: "a", fromPort: "out", to: "b", toPort: "in" },
+ { from: "b", fromPort: "out", to: "a", toPort: "in" }, // the cycle
+ { from: "b", fromPort: "out", to: "out", toPort: "video" },
+ ],
+ });
+
+ const result = pipeline.validate(cyclic);
+ assert.equal(result.valid, false);
+ assert.ok(result.errors.some(e => /cycle/i.test(e)));
+ assert.throws(() => pipeline.compile(cyclic), /not valid/);
+});
+
+test("pipeline: mismatched port types are rejected", () => {
+ const bad = pipeline.createPipeline({
+ nodes: [
+ { id: "in", type: "input" },
+ { id: "v", type: "volume", params: { volume: 2 } },
+ { id: "out", type: "output" },
+ ],
+ edges: [
+ { from: "in", fromPort: "video", to: "v", toPort: "in" }, // video into an audio filter
+ { from: "v", fromPort: "out", to: "out", toPort: "audio" },
+ ],
+ });
+ const result = pipeline.validate(bad);
+ assert.equal(result.valid, false);
+ assert.ok(result.errors.some(e => /Cannot connect video output/.test(e)));
+});
+
+test("pipeline: missing required settings are rejected", () => {
+ const bad = pipeline.createPipeline({
+ nodes: [
+ { id: "in", type: "input" },
+ { id: "c", type: "crop", params: { width: 100 } }, // height missing
+ { id: "out", type: "output" },
+ ],
+ edges: [
+ { from: "in", fromPort: "video", to: "c", toPort: "in" },
+ { from: "c", fromPort: "out", to: "out", toPort: "video" },
+ ],
+ });
+ const result = pipeline.validate(bad);
+ assert.equal(result.valid, false);
+ assert.ok(result.errors.some(e => /height/.test(e)));
+});
+
+test("pipeline: a graph with no output node is rejected", () => {
+ const bad = pipeline.createPipeline({ nodes: [{ id: "in", type: "input" }], edges: [] });
+ assert.equal(pipeline.validate(bad).valid, false);
+});
+
+test("pipeline: disconnected nodes warn but do not fail", () => {
+ const graph = linearPipeline();
+ graph.nodes.push({ id: "orphan", type: "fps", params: { fps: 10 } });
+ const result = pipeline.validate(graph);
+ assert.equal(result.valid, true);
+ assert.ok(result.warnings.some(w => /orphan/.test(w)));
+});
+
+test("pipeline: an ANY-typed node takes its type from what feeds it", () => {
+ const graph = pipeline.createPipeline({
+ nodes: [
+ { id: "in", type: "input" },
+ { id: "t", type: "trim", params: { start: 1, end: 4 } },
+ { id: "out", type: "output" },
+ ],
+ edges: [
+ { from: "in", fromPort: "audio", to: "t", toPort: "in" },
+ { from: "t", fromPort: "out", to: "out", toPort: "audio" },
+ ],
+ });
+ const compiled = pipeline.compile(graph);
+ assert.match(compiled.filterComplex, /atrim=start=1:end=4,asetpts=PTS-STARTPTS/);
+});
+
+test("pipeline: a no-op node forwards its input instead of emitting an empty link", () => {
+ const graph = pipeline.createPipeline({
+ nodes: [
+ { id: "in", type: "input" },
+ { id: "t", type: "trim", params: {} }, // no bounds set
+ { id: "out", type: "output" },
+ ],
+ edges: [
+ { from: "in", fromPort: "video", to: "t", toPort: "in" },
+ { from: "t", fromPort: "out", to: "out", toPort: "video" },
+ ],
+ });
+ const compiled = pipeline.compile(graph);
+ assert.equal(compiled.filterComplex, null);
+ assert.deepEqual(compiled.maps, ["[0:v]"]);
+});
+
+test("pipeline: applying to a job produces runnable ffmpeg args", () => {
+ const job = createJob({ inputPath: "in.mp4", targetExt: "mp4" });
+ const piped = pipeline.applyToJob(job, linearPipeline());
+ const args = buildArgs(piped, { outputPath: "out.mp4" });
+
+ assert.ok(args.includes("-filter_complex"));
+ assert.equal(args.filter(a => a === "-map").length, 2);
+});
+
+// ---------------------------------------------------------------------------
+// runner progress parsing
+// ---------------------------------------------------------------------------
+
+test("runner: progress is derived from out_time against a known duration", () => {
+ const update = parseProgressLines(
+ ["out_time_us=5000000", "speed=2.0x", "fps=48.0", "progress=continue"],
+ 10
+ );
+ assert.equal(update.percent, 50);
+ assert.equal(update.speed, 2);
+ assert.equal(update.fps, 48);
+ assert.equal(update.eta, 2.5);
+});
+
+test("runner: unknown duration yields indeterminate progress, not a frozen zero", () => {
+ // This is exactly the case where v1's bar sat at 0% for the whole job.
+ const update = parseProgressLines(["out_time_us=1000000", "progress=continue"], null);
+ assert.equal(update.percent, null);
+ assert.equal(update.outSeconds, 1);
+});
+
+test("runner: the end marker completes even without a duration", () => {
+ assert.equal(parseProgressLines(["progress=end"], null).percent, 100);
+});
+
+test("runner: progress never reaches 100 before the end marker", () => {
+ const update = parseProgressLines(["out_time_us=20000000", "progress=continue"], 10);
+ assert.ok(update.percent < 100);
+});
+
+test("runner: uninteresting blocks are dropped", () => {
+ assert.equal(parseProgressLines(["bitrate=N/A", "garbage"], 10), null);
+});
+
+// ---------------------------------------------------------------------------
+// scan
+// ---------------------------------------------------------------------------
+
+test("scan: walks recursively and filters by the format graph", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dfc-scan-"));
+ try {
+ fs.mkdirSync(path.join(root, "nested", "deep"), { recursive: true });
+ fs.writeFileSync(path.join(root, "a.mp3"), "");
+ fs.writeFileSync(path.join(root, "notes.txt"), "");
+ fs.writeFileSync(path.join(root, "nested", "b.mp4"), "");
+ fs.writeFileSync(path.join(root, "nested", "deep", "c.png"), "");
+
+ const all = await scanPaths([root]);
+ assert.equal(all.files.length, 3, "v1 would have found only the top-level file");
+ assert.equal(all.skipped, 1, "the .txt is skipped");
+
+ const shallow = await scanPaths([root], { recursive: false });
+ assert.equal(shallow.files.length, 1);
+
+ const depthOne = await scanPaths([root], { maxDepth: 1 });
+ assert.equal(depthOne.files.length, 2);
+
+ const onlyAudio = await scanPaths([root], { include: ["mp3"] });
+ assert.equal(onlyAudio.files.length, 1);
+
+ const noImages = await scanPaths([root], { exclude: ["png"] });
+ assert.equal(noImages.files.length, 2);
+
+ const capped = await scanPaths([root], { maxFiles: 2 });
+ assert.equal(capped.files.length, 2);
+ assert.equal(capped.truncated, true);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("scan: deduplicates and reports unreadable paths without throwing", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dfc-scan2-"));
+ try {
+ const file = path.join(root, "a.mp3");
+ fs.writeFileSync(file, "");
+
+ const result = await scanPaths([file, file, path.join(root, "missing.mp4")]);
+ assert.equal(result.files.length, 1);
+ assert.equal(result.errors.length, 1);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("scan: common root finds the shared ancestor for mirrored output", () => {
+ const a = path.join("C:", "media", "a", "one.mp4");
+ const b = path.join("C:", "media", "b", "two.mp4");
+ assert.equal(commonRoot([a, b]), path.join("C:", "media"));
+ assert.equal(commonRoot([a]), path.join("C:", "media", "a"));
+ assert.equal(commonRoot([]), null);
+});
+
+// ---------------------------------------------------------------------------
+// probe
+// ---------------------------------------------------------------------------
+
+test("probe: normalises ffprobe JSON into the shape the UI binds to", () => {
+ const raw = {
+ format: { duration: "12.5", size: "1048576", bit_rate: "800000", format_name: "mov,mp4,m4a" },
+ streams: [
+ { codec_type: "video", codec_name: "h264", width: 1920, height: 1080, avg_frame_rate: "30000/1001", pix_fmt: "yuv420p" },
+ { codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2, channel_layout: "stereo" },
+ ],
+ };
+ const meta = probe.normalise(raw, "clip.mp4");
+
+ assert.equal(meta.ok, true);
+ assert.equal(meta.duration, 12.5);
+ assert.equal(meta.hasVideo, true);
+ assert.equal(meta.hasAudio, true);
+ assert.equal(meta.video.width, 1920);
+ assert.equal(meta.video.fps, 29.97);
+ assert.equal(meta.audio.channels, 2);
+});
+
+test("probe: cover art does not make an MP3 look like a video", () => {
+ const raw = {
+ format: { duration: "180" },
+ streams: [
+ { codec_type: "video", codec_name: "mjpeg", width: 600, height: 600, disposition: { attached_pic: 1 } },
+ { codec_type: "audio", codec_name: "mp3", sample_rate: "44100", channels: 2 },
+ ],
+ };
+ const meta = probe.normalise(raw, "song.mp3");
+ assert.equal(meta.hasVideo, false);
+ assert.equal(meta.streamCounts.video, 0);
+});
+
+test("probe: rational frame rates parse, degenerate ones do not", () => {
+ assert.equal(probe.parseFrameRate("30000/1001"), 29.97);
+ assert.equal(probe.parseFrameRate("25/1"), 25);
+ assert.equal(probe.parseFrameRate("0/0"), null);
+ assert.equal(probe.parseFrameRate("N/A"), null);
+});
+
+test("probe: an unconfigured binary degrades instead of throwing", async () => {
+ const before = probe.getFfprobePath();
+ probe.setFfprobePath(null);
+ const result = await probe.probe("anything.mp4");
+ assert.equal(result.ok, false);
+ assert.match(result.error, /not been configured/);
+ probe.setFfprobePath(before);
+});
From bcdb2c9a4674a17b728d3f457b48038c3d40446b Mon Sep 17 00:00:00 2001
From: Will Knowles
Date: Thu, 27 Aug 2026 19:37:49 +0100
Subject: [PATCH 03/81] feat: move the renderer behind a context-isolated
bridge, add token layer
The renderer ran with nodeIntegration:true and contextIsolation:false and no
preload, requiring fs, path and electron directly. It now has no Node access
at all; everything goes through window.electronAPI, matching the name the
Dropgate client already uses.
- preload.js exposes the bridge. It runs sandboxed, where require() is limited
to a small allowlist, so its channel names are inlined rather than imported
from constants.js. Requiring anything else there silently leaves
window.electronAPI undefined and the app inert with no error in main, so
test/preload.test.js guards both the allowlist and channel-name drift.
- IPC channels are namespaced by domain. Progress and status now carry a
jobId, which is what makes concurrency possible; v1 pushed a bare integer
with no way to tell which file it belonged to. The convert result is an
object rather than an overloaded string|'cancelled'|null whose string could
be either a file or a directory.
- The renderer no longer stats the filesystem to discover whether an output
was a folder of frames; the result carries isDirectory and fileCount.
- Directory expansion on drop moves to the main process.
- Add a document-level drop guard. Dropping outside the zone previously
navigated the window to the file, replacing the app with it.
- Fix dragleave firing on child boundaries, which made the highlight flicker.
- credits.html loses its require() and inline onclick handlers.
- Handle file associations properly: parse argv, add an open-file handler, and
forward paths from second-instance, which previously discarded them so
'Open with' on a running app did nothing. Explorer spawns one process per
selected file, so arrivals are debounced.
- Extend electron-store beyond windowBounds with a real settings schema.
- Remove dead code: the unused get-version/get-setting/set-setting handlers,
the globalShortcut import, and the .btn-accent, .bg-accent and
.btn-format-disabled rules.
styles/tokens.css is the first real token layer in any of these apps, and it
fixes a live bug: --bs-primary was remapped to the accent only inside
[data-bs-theme=dark], so the primary Convert button rendered Bootstrap blue in
light mode and green in dark. It is now bound in both themes and on the bare
:root, which also covers the system-default case.
---
src/constants.js | 136 ++++++------
src/credits.html | 41 ++--
src/index.html | 1 +
src/main.js | 474 +++++++++++++++++++++++++-----------------
src/preload.js | 126 +++++++++++
src/renderer.js | 172 ++++++++++-----
src/styles.css | 74 +++----
src/styles/tokens.css | 95 +++++++++
test/preload.test.js | 130 ++++++++++++
9 files changed, 865 insertions(+), 384 deletions(-)
create mode 100644 src/preload.js
create mode 100644 src/styles/tokens.css
create mode 100644 test/preload.test.js
diff --git a/src/constants.js b/src/constants.js
index 56b1271..a04297f 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -7,19 +7,51 @@ const LOG = {
DEBUG: "DEBUG",
};
-// ipc channel names
+// IPC channel names.
+//
+// Namespaced by domain. v1 kept a flat map that also carried an
+// electron-updater event name ("update-available") alongside real IPC
+// channels, which made the two easy to confuse.
const IPC = {
- GET_VERSION: "get-version",
- GET_SETTING: "get-setting",
- SET_SETTING: "set-setting",
- UPDATE_AVAILABLE: "update-available",
- BROWSE_FILE: "browse-file",
- CONVERT_FILE: "convert-file",
- CANCEL_CONVERT: "cancel-convert",
- OPEN_FOLDER: "open-folder",
- SHOW_IN_FOLDER: "show-in-folder",
- CONVERSION_PROGRESS: "conversion-progress", // push: percent (0-100)
- FILE_OPENED_FROM_MENU: "file-opened-from-menu", // push: file path string
+ // Jobs and the queue. Progress and status both carry a jobId, which is
+ // what makes concurrency possible at all; v1 pushed a bare integer with
+ // no way to tell which file it belonged to.
+ JOB_RUN: "job:run", // invoke(job) -> result
+ JOB_CANCEL: "job:cancel", // invoke(jobId) -> boolean
+ JOB_PROGRESS: "job:progress", // push { jobId, percent, speed, fps, eta }
+ JOB_STATUS: "job:status", // push { jobId, status, outputPath?, error? }
+ QUEUE_CANCEL_ALL: "queue:cancel-all",
+ QUEUE_SET_CONCURRENCY: "queue:set-concurrency",
+
+ // Inspection and ingest.
+ PROBE_FILE: "probe:file", // invoke(path) -> metadata
+ FS_SCAN: "fs:scan", // invoke(paths, opts) -> { files, skipped, ... }
+
+ // Dialogs.
+ DIALOG_BROWSE_FILES: "dialog:browse-files",
+ DIALOG_BROWSE_FOLDER: "dialog:browse-folder",
+ DIALOG_CHOOSE_OUTPUT: "dialog:choose-output",
+
+ // Presets and pipelines. The store is wired; the editors are TODO.
+ PRESET_LIST: "preset:list",
+ PRESET_SAVE: "preset:save",
+ PRESET_DELETE: "preset:delete",
+ PIPELINE_LIST: "pipeline:list",
+ PIPELINE_SAVE: "pipeline:save",
+ PIPELINE_DELETE: "pipeline:delete",
+ PIPELINE_VALIDATE: "pipeline:validate",
+
+ // Shell and app.
+ SHELL_OPEN_PATH: "shell:open-path",
+ SHELL_SHOW_IN_FOLDER: "shell:show-in-folder",
+ SHELL_OPEN_EXTERNAL: "shell:open-external",
+ APP_GET_VERSION: "app:get-version",
+ APP_GET_FORMATS: "app:get-formats",
+ SETTINGS_GET: "settings:get",
+ SETTINGS_SET: "settings:set",
+
+ // Pushed from main.
+ FILES_OPENED: "files:opened", // push: string[] of paths from menu/argv/shell
};
// window size constraints
@@ -30,68 +62,26 @@ const WINDOW = {
MIN_HEIGHT: 600,
};
-// to add a new format, add an entry with ext and label to the appropriate array
-// to add a new format, group add a new array and call buildEntries in CONVERSION_MAP
-const AUDIO_FORMATS = [
- { ext: "mp3", label: "MP3" },
- { ext: "wav", label: "WAV" },
- { ext: "flac", label: "FLAC" },
- { ext: "ogg", label: "OGG" },
- { ext: "aac", label: "AAC" },
- { ext: "m4a", label: "M4A" },
- { ext: "opus", label: "OPUS" },
- { ext: "wma", label: "WMA" },
-];
-
-const VIDEO_FORMATS = [
- { ext: "mp4", label: "MP4" },
- { ext: "mkv", label: "MKV" },
- { ext: "webm", label: "WebM" },
- { ext: "avi", label: "AVI" },
- { ext: "mov", label: "MOV" },
- { ext: "wmv", label: "WMV" },
- { ext: "flv", label: "FLV" },
-];
+// Windows spawns one process per file when several are selected in Explorer,
+// so incoming paths are collected before being handed to the renderer.
+const ARGV_BATCH_DEBOUNCE_MS = 500;
-const IMAGE_FORMATS = [
- { ext: "jpg", label: "JPG" },
- { ext: "png", label: "PNG" },
- { ext: "webp", label: "WebP" },
- { ext: "gif", label: "GIF" },
- { ext: "bmp", label: "BMP" },
- { ext: "tiff", label: "TIFF" },
-];
-
-function buildEntries(formats, type, group) {
- const entries = {};
- for (const fmt of formats) {
- entries[fmt.ext] = {
- type,
- targets: formats.map(f => ({ ...f, group }))
- };
- }
- return entries;
-}
-
-const CONVERSION_MAP = {
- ...buildEntries(AUDIO_FORMATS, "audio", "Audio"),
- ...buildEntries(VIDEO_FORMATS, "video", "Video"),
- ...buildEntries(IMAGE_FORMATS, "image", "Image"),
+// Defaults written into electron-store on first run. v1 persisted nothing but
+// windowBounds, so there were no user settings at all.
+const SETTINGS_DEFAULTS = {
+ outputRouting: "alongside",
+ outputDir: null,
+ onConflict: "unique",
+ concurrency: null, // null = derive from the CPU count
+ nameTemplate: "{name}",
+ lastTargetByKind: {},
};
-const SUPPORTED_EXTENSIONS = Object.keys(CONVERSION_MAP);
-
-// extension aliases, maps alternate extensions to their canonical key in CONVERSION_MAP
-const EXT_ALIASES = {
- jpeg: "jpg",
- tif: "tiff",
- jfif: "jpg",
+module.exports = {
+ APP_NAME,
+ IPC,
+ WINDOW,
+ LOG,
+ ARGV_BATCH_DEBOUNCE_MS,
+ SETTINGS_DEFAULTS,
};
-
-const STATIC_IMAGE_EXTS = new Set(["jpg", "png", "webp", "bmp", "tiff"]);
-
-function isGifToStaticImage(sourceExt, targetExt) {
- return sourceExt === "gif" && STATIC_IMAGE_EXTS.has(targetExt);
-}
-
-module.exports = { APP_NAME, IPC, WINDOW, LOG, AUDIO_FORMATS, VIDEO_FORMATS, IMAGE_FORMATS, CONVERSION_MAP, SUPPORTED_EXTENSIONS, EXT_ALIASES, isGifToStaticImage };
diff --git a/src/credits.html b/src/credits.html
index d743595..5aafab8 100644
--- a/src/credits.html
+++ b/src/credits.html
@@ -6,17 +6,17 @@
Diamond File Converter | Credits
+
-
@@ -27,8 +27,8 @@
Diamond File Converter
- • Created and maintained by Diamond Digital Development.
- • Logo designed by TheFuturisticIdiot.
+ • Created and maintained by Diamond Digital Development.
+ • Logo designed by TheFuturisticIdiot.
• This software is licensed under the Apache 2.0 license.
• If you enjoy this software, consider donating to support development!
@@ -40,18 +40,29 @@ Diamond File Converter
+
-
+
-
-
-
Diamond File Converter
+
-
-
+
+
+
upload_file
-
Drag & Drop Files Here
+
Drag & Drop Files or Folders Here
or
-
-
add_circle_outline
-
-
drag and drop more files or
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- sync
- Converting...
-
-
0%
+
+
+
checklist
+
+
+
+
+
+
+
+
-
-
-
-
-
check_circle
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+